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 { 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<FlatList>(null);
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 [messages, setMessages] = useState<ChatMsg[]>([]);
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>(
null,
);
@ -84,6 +90,7 @@ export default function DmScreen() {
const data = await apiFetch<DmHistoryResponse>(`/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 ?? '…'}
</Text>
</View>
<View style={{ width: 36 }} />
</View>
<KeyboardAvoidingView
@ -261,36 +268,39 @@ export default function DmScreen() {
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={0}
>
{isLoading && messages.length === 0 ? (
<View style={styles.loadingBox}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : messages.length === 0 ? (
<View style={styles.loadingBox}>
<Ionicons name="chatbubble-outline" size={42} color="#d4d4d4" />
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
</View>
) : (
<FlatList
ref={flatRef}
data={messages}
style={{ flex: 1 }}
renderItem={({ item, index }) => (
<ChatBubble
msg={item}
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
isLastInGroup={!sameAuthor(item, messages[index + 1])}
onReply={startReply}
onLike={toggleLike}
onOpenImage={() => {}}
/>
)}
keyExtractor={(m) => m.id}
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
/>
)}
<View style={{ flex: 1, backgroundColor: chatBg }}>
<DmChatBackground />
{isLoading && messages.length === 0 ? (
<View style={styles.loadingBox}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : messages.length === 0 ? (
<View style={styles.loadingBox}>
<Ionicons name="chatbubble-outline" size={42} color="#d4d4d4" />
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
</View>
) : (
<FlatList
ref={flatRef}
data={messages}
style={{ flex: 1 }}
renderItem={({ item, index }) => (
<ChatBubble
msg={item}
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
isLastInGroup={!sameAuthor(item, messages[index + 1])}
onReply={startReply}
onLike={toggleLike}
onOpenImage={() => {}}
/>
)}
keyExtractor={(m) => m.id}
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
/>
)}
</View>
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
<ChatInput
@ -311,7 +321,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: colors.bg,
@ -330,8 +339,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 8,
marginLeft: 8,
},
headerAvatar: {
width: 32,

View File

@ -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 && (
<View style={styles.avatarSlot}>
{isLastInGroup ? (
@ -111,31 +133,28 @@ export function ChatBubble({
</Text>
)}
<Pressable
<TouchableOpacity
delayLongPress={350}
onLongPress={() => 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 && (
<TouchableOpacity
onPress={() => {
/* 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 && (
<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') : '…')}
</Text>
</TouchableOpacity>
)}
{/* Image attachment */}
{msg.attachmentUrl && msg.attachmentType === 'image' && (
<TouchableOpacity
onPress={() => onOpenImage(msg.attachmentUrl!)}
@ -194,7 +212,6 @@ export function ChatBubble({
</TouchableOpacity>
)}
{/* File attachment */}
{msg.attachmentUrl && msg.attachmentType !== 'image' && (
<View
style={{
@ -204,20 +221,16 @@ export function ChatBubble({
paddingVertical: 8,
borderRadius: 10,
marginBottom: 4,
backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.15)' : '#e5e5e5',
backgroundColor: 'rgba(0,0,0,0.06)',
}}
>
<Ionicons
name="document-attach"
size={18}
color={msg.isOwn ? '#fff' : '#525252'}
/>
<Ionicons name="document-attach" size={18} color={colors.textMuted} />
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
marginLeft: 8,
color: msg.isOwn ? '#fff' : '#171717',
color: bubbleText,
flex: 1,
}}
numberOfLines={1}
@ -227,21 +240,14 @@ export function ChatBubble({
</View>
)}
{/* Content */}
{msg.content !== '' && (
<Text
style={[
styles.content,
{ color: msg.isOwn ? '#ffffff' : colors.text },
]}
>
<Text style={[styles.content, { color: bubbleText }]}>
{msg.content}
</Text>
)}
{/* Footer: timestamp + read-receipt inline below content */}
{!isImageOnly && (
<View style={[styles.footer, { justifyContent: msg.isOwn ? 'flex-end' : 'flex-start' }]}>
<View style={styles.footer}>
{msg.likesCount > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
<Ionicons name="heart" size={10} color="#f87171" />
@ -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({
<Ionicons
name={msg.readAt ? 'checkmark-done' : 'checkmark'}
size={12}
color={msg.readAt ? '#93c5fd' : 'rgba(255,255,255,0.7)'}
style={{ marginLeft: 3 }}
color={msg.readAt ? bubbleColors.readColor : 'rgba(0,0,0,0.35)'}
style={{ marginLeft: 2 }}
/>
)}
</View>
)}
</Pressable>
</TouchableOpacity>
</View>
</View>
{/* Long-press action sheet */}
<Modal
visible={actionsOpen}
transparent
@ -288,7 +293,7 @@ export function ChatBubble({
onRequestClose={() => setActionsOpen(false)}
>
<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} />
<TouchableOpacity
style={styles.sheetItem}
@ -324,7 +329,7 @@ export function ChatBubble({
<Text style={styles.sheetText}>{t('chat.copy')}</Text>
</TouchableOpacity>
)}
</Pressable>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</>
@ -354,37 +359,33 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
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<typeof useColors>) {
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 3,
marginTop: 4,
alignSelf: 'flex-end',
},
sheetBackdrop: {
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>
);
}