From 6ac6a26b9c4053d1b6a408914f0199fd1f8943dc Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sat, 16 May 2026 08:50:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(native/dm):=20WhatsApp-style=20chat=20?= =?UTF-8?q?=E2=80=94=20bg=20pattern,=20bubble=20redesign,=20avatar=20+=20r?= =?UTF-8?q?ealtime=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/rebreak-native/app/dm.tsx | 82 ++++++----- .../components/chat/ChatBubble.tsx | 133 +++++++++--------- .../components/chat/DmChatBackground.tsx | 123 ++++++++++++++++ 3 files changed, 236 insertions(+), 102 deletions(-) create mode 100644 apps/rebreak-native/components/chat/DmChatBackground.tsx diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx index 7df7d6a..c9f4b1e 100644 --- a/apps/rebreak-native/app/dm.tsx +++ b/apps/rebreak-native/app/dm.tsx @@ -20,8 +20,10 @@ import { supabase } from '../lib/supabase'; import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble'; import { resolveAvatar } from '../lib/resolveAvatar'; import { ChatInput, type SendPayload } from '../components/chat/ChatInput'; +import { DmChatBackground } from '../components/chat/DmChatBackground'; import { useDmRealtime } from '../hooks/useChatRealtime'; import { useColors } from '../lib/theme'; +import { useThemeStore } from '../stores/theme'; type DmHistoryResponse = { partner: { @@ -59,10 +61,14 @@ export default function DmScreen() { const flatRef = useRef(null); const [myUserId, setMyUserId] = useState(undefined); + const colorScheme = useThemeStore((s) => s.colorScheme); + const chatBg = colorScheme === 'dark' ? '#1a1f1e' : '#EDE8E1'; + const { userId } = useLocalSearchParams<{ userId: string }>(); const [messages, setMessages] = useState([]); const [partner, setPartner] = useState(null); + const partnerRef = useRef(null); const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>( null, ); @@ -84,6 +90,7 @@ export default function DmScreen() { const data = await apiFetch(`/api/chat/dm/${userId}`); console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length); setPartner(data.partner); + partnerRef.current = data.partner; const msgs: ChatMsg[] = data.messages.map((m: any) => ({ id: m.id, userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId), @@ -125,13 +132,14 @@ export default function DmScreen() { if (row.receiver_id !== myUserId) return; setMessages((prev) => { if (prev.some((m) => m.id === row.id)) return prev; + const p = partnerRef.current; return [ ...prev, { id: row.id, userId: row.sender_id, - nickname: partner?.nickname ?? '?', - avatar: partner?.avatar ?? null, + nickname: p?.nickname ?? '?', + avatar: p?.avatar ?? null, content: row.content ?? '', replyTo: null, attachmentUrl: row.attachment_url ?? null, @@ -146,7 +154,7 @@ export default function DmScreen() { ]; }); }, - [myUserId, partner], + [myUserId], ); useDmRealtime(userId, onDmInsert, !!myUserId); @@ -253,7 +261,6 @@ export default function DmScreen() { {partner?.nickname ?? '…'} - - {isLoading && messages.length === 0 ? ( - - - - ) : messages.length === 0 ? ( - - - {t('chat.no_chats')} - - ) : ( - ( - {}} - /> - )} - keyExtractor={(m) => m.id} - contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }} - showsVerticalScrollIndicator={false} - onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })} - /> - )} + + + {isLoading && messages.length === 0 ? ( + + + + ) : messages.length === 0 ? ( + + + {t('chat.no_chats')} + + ) : ( + ( + {}} + /> + )} + keyExtractor={(m) => m.id} + contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }} + showsVerticalScrollIndicator={false} + onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })} + /> + )} + ) { header: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', paddingHorizontal: 12, paddingVertical: 10, backgroundColor: colors.bg, @@ -330,8 +339,7 @@ function makeStyles(colors: ReturnType) { flex: 1, flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - marginHorizontal: 8, + marginLeft: 8, }, headerAvatar: { width: 32, diff --git a/apps/rebreak-native/components/chat/ChatBubble.tsx b/apps/rebreak-native/components/chat/ChatBubble.tsx index e3a7790..07e73c5 100644 --- a/apps/rebreak-native/components/chat/ChatBubble.tsx +++ b/apps/rebreak-native/components/chat/ChatBubble.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { View, Text, - Pressable, TouchableOpacity, Image, StyleSheet, @@ -14,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { resolveAvatar } from '../../lib/resolveAvatar'; import { useColors } from '../../lib/theme'; +import { useThemeStore } from '../../stores/theme'; export type ChatMsg = { id: string; @@ -53,6 +53,19 @@ function formatTime(ts: string) { 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({ msg, showName = false, @@ -66,20 +79,30 @@ export function ChatBubble({ const { t } = useTranslation(); const colors = useColors(); const styles = makeStyles(colors); + const bubbleColors = useBubbleColors(); const [actionsOpen, setActionsOpen] = useState(false); 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 avatarUrl = resolveAvatar(msg.avatar, msg.nickname ?? '?'); - const cornerStyle = msg.isOwn - ? isLastInGroup - ? { borderBottomRightRadius: 6 } - : { borderTopRightRadius: 6, borderBottomRightRadius: 6 } - : isLastInGroup - ? { borderBottomLeftRadius: 6 } - : { borderTopLeftRadius: 6, borderBottomLeftRadius: 6 }; + const ownBubbleRadius = { + borderTopLeftRadius: 14, + borderTopRightRadius: isFirstInGroup ? 14 : 4, + borderBottomLeftRadius: 14, + borderBottomRightRadius: isLastInGroup ? 4 : 4, + }; + + 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() { if (msg.content) Clipboard.setStringAsync(msg.content); @@ -95,7 +118,6 @@ export function ChatBubble({ { marginTop: isFirstInGroup ? 8 : 2 }, ]} > - {/* Avatar slot left (last of group, not own) */} {!msg.isOwn && ( {isLastInGroup ? ( @@ -111,31 +133,28 @@ export function ChatBubble({ )} - setActionsOpen(true)} - onPress={() => { - /* tap eats - keeps long-press primary */ - }} + activeOpacity={1} style={[ styles.bubble, - msg.isOwn ? styles.bubbleOwn : styles.bubbleOther, - cornerStyle, + msg.isOwn ? ownBubbleRadius : otherBubbleRadius, + { backgroundColor: bubbleBg }, + !msg.isOwn && styles.bubbleOtherBorder, isImageOnly && { padding: 4 }, ]} > - {/* Reply preview */} {msg.replyTo && ( { - /* could implement scroll-to */ - }} activeOpacity={0.7} style={[ styles.replyPreview, { - backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.18)' : '#e5e5e5', - borderLeftColor: msg.isOwn ? '#fff' : '#007AFF', + backgroundColor: msg.isOwn + ? 'rgba(0,0,0,0.08)' + : colors.surfaceElevated, + borderLeftColor: bubbleColors.replyBarColor, }, ]} > @@ -143,7 +162,7 @@ export function ChatBubble({ style={{ fontSize: 11, fontFamily: 'Nunito_700Bold', - color: msg.isOwn ? '#fff' : '#007AFF', + color: bubbleColors.replyBarColor, }} numberOfLines={1} > @@ -153,20 +172,19 @@ export function ChatBubble({ style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', - color: msg.isOwn ? 'rgba(255,255,255,0.85)' : '#737373', + color: colors.textMuted, marginTop: 1, }} numberOfLines={1} > {replyHasAttachment && ( - + )}{' '} {msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')} )} - {/* Image attachment */} {msg.attachmentUrl && msg.attachmentType === 'image' && ( onOpenImage(msg.attachmentUrl!)} @@ -194,7 +212,6 @@ export function ChatBubble({ )} - {/* File attachment */} {msg.attachmentUrl && msg.attachmentType !== 'image' && ( - + )} - {/* Content */} {msg.content !== '' && ( - + {msg.content} )} - {/* Footer: timestamp + read-receipt inline below content */} {!isImageOnly && ( - + {msg.likesCount > 0 && ( @@ -250,7 +256,7 @@ export function ChatBubble({ fontSize: 10, marginLeft: 2, fontFamily: 'Nunito_600SemiBold', - color: msg.isOwn ? 'rgba(255,255,255,0.75)' : '#a3a3a3', + color: colors.textMuted, }} > {msg.likesCount} @@ -261,7 +267,7 @@ export function ChatBubble({ style={{ fontSize: 10, 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)} @@ -270,17 +276,16 @@ export function ChatBubble({ )} )} - + - {/* Long-press action sheet */} setActionsOpen(false)} > setActionsOpen(false)} activeOpacity={1}> - {}}> + {}} activeOpacity={1}> {t('chat.copy')} )} - + @@ -354,37 +359,33 @@ function makeStyles(colors: ReturnType) { nickname: { fontSize: 11, fontFamily: 'Nunito_700Bold', - color: '#007AFF', + color: '#25D366', marginBottom: 3, marginLeft: 12, }, bubble: { - borderRadius: 20, - paddingHorizontal: 14, + paddingHorizontal: 12, paddingTop: 8, paddingBottom: 6, shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 2, + shadowOpacity: 0.08, + shadowRadius: 3, shadowOffset: { width: 0, height: 1 }, + elevation: 1, }, - bubbleOwn: { - backgroundColor: '#007AFF', - }, - bubbleOther: { - backgroundColor: colors.surface, + bubbleOtherBorder: { borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.border, + borderColor: 'rgba(0,0,0,0.06)', }, replyPreview: { - borderLeftWidth: 3, - borderRadius: 8, + borderLeftWidth: 4, + borderRadius: 6, paddingHorizontal: 8, paddingVertical: 4, marginBottom: 6, }, imageWrap: { - borderRadius: 14, + borderRadius: 10, overflow: 'hidden', position: 'relative', }, @@ -412,8 +413,10 @@ function makeStyles(colors: ReturnType) { footer: { flexDirection: 'row', alignItems: 'center', + justifyContent: 'flex-end', gap: 3, marginTop: 4, + alignSelf: 'flex-end', }, sheetBackdrop: { flex: 1, diff --git a/apps/rebreak-native/components/chat/DmChatBackground.tsx b/apps/rebreak-native/components/chat/DmChatBackground.tsx new file mode 100644 index 0000000..17368a4 --- /dev/null +++ b/apps/rebreak-native/components/chat/DmChatBackground.tsx @@ -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 ( + + ); + case 'heart': + return ( + + ); + case 'check': + return ( + + ); + case 'dot': + return ; + case 'cloud': + return ( + + ); + case 'wave': + return ( + + ); + case 'diamond': + return ( + + ); + } +} + +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 ( + + + {symbols.map((s, i) => ( + + + + ))} + + + ); +}