chahinebrini d7b15e231a feat(theme): Dark Mode Wave 2 — blocker, mail, chat, community, notifications, all remaining screens
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>
2026-05-09 14:51:02 +02:00

448 lines
13 KiB
TypeScript

import { useState, useRef } from 'react';
import {
View,
Text,
Pressable,
Image,
StyleSheet,
Modal,
Alert,
Platform,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { resolveAvatar } from '../../lib/resolveAvatar';
import { useColors } from '../../lib/theme';
export type ChatMsg = {
id: string;
userId: string;
nickname?: string | null;
avatar?: string | null;
content: string;
replyTo?: {
id: string;
userId: string;
nickname?: string | null;
content: string;
attachmentType?: string | null;
} | null;
attachmentUrl?: string | null;
attachmentType?: string | null;
attachmentName?: string | null;
likesCount: number;
likedByMe?: boolean;
createdAt: string;
isOwn: boolean;
readAt?: string | null;
};
type Props = {
msg: ChatMsg;
showName?: boolean;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
hideReadStatus?: boolean;
onReply: (msg: ChatMsg) => void;
onLike: (msg: ChatMsg) => void;
onOpenImage: (url: string) => void;
};
function formatTime(ts: string) {
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
export function ChatBubble({
msg,
showName = false,
isFirstInGroup = true,
isLastInGroup = true,
hideReadStatus = false,
onReply,
onLike,
onOpenImage,
}: Props) {
const { t } = useTranslation();
const colors = useColors();
const styles = makeStyles(colors);
const [actionsOpen, setActionsOpen] = useState(false);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isImageOnly =
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
const replyHasAttachment = msg.replyTo?.attachmentType === 'image';
const avatarUrl = msg.avatar ? msg.avatar : resolveAvatar(null, msg.nickname ?? '?');
const cornerStyle = msg.isOwn
? isLastInGroup
? { borderBottomRightRadius: 6 }
: { borderTopRightRadius: 6, borderBottomRightRadius: 6 }
: isLastInGroup
? { borderBottomLeftRadius: 6 }
: { borderTopLeftRadius: 6, borderBottomLeftRadius: 6 };
function copyContent() {
if (msg.content) Clipboard.setStringAsync(msg.content);
setActionsOpen(false);
}
return (
<>
<View
style={[
styles.row,
{ justifyContent: msg.isOwn ? 'flex-end' : 'flex-start' },
{ marginTop: isFirstInGroup ? 8 : 2 },
]}
>
{/* Avatar slot left (last of group, not own) */}
{!msg.isOwn && (
<View style={styles.avatarSlot}>
{isLastInGroup ? (
<Image source={{ uri: avatarUrl }} style={styles.avatar} />
) : null}
</View>
)}
<View style={[styles.bubbleCol, { alignItems: msg.isOwn ? 'flex-end' : 'flex-start' }]}>
{showName && !msg.isOwn && isFirstInGroup && (
<Text style={styles.nickname} numberOfLines={1}>
{msg.nickname ?? '?'}
</Text>
)}
<Pressable
delayLongPress={350}
onLongPress={() => setActionsOpen(true)}
onPress={() => {
/* tap eats - keeps long-press primary */
}}
style={[
styles.bubble,
msg.isOwn ? styles.bubbleOwn : styles.bubbleOther,
cornerStyle,
isImageOnly && { padding: 4 },
]}
>
{/* Reply preview */}
{msg.replyTo && (
<Pressable
onPress={() => {
/* could implement scroll-to */
}}
style={[
styles.replyPreview,
{
backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.18)' : '#e5e5e5',
borderLeftColor: msg.isOwn ? '#fff' : '#007AFF',
},
]}
>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: msg.isOwn ? '#fff' : '#007AFF',
}}
numberOfLines={1}
>
{msg.replyTo.nickname ?? '?'}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: msg.isOwn ? 'rgba(255,255,255,0.85)' : '#737373',
marginTop: 1,
}}
numberOfLines={1}
>
{replyHasAttachment && (
<Ionicons name="image" size={11} color={msg.isOwn ? '#fff' : '#737373'} />
)}{' '}
{msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')}
</Text>
</Pressable>
)}
{/* Image attachment */}
{msg.attachmentUrl && msg.attachmentType === 'image' && (
<Pressable
onPress={() => onOpenImage(msg.attachmentUrl!)}
style={[styles.imageWrap, msg.content ? { marginBottom: 4 } : null]}
>
<Image
source={{ uri: msg.attachmentUrl }}
style={styles.image}
resizeMode="cover"
/>
{isImageOnly && (
<View style={styles.imageTimeOverlay}>
{msg.likesCount > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
<Ionicons name="heart" size={10} color="#f87171" />
<Text style={{ fontSize: 10, color: '#fff', marginLeft: 2 }}>
{msg.likesCount}
</Text>
</View>
)}
<Text style={{ fontSize: 10, color: '#fff' }}>{formatTime(msg.createdAt)}</Text>
</View>
)}
</Pressable>
)}
{/* File attachment */}
{msg.attachmentUrl && msg.attachmentType !== 'image' && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 10,
marginBottom: 4,
backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.15)' : '#e5e5e5',
}}
>
<Ionicons
name="document-attach"
size={18}
color={msg.isOwn ? '#fff' : '#525252'}
/>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
marginLeft: 8,
color: msg.isOwn ? '#fff' : '#171717',
flex: 1,
}}
numberOfLines={1}
>
{msg.attachmentName ?? t('chat.file_attachment')}
</Text>
</View>
)}
{/* Content */}
{msg.content !== '' && (
<Text
style={[
styles.content,
{ color: msg.isOwn ? '#ffffff' : '#171717', paddingRight: 48 },
]}
>
{msg.content}
</Text>
)}
{/* Footer */}
{!isImageOnly && (
<View style={styles.footer}>
{msg.likesCount > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 3 }}>
<Ionicons name="heart" size={9} color="#f87171" />
<Text
style={{
fontSize: 9,
marginLeft: 1,
color: msg.isOwn ? 'rgba(255,255,255,0.7)' : '#a3a3a3',
}}
>
{msg.likesCount}
</Text>
</View>
)}
<Text
style={{
fontSize: 9,
color: msg.isOwn ? 'rgba(255,255,255,0.65)' : '#a3a3a3',
}}
>
{formatTime(msg.createdAt)}
</Text>
{msg.isOwn && !hideReadStatus && (
<Ionicons
name={msg.readAt ? 'checkmark-done' : 'checkmark'}
size={11}
color={msg.readAt ? '#93c5fd' : 'rgba(255,255,255,0.65)'}
style={{ marginLeft: 2 }}
/>
)}
</View>
)}
</Pressable>
</View>
</View>
{/* Long-press action sheet */}
<Modal
visible={actionsOpen}
transparent
animationType="fade"
onRequestClose={() => setActionsOpen(false)}
>
<Pressable style={styles.sheetBackdrop} onPress={() => setActionsOpen(false)}>
<Pressable style={styles.sheet} onPress={() => {}}>
<View style={styles.sheetGrabber} />
<Pressable
style={styles.sheetItem}
onPress={() => {
setActionsOpen(false);
onReply(msg);
}}
>
<Ionicons name="arrow-undo" size={18} color="#007AFF" />
<Text style={styles.sheetText}>{t('chat.reply')}</Text>
</Pressable>
<Pressable
style={styles.sheetItem}
onPress={() => {
setActionsOpen(false);
onLike(msg);
}}
>
<Ionicons
name={msg.likedByMe ? 'heart' : 'heart-outline'}
size={18}
color={msg.likedByMe ? '#f87171' : '#007AFF'}
/>
<Text style={styles.sheetText}>
{msg.likedByMe ? t('chat.unlike') : t('chat.like')}
</Text>
</Pressable>
{msg.content !== '' && (
<Pressable style={styles.sheetItem} onPress={copyContent}>
<Ionicons name="copy-outline" size={18} color="#007AFF" />
<Text style={styles.sheetText}>{t('chat.copy')}</Text>
</Pressable>
)}
</Pressable>
</Pressable>
</Modal>
</>
);
}
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
row: {
flexDirection: 'row',
paddingHorizontal: 8,
},
avatarSlot: {
width: 30,
marginRight: 4,
justifyContent: 'flex-end',
},
avatar: {
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: colors.surfaceElevated,
},
bubbleCol: {
maxWidth: '78%',
},
nickname: {
fontSize: 10,
fontFamily: 'Nunito_700Bold',
color: '#007AFF',
marginBottom: 2,
marginLeft: 10,
},
bubble: {
borderRadius: 18,
paddingHorizontal: 12,
paddingVertical: 6,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 1,
shadowOffset: { width: 0, height: 1 },
},
bubbleOwn: {
backgroundColor: '#007AFF',
},
bubbleOther: {
backgroundColor: colors.surface,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.border,
},
replyPreview: {
borderLeftWidth: 3,
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 4,
marginBottom: 4,
},
imageWrap: {
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
},
image: {
width: 220,
height: 220,
backgroundColor: colors.surfaceElevated,
},
imageTimeOverlay: {
position: 'absolute',
bottom: 6,
right: 6,
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
flexDirection: 'row',
alignItems: 'center',
},
content: {
fontSize: 14,
lineHeight: 20,
fontFamily: 'Nunito_400Regular',
},
footer: {
position: 'absolute',
bottom: 4,
right: 8,
flexDirection: 'row',
alignItems: 'center',
},
sheetBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: colors.bg,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 8,
paddingBottom: Platform.OS === 'ios' ? 32 : 16,
},
sheetGrabber: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.border,
alignSelf: 'center',
marginBottom: 10,
},
sheetItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 14,
borderRadius: 12,
},
sheetText: {
fontSize: 15,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
marginLeft: 12,
},
});
}