Wave 2 = ALLE app-files die in Wave 1 noch hardcoded waren. Komplette App-weit
theme-aware-Migration jetzt durch. Legacy `import { colors }` flat export
vollständig eliminiert.
Migrated this wave:
Top-level Screens:
- app/urge.tsx (makeStyles factory mit ~20 colors)
- app/room.tsx + dm.tsx + games.tsx
- app/(app)/chat.tsx + mail.tsx + coach.tsx + notifications.tsx
- app/profile/[userId].tsx + profile/edit.tsx (INPUT_STYLE in body moved)
- app/debug.tsx + auth/callback.tsx
Blocker (7):
- AddDomainSheet, CooldownBanner, DeactivationExplainerSheet, DomainGrid,
ProtectionCard, ProtectionDetailsSheet, ProtectionLockedCard
Mail (3):
- ConnectMailSheet, EditMailAccountSheet, MailEmptyState
Chat (1):
- ChatBubble, ChatInput
Community/Posts/Notifications:
- PostCard, PostCardSkeleton, ComposeCard, PostCommentsSheet
- NotificationsDropdown
- StreakBadge (Nativewind classes durch inline dynamic styles ersetzt)
Reusable Sheets:
- WheelPickerModal, OptionsBottomSheet, DeviceLimitReachedSheet
Urge subsystem (5):
- InlineRatingDrawer, ShareSuccessDrawer, UrgeStats, SosFeedbackModal,
Breathing
Profile components:
- DigaMissionBanner
Pattern: useColors() hook in component body, makeStyles(colors) factory wo
StyleSheet.create vorher hardcoded war. 11 base-tokens (bg/surface/
surfaceElevated/border/text/textMuted/brandOrange/brandBlue/success/error/
warning) nutzen colors.light vs colors.dark scheme.
Bewusst NICHT migriert (semantic colors):
- DigaMissionBanner amber (#fffbeb, #854d0e) — DiGA-brand, nicht neutral
- Lyra-thinking #3b82f6 in urge.tsx — Lyra-brand-color
- scrollDownBtn #374151 — intentional dark floating-button
TS clean. Test: Settings → Theme → Dark — alle screens sollen jetzt dunkel
werden ohne white-flashes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
340 lines
9.1 KiB
TypeScript
340 lines
9.1 KiB
TypeScript
import { useState, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
Pressable,
|
|
Image,
|
|
StyleSheet,
|
|
ActivityIndicator,
|
|
Platform,
|
|
Alert,
|
|
} from 'react-native';
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
|
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { supabase } from '../../lib/supabase';
|
|
import { useColors } from '../../lib/theme';
|
|
|
|
type ReplyTo = { id: string; nickname: string; content: string };
|
|
|
|
export type SendPayload = {
|
|
content: string;
|
|
replyToId?: string;
|
|
attachmentUrl?: string;
|
|
attachmentType?: string;
|
|
attachmentName?: string;
|
|
};
|
|
|
|
type Props = {
|
|
replyTo: ReplyTo | null;
|
|
sending?: boolean;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
onSend: (data: SendPayload) => void;
|
|
onCancelReply: () => void;
|
|
};
|
|
|
|
export function ChatInput({
|
|
replyTo,
|
|
sending,
|
|
placeholder,
|
|
disabled,
|
|
onSend,
|
|
onCancelReply,
|
|
}: Props) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const [text, setText] = useState('');
|
|
const [attachment, setAttachment] = useState<{
|
|
uri: string;
|
|
name: string;
|
|
isImage: boolean;
|
|
} | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const inputRef = useRef<TextInput>(null);
|
|
|
|
const hasContent = text.trim().length > 0 || attachment !== null;
|
|
|
|
async function pickImage() {
|
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (!perm.granted) {
|
|
Alert.alert('Foto-Zugriff', 'Bitte Foto-Zugriff in den Einstellungen erlauben.');
|
|
return;
|
|
}
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
quality: 0.8,
|
|
});
|
|
if (!result.canceled && result.assets[0]?.uri) {
|
|
const a = result.assets[0];
|
|
setAttachment({
|
|
uri: a.uri,
|
|
name: a.fileName ?? `image-${Date.now()}.jpg`,
|
|
isImage: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
function clearAttachment() {
|
|
setAttachment(null);
|
|
}
|
|
|
|
async function uploadAttachment(): Promise<{
|
|
url: string;
|
|
type: string;
|
|
name: string;
|
|
} | null> {
|
|
if (!attachment) return null;
|
|
try {
|
|
setUploading(true);
|
|
const ext = attachment.name.split('.').pop() || 'jpg';
|
|
const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
|
const base64 = await FileSystem.readAsStringAsync(attachment.uri, {
|
|
encoding: FileSystem.EncodingType.Base64,
|
|
});
|
|
const arrayBuffer = decodeBase64(base64);
|
|
const { error } = await supabase.storage
|
|
.from('chat-attachments')
|
|
.upload(path, arrayBuffer, {
|
|
cacheControl: '3600',
|
|
upsert: false,
|
|
contentType: attachment.isImage ? 'image/jpeg' : 'application/octet-stream',
|
|
});
|
|
if (error) throw error;
|
|
const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path);
|
|
return {
|
|
url: data.publicUrl,
|
|
type: attachment.isImage ? 'image' : 'file',
|
|
name: attachment.name,
|
|
};
|
|
} catch (err: any) {
|
|
Alert.alert(t('chat.upload_failed'), err?.message ?? '');
|
|
return null;
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
async function handleSend() {
|
|
const content = text.trim();
|
|
if (!content && !attachment) return;
|
|
|
|
let attachmentMeta: { url: string; type: string; name: string } | null = null;
|
|
if (attachment) {
|
|
attachmentMeta = await uploadAttachment();
|
|
if (!attachmentMeta) return;
|
|
}
|
|
|
|
onSend({
|
|
content,
|
|
replyToId: replyTo?.id,
|
|
attachmentUrl: attachmentMeta?.url,
|
|
attachmentType: attachmentMeta?.type,
|
|
attachmentName: attachmentMeta?.name,
|
|
});
|
|
setText('');
|
|
setAttachment(null);
|
|
}
|
|
|
|
const styles = makeStyles(colors);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{/* Reply preview */}
|
|
{replyTo && (
|
|
<View style={styles.replyBar}>
|
|
<Ionicons name="arrow-undo" size={14} color="#007AFF" style={{ marginRight: 6 }} />
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.replyName} numberOfLines={1}>
|
|
{t('chat.reply_to')} {replyTo.nickname}
|
|
</Text>
|
|
<Text style={styles.replyContent} numberOfLines={1}>
|
|
{replyTo.content || '…'}
|
|
</Text>
|
|
</View>
|
|
<Pressable hitSlop={10} onPress={onCancelReply}>
|
|
<Ionicons name="close" size={16} color="#737373" />
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
{/* Attachment preview */}
|
|
{attachment && (
|
|
<View style={styles.attachBar}>
|
|
{attachment.isImage ? (
|
|
<Image source={{ uri: attachment.uri }} style={styles.attachImg} />
|
|
) : (
|
|
<View style={styles.attachFileIcon}>
|
|
<Ionicons name="document" size={18} color="#737373" />
|
|
</View>
|
|
)}
|
|
<Text style={styles.attachName} numberOfLines={1}>
|
|
{attachment.name}
|
|
</Text>
|
|
<Pressable hitSlop={10} onPress={clearAttachment}>
|
|
<Ionicons name="close" size={16} color="#737373" />
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
{/* Input row */}
|
|
<View style={styles.row}>
|
|
<Pressable
|
|
style={styles.iconBtn}
|
|
onPress={pickImage}
|
|
disabled={uploading || sending || disabled}
|
|
>
|
|
<Ionicons name="image-outline" size={22} color="#737373" />
|
|
</Pressable>
|
|
|
|
<View style={styles.inputWrap}>
|
|
<TextInput
|
|
ref={inputRef}
|
|
value={text}
|
|
onChangeText={setText}
|
|
placeholder={placeholder ?? t('chat.placeholder')}
|
|
placeholderTextColor="#a3a3a3"
|
|
multiline
|
|
maxLength={2000}
|
|
editable={!sending && !disabled}
|
|
style={styles.input}
|
|
/>
|
|
</View>
|
|
|
|
<Pressable
|
|
onPress={handleSend}
|
|
disabled={!hasContent || sending || uploading || disabled}
|
|
style={[
|
|
styles.sendBtn,
|
|
{ backgroundColor: hasContent ? '#007AFF' : '#e5e5e5' },
|
|
]}
|
|
>
|
|
{sending || uploading ? (
|
|
<ActivityIndicator size="small" color="#fff" />
|
|
) : (
|
|
<Ionicons name="send" size={16} color={hasContent ? '#fff' : '#a3a3a3'} />
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Base64 → Uint8Array (für Supabase Storage Upload)
|
|
function decodeBase64(base64: string): Uint8Array {
|
|
const binary =
|
|
typeof atob === 'function'
|
|
? atob(base64)
|
|
: Buffer.from(base64, 'base64').toString('binary');
|
|
const len = binary.length;
|
|
const bytes = new Uint8Array(len);
|
|
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
|
return StyleSheet.create({
|
|
container: {
|
|
backgroundColor: colors.bg,
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
borderTopColor: colors.border,
|
|
},
|
|
replyBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 8,
|
|
backgroundColor: colors.surface,
|
|
borderLeftWidth: 3,
|
|
borderLeftColor: '#007AFF',
|
|
marginHorizontal: 8,
|
|
marginTop: 6,
|
|
borderRadius: 8,
|
|
},
|
|
replyName: {
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: '#007AFF',
|
|
},
|
|
replyContent: {
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.textMuted,
|
|
marginTop: 1,
|
|
},
|
|
attachBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
backgroundColor: colors.surface,
|
|
marginHorizontal: 8,
|
|
marginTop: 6,
|
|
borderRadius: 8,
|
|
},
|
|
attachImg: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 6,
|
|
marginRight: 8,
|
|
},
|
|
attachFileIcon: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 6,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: 8,
|
|
},
|
|
attachName: {
|
|
flex: 1,
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: colors.text,
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-end',
|
|
paddingHorizontal: 8,
|
|
paddingTop: 8,
|
|
paddingBottom: 8,
|
|
},
|
|
iconBtn: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: 4,
|
|
},
|
|
inputWrap: {
|
|
flex: 1,
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderRadius: 22,
|
|
paddingHorizontal: 14,
|
|
minHeight: 36,
|
|
maxHeight: 120,
|
|
justifyContent: 'center',
|
|
},
|
|
input: {
|
|
fontSize: 14,
|
|
lineHeight: 19,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.text,
|
|
paddingVertical: Platform.OS === 'ios' ? 8 : 4,
|
|
},
|
|
sendBtn: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginLeft: 6,
|
|
},
|
|
});
|
|
}
|