feat(native/dm): WhatsApp-style chat — bg pattern, bubble redesign, avatar + realtime fixes

- Header: partner avatar left-aligned (was centered)
- ChatBubble: replace bright blue with subtle mint/brand tint, asymmetric
  tail-corner radius, footer pinned bottom-right, reply-quote with green
  side-bar
- New DmChatBackground: SVG hex-offset doodle pattern (stars, hearts,
  clouds, dots) at 7% opacity — light-cream / dark-warm-green base
- Avatar in chat list: use resolveAvatar() consistently to handle
  hero-id, https, and null cases
- Realtime subscription: stabilize deps via partnerRef to stop
  re-subscribing on partner state change
- Pressable → TouchableOpacity throughout

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-16 08:50:12 +02:00
parent dba33b5733
commit 6ac6a26b9c
3 changed files with 236 additions and 102 deletions

View File

@ -20,8 +20,10 @@ import { supabase } from '../lib/supabase';
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble'; import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
import { resolveAvatar } from '../lib/resolveAvatar'; import { resolveAvatar } from '../lib/resolveAvatar';
import { ChatInput, type SendPayload } from '../components/chat/ChatInput'; import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
import { DmChatBackground } from '../components/chat/DmChatBackground';
import { useDmRealtime } from '../hooks/useChatRealtime'; import { useDmRealtime } from '../hooks/useChatRealtime';
import { useColors } from '../lib/theme'; import { useColors } from '../lib/theme';
import { useThemeStore } from '../stores/theme';
type DmHistoryResponse = { type DmHistoryResponse = {
partner: { partner: {
@ -59,10 +61,14 @@ export default function DmScreen() {
const flatRef = useRef<FlatList>(null); const flatRef = useRef<FlatList>(null);
const [myUserId, setMyUserId] = useState<string | undefined>(undefined); const [myUserId, setMyUserId] = useState<string | undefined>(undefined);
const colorScheme = useThemeStore((s) => s.colorScheme);
const chatBg = colorScheme === 'dark' ? '#1a1f1e' : '#EDE8E1';
const { userId } = useLocalSearchParams<{ userId: string }>(); const { userId } = useLocalSearchParams<{ userId: string }>();
const [messages, setMessages] = useState<ChatMsg[]>([]); const [messages, setMessages] = useState<ChatMsg[]>([]);
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null); const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null);
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>( const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
null, null,
); );
@ -84,6 +90,7 @@ export default function DmScreen() {
const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`); const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length); console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length);
setPartner(data.partner); setPartner(data.partner);
partnerRef.current = data.partner;
const msgs: ChatMsg[] = data.messages.map((m: any) => ({ const msgs: ChatMsg[] = data.messages.map((m: any) => ({
id: m.id, id: m.id,
userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId), userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId),
@ -125,13 +132,14 @@ export default function DmScreen() {
if (row.receiver_id !== myUserId) return; if (row.receiver_id !== myUserId) return;
setMessages((prev) => { setMessages((prev) => {
if (prev.some((m) => m.id === row.id)) return prev; if (prev.some((m) => m.id === row.id)) return prev;
const p = partnerRef.current;
return [ return [
...prev, ...prev,
{ {
id: row.id, id: row.id,
userId: row.sender_id, userId: row.sender_id,
nickname: partner?.nickname ?? '?', nickname: p?.nickname ?? '?',
avatar: partner?.avatar ?? null, avatar: p?.avatar ?? null,
content: row.content ?? '', content: row.content ?? '',
replyTo: null, replyTo: null,
attachmentUrl: row.attachment_url ?? null, attachmentUrl: row.attachment_url ?? null,
@ -146,7 +154,7 @@ export default function DmScreen() {
]; ];
}); });
}, },
[myUserId, partner], [myUserId],
); );
useDmRealtime(userId, onDmInsert, !!myUserId); useDmRealtime(userId, onDmInsert, !!myUserId);
@ -253,7 +261,6 @@ export default function DmScreen() {
{partner?.nickname ?? '…'} {partner?.nickname ?? '…'}
</Text> </Text>
</View> </View>
<View style={{ width: 36 }} />
</View> </View>
<KeyboardAvoidingView <KeyboardAvoidingView
@ -261,36 +268,39 @@ export default function DmScreen() {
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={0} keyboardVerticalOffset={0}
> >
{isLoading && messages.length === 0 ? ( <View style={{ flex: 1, backgroundColor: chatBg }}>
<View style={styles.loadingBox}> <DmChatBackground />
<ActivityIndicator color={colors.brandOrange} /> {isLoading && messages.length === 0 ? (
</View> <View style={styles.loadingBox}>
) : messages.length === 0 ? ( <ActivityIndicator color={colors.brandOrange} />
<View style={styles.loadingBox}> </View>
<Ionicons name="chatbubble-outline" size={42} color="#d4d4d4" /> ) : messages.length === 0 ? (
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text> <View style={styles.loadingBox}>
</View> <Ionicons name="chatbubble-outline" size={42} color="#d4d4d4" />
) : ( <Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
<FlatList </View>
ref={flatRef} ) : (
data={messages} <FlatList
style={{ flex: 1 }} ref={flatRef}
renderItem={({ item, index }) => ( data={messages}
<ChatBubble style={{ flex: 1 }}
msg={item} renderItem={({ item, index }) => (
isFirstInGroup={!sameAuthor(messages[index - 1], item)} <ChatBubble
isLastInGroup={!sameAuthor(item, messages[index + 1])} msg={item}
onReply={startReply} isFirstInGroup={!sameAuthor(messages[index - 1], item)}
onLike={toggleLike} isLastInGroup={!sameAuthor(item, messages[index + 1])}
onOpenImage={() => {}} onReply={startReply}
/> onLike={toggleLike}
)} onOpenImage={() => {}}
keyExtractor={(m) => m.id} />
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }} )}
showsVerticalScrollIndicator={false} keyExtractor={(m) => m.id}
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })} contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
/> showsVerticalScrollIndicator={false}
)} onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
/>
)}
</View>
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}> <View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
<ChatInput <ChatInput
@ -311,7 +321,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
backgroundColor: colors.bg, backgroundColor: colors.bg,
@ -330,8 +339,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', marginLeft: 8,
marginHorizontal: 8,
}, },
headerAvatar: { headerAvatar: {
width: 32, width: 32,

View File

@ -2,7 +2,6 @@ import { useState } from 'react';
import { import {
View, View,
Text, Text,
Pressable,
TouchableOpacity, TouchableOpacity,
Image, Image,
StyleSheet, StyleSheet,
@ -14,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { resolveAvatar } from '../../lib/resolveAvatar'; import { resolveAvatar } from '../../lib/resolveAvatar';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';
import { useThemeStore } from '../../stores/theme';
export type ChatMsg = { export type ChatMsg = {
id: string; id: string;
@ -53,6 +53,19 @@ function formatTime(ts: string) {
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} }
function useBubbleColors() {
const colorScheme = useThemeStore((s) => s.colorScheme);
const isDark = colorScheme === 'dark';
return {
ownBg: isDark ? '#1e4d3a' : '#D1F4CC',
ownText: isDark ? '#e8f5e2' : '#0a0a0a',
otherBg: isDark ? '#2c2c2e' : '#ffffff',
otherText: isDark ? '#ffffff' : '#0a0a0a',
replyBarColor: '#25D366',
readColor: '#34B7F1',
};
}
export function ChatBubble({ export function ChatBubble({
msg, msg,
showName = false, showName = false,
@ -66,20 +79,30 @@ export function ChatBubble({
const { t } = useTranslation(); const { t } = useTranslation();
const colors = useColors(); const colors = useColors();
const styles = makeStyles(colors); const styles = makeStyles(colors);
const bubbleColors = useBubbleColors();
const [actionsOpen, setActionsOpen] = useState(false); const [actionsOpen, setActionsOpen] = useState(false);
const isImageOnly = const isImageOnly =
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo; !!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
const replyHasAttachment = msg.replyTo?.attachmentType === 'image'; const replyHasAttachment = msg.replyTo?.attachmentType === 'image';
const avatarUrl = msg.avatar ? msg.avatar : resolveAvatar(null, msg.nickname ?? '?'); const avatarUrl = resolveAvatar(msg.avatar, msg.nickname ?? '?');
const cornerStyle = msg.isOwn const ownBubbleRadius = {
? isLastInGroup borderTopLeftRadius: 14,
? { borderBottomRightRadius: 6 } borderTopRightRadius: isFirstInGroup ? 14 : 4,
: { borderTopRightRadius: 6, borderBottomRightRadius: 6 } borderBottomLeftRadius: 14,
: isLastInGroup borderBottomRightRadius: isLastInGroup ? 4 : 4,
? { borderBottomLeftRadius: 6 } };
: { borderTopLeftRadius: 6, borderBottomLeftRadius: 6 };
const otherBubbleRadius = {
borderTopLeftRadius: isFirstInGroup ? 14 : 4,
borderTopRightRadius: 14,
borderBottomLeftRadius: isLastInGroup ? 4 : 4,
borderBottomRightRadius: 14,
};
const bubbleBg = msg.isOwn ? bubbleColors.ownBg : bubbleColors.otherBg;
const bubbleText = msg.isOwn ? bubbleColors.ownText : bubbleColors.otherText;
function copyContent() { function copyContent() {
if (msg.content) Clipboard.setStringAsync(msg.content); if (msg.content) Clipboard.setStringAsync(msg.content);
@ -95,7 +118,6 @@ export function ChatBubble({
{ marginTop: isFirstInGroup ? 8 : 2 }, { marginTop: isFirstInGroup ? 8 : 2 },
]} ]}
> >
{/* Avatar slot left (last of group, not own) */}
{!msg.isOwn && ( {!msg.isOwn && (
<View style={styles.avatarSlot}> <View style={styles.avatarSlot}>
{isLastInGroup ? ( {isLastInGroup ? (
@ -111,31 +133,28 @@ export function ChatBubble({
</Text> </Text>
)} )}
<Pressable <TouchableOpacity
delayLongPress={350} delayLongPress={350}
onLongPress={() => setActionsOpen(true)} onLongPress={() => setActionsOpen(true)}
onPress={() => { activeOpacity={1}
/* tap eats - keeps long-press primary */
}}
style={[ style={[
styles.bubble, styles.bubble,
msg.isOwn ? styles.bubbleOwn : styles.bubbleOther, msg.isOwn ? ownBubbleRadius : otherBubbleRadius,
cornerStyle, { backgroundColor: bubbleBg },
!msg.isOwn && styles.bubbleOtherBorder,
isImageOnly && { padding: 4 }, isImageOnly && { padding: 4 },
]} ]}
> >
{/* Reply preview */}
{msg.replyTo && ( {msg.replyTo && (
<TouchableOpacity <TouchableOpacity
onPress={() => {
/* could implement scroll-to */
}}
activeOpacity={0.7} activeOpacity={0.7}
style={[ style={[
styles.replyPreview, styles.replyPreview,
{ {
backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.18)' : '#e5e5e5', backgroundColor: msg.isOwn
borderLeftColor: msg.isOwn ? '#fff' : '#007AFF', ? 'rgba(0,0,0,0.08)'
: colors.surfaceElevated,
borderLeftColor: bubbleColors.replyBarColor,
}, },
]} ]}
> >
@ -143,7 +162,7 @@ export function ChatBubble({
style={{ style={{
fontSize: 11, fontSize: 11,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
color: msg.isOwn ? '#fff' : '#007AFF', color: bubbleColors.replyBarColor,
}} }}
numberOfLines={1} numberOfLines={1}
> >
@ -153,20 +172,19 @@ export function ChatBubble({
style={{ style={{
fontSize: 11, fontSize: 11,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_400Regular',
color: msg.isOwn ? 'rgba(255,255,255,0.85)' : '#737373', color: colors.textMuted,
marginTop: 1, marginTop: 1,
}} }}
numberOfLines={1} numberOfLines={1}
> >
{replyHasAttachment && ( {replyHasAttachment && (
<Ionicons name="image" size={11} color={msg.isOwn ? '#fff' : '#737373'} /> <Ionicons name="image" size={11} color={colors.textMuted} />
)}{' '} )}{' '}
{msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')} {msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* Image attachment */}
{msg.attachmentUrl && msg.attachmentType === 'image' && ( {msg.attachmentUrl && msg.attachmentType === 'image' && (
<TouchableOpacity <TouchableOpacity
onPress={() => onOpenImage(msg.attachmentUrl!)} onPress={() => onOpenImage(msg.attachmentUrl!)}
@ -194,7 +212,6 @@ export function ChatBubble({
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* File attachment */}
{msg.attachmentUrl && msg.attachmentType !== 'image' && ( {msg.attachmentUrl && msg.attachmentType !== 'image' && (
<View <View
style={{ style={{
@ -204,20 +221,16 @@ export function ChatBubble({
paddingVertical: 8, paddingVertical: 8,
borderRadius: 10, borderRadius: 10,
marginBottom: 4, marginBottom: 4,
backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.15)' : '#e5e5e5', backgroundColor: 'rgba(0,0,0,0.06)',
}} }}
> >
<Ionicons <Ionicons name="document-attach" size={18} color={colors.textMuted} />
name="document-attach"
size={18}
color={msg.isOwn ? '#fff' : '#525252'}
/>
<Text <Text
style={{ style={{
fontSize: 12, fontSize: 12,
fontFamily: 'Nunito_600SemiBold', fontFamily: 'Nunito_600SemiBold',
marginLeft: 8, marginLeft: 8,
color: msg.isOwn ? '#fff' : '#171717', color: bubbleText,
flex: 1, flex: 1,
}} }}
numberOfLines={1} numberOfLines={1}
@ -227,21 +240,14 @@ export function ChatBubble({
</View> </View>
)} )}
{/* Content */}
{msg.content !== '' && ( {msg.content !== '' && (
<Text <Text style={[styles.content, { color: bubbleText }]}>
style={[
styles.content,
{ color: msg.isOwn ? '#ffffff' : colors.text },
]}
>
{msg.content} {msg.content}
</Text> </Text>
)} )}
{/* Footer: timestamp + read-receipt inline below content */}
{!isImageOnly && ( {!isImageOnly && (
<View style={[styles.footer, { justifyContent: msg.isOwn ? 'flex-end' : 'flex-start' }]}> <View style={styles.footer}>
{msg.likesCount > 0 && ( {msg.likesCount > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
<Ionicons name="heart" size={10} color="#f87171" /> <Ionicons name="heart" size={10} color="#f87171" />
@ -250,7 +256,7 @@ export function ChatBubble({
fontSize: 10, fontSize: 10,
marginLeft: 2, marginLeft: 2,
fontFamily: 'Nunito_600SemiBold', fontFamily: 'Nunito_600SemiBold',
color: msg.isOwn ? 'rgba(255,255,255,0.75)' : '#a3a3a3', color: colors.textMuted,
}} }}
> >
{msg.likesCount} {msg.likesCount}
@ -261,7 +267,7 @@ export function ChatBubble({
style={{ style={{
fontSize: 10, fontSize: 10,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_400Regular',
color: msg.isOwn ? 'rgba(255,255,255,0.7)' : colors.textMuted, color: msg.isOwn ? 'rgba(0,0,0,0.45)' : colors.textMuted,
}} }}
> >
{formatTime(msg.createdAt)} {formatTime(msg.createdAt)}
@ -270,17 +276,16 @@ export function ChatBubble({
<Ionicons <Ionicons
name={msg.readAt ? 'checkmark-done' : 'checkmark'} name={msg.readAt ? 'checkmark-done' : 'checkmark'}
size={12} size={12}
color={msg.readAt ? '#93c5fd' : 'rgba(255,255,255,0.7)'} color={msg.readAt ? bubbleColors.readColor : 'rgba(0,0,0,0.35)'}
style={{ marginLeft: 3 }} style={{ marginLeft: 2 }}
/> />
)} )}
</View> </View>
)} )}
</Pressable> </TouchableOpacity>
</View> </View>
</View> </View>
{/* Long-press action sheet */}
<Modal <Modal
visible={actionsOpen} visible={actionsOpen}
transparent transparent
@ -288,7 +293,7 @@ export function ChatBubble({
onRequestClose={() => setActionsOpen(false)} onRequestClose={() => setActionsOpen(false)}
> >
<TouchableOpacity style={styles.sheetBackdrop} onPress={() => setActionsOpen(false)} activeOpacity={1}> <TouchableOpacity style={styles.sheetBackdrop} onPress={() => setActionsOpen(false)} activeOpacity={1}>
<Pressable style={styles.sheet} onPress={() => {}}> <TouchableOpacity style={styles.sheet} onPress={() => {}} activeOpacity={1}>
<View style={styles.sheetGrabber} /> <View style={styles.sheetGrabber} />
<TouchableOpacity <TouchableOpacity
style={styles.sheetItem} style={styles.sheetItem}
@ -324,7 +329,7 @@ export function ChatBubble({
<Text style={styles.sheetText}>{t('chat.copy')}</Text> <Text style={styles.sheetText}>{t('chat.copy')}</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</Pressable> </TouchableOpacity>
</TouchableOpacity> </TouchableOpacity>
</Modal> </Modal>
</> </>
@ -354,37 +359,33 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
nickname: { nickname: {
fontSize: 11, fontSize: 11,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
color: '#007AFF', color: '#25D366',
marginBottom: 3, marginBottom: 3,
marginLeft: 12, marginLeft: 12,
}, },
bubble: { bubble: {
borderRadius: 20, paddingHorizontal: 12,
paddingHorizontal: 14,
paddingTop: 8, paddingTop: 8,
paddingBottom: 6, paddingBottom: 6,
shadowColor: '#000', shadowColor: '#000',
shadowOpacity: 0.06, shadowOpacity: 0.08,
shadowRadius: 2, shadowRadius: 3,
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
elevation: 1,
}, },
bubbleOwn: { bubbleOtherBorder: {
backgroundColor: '#007AFF',
},
bubbleOther: {
backgroundColor: colors.surface,
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.border, borderColor: 'rgba(0,0,0,0.06)',
}, },
replyPreview: { replyPreview: {
borderLeftWidth: 3, borderLeftWidth: 4,
borderRadius: 8, borderRadius: 6,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 4, paddingVertical: 4,
marginBottom: 6, marginBottom: 6,
}, },
imageWrap: { imageWrap: {
borderRadius: 14, borderRadius: 10,
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
}, },
@ -412,8 +413,10 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
footer: { footer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'flex-end',
gap: 3, gap: 3,
marginTop: 4, marginTop: 4,
alignSelf: 'flex-end',
}, },
sheetBackdrop: { sheetBackdrop: {
flex: 1, flex: 1,

View File

@ -0,0 +1,123 @@
import { useMemo } from 'react';
import { useWindowDimensions, View } from 'react-native';
import Svg, { Circle, Path, Line, Rect } from 'react-native-svg';
import { useColors } from '../../lib/theme';
const TILE = 80;
const OPACITY = 0.07;
type Symbol = 'star' | 'heart' | 'check' | 'dot' | 'cloud' | 'wave' | 'diamond';
const SEQUENCE: Symbol[] = [
'star', 'dot', 'heart', 'wave', 'check', 'dot', 'cloud',
'diamond', 'dot', 'star', 'check', 'heart', 'dot', 'wave',
];
function SymbolShape({ type, color }: { type: Symbol; color: string }) {
switch (type) {
case 'star':
return (
<Path
d="M10 2 L11.8 7.6 L18 7.6 L13 11.2 L14.8 16.8 L10 13.2 L5.2 16.8 L7 11.2 L2 7.6 L8.2 7.6 Z"
fill={color}
opacity={OPACITY}
/>
);
case 'heart':
return (
<Path
d="M10 15 C10 15 3 10 3 6 C3 3.8 4.8 2 7 2 C8.3 2 9.5 2.7 10 3.7 C10.5 2.7 11.7 2 13 2 C15.2 2 17 3.8 17 6 C17 10 10 15 10 15 Z"
fill={color}
opacity={OPACITY}
/>
);
case 'check':
return (
<Path
d="M3 9 L7 13 L17 4"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity={OPACITY}
/>
);
case 'dot':
return <Circle cx="10" cy="10" r="3.5" fill={color} opacity={OPACITY} />;
case 'cloud':
return (
<Path
d="M5 12 C3.3 12 2 10.7 2 9 C2 7.4 3.2 6.1 4.7 6 C5 4.3 6.5 3 8.3 3 C9.8 3 11.1 3.9 11.7 5.2 C12 5.1 12.3 5 12.7 5 C14.5 5 16 6.5 16 8.3 C16 10.3 14.3 12 12.3 12 Z"
fill={color}
opacity={OPACITY}
/>
);
case 'wave':
return (
<Path
d="M2 10 Q5 7 8 10 Q11 13 14 10 Q17 7 20 10"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
fill="none"
opacity={OPACITY}
/>
);
case 'diamond':
return (
<Path
d="M10 2 L18 10 L10 18 L2 10 Z"
fill={color}
opacity={OPACITY}
/>
);
}
}
export function DmChatBackground() {
const { width, height } = useWindowDimensions();
const colors = useColors();
const patternColor = colors.text;
const cols = Math.ceil(width / TILE) + 1;
const rows = Math.ceil(height / TILE) + 1;
const symbols = useMemo(() => {
const items: { x: number; y: number; type: Symbol; rotate: number }[] = [];
let seq = 0;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const offsetX = r % 2 === 0 ? 0 : TILE / 2;
items.push({
x: c * TILE + offsetX,
y: r * TILE,
type: SEQUENCE[seq % SEQUENCE.length],
rotate: [0, 15, -10, 30, -20, 5, -15][seq % 7],
});
seq++;
}
}
return items;
}, [cols, rows]);
return (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} pointerEvents="none">
<Svg width={width} height={height}>
{symbols.map((s, i) => (
<Svg
key={i}
x={s.x - 10}
y={s.y - 10}
width={20}
height={20}
viewBox="0 0 20 20"
>
<SymbolShape type={s.type} color={patternColor} />
</Svg>
))}
</Svg>
</View>
);
}