feat(chat): redesign chat list + conversation view

- RoomCard / chat.tsx DmItem: cleaner list rows (48px avatar, minHeight 68,
  consistent padding, time next to name, TouchableOpacity)
- ChatBubble: timestamp inline under content (no absolute-position hack),
  borderRadius 20, 28px avatar, lighter backdrop
- ChatInput: surface bg, hairline-bordered input pill, 38px send button

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-11 15:52:45 +02:00
parent 7369912d60
commit 2dcff6408c
4 changed files with 189 additions and 172 deletions

View File

@ -3,7 +3,6 @@ import {
View, View,
Text, Text,
FlatList, FlatList,
Pressable,
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
Image, Image,
@ -45,51 +44,51 @@ function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }
const hasUnread = conv.unreadCount > 0; const hasUnread = conv.unreadCount > 0;
return ( return (
<Pressable onPress={onPress} android_ripple={{ color: '#f5f5f5' }}> <TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<View style={styles.dmRow}> <View style={styles.dmRow}>
<View style={styles.dmAvatar}> <View style={styles.dmAvatar}>
{conv.partnerAvatar ? ( {conv.partnerAvatar ? (
<Image source={{ uri: conv.partnerAvatar }} style={styles.dmAvatarImg} /> <Image source={{ uri: conv.partnerAvatar }} style={styles.dmAvatarImg} />
) : ( ) : (
<Text style={styles.dmAvatarInitials}> <Text style={styles.dmAvatarInitials}>
{conv.partnerName.slice(0, 2).toUpperCase()} {conv.partnerName.slice(0, 2).toUpperCase()}
</Text> </Text>
)}
</View>
<View style={styles.dmInfo}>
<View style={styles.dmHeaderRow}>
<Text style={styles.dmName} numberOfLines={1}>
{conv.partnerName}
</Text>
<Text style={[styles.dmTime, { color: hasUnread ? '#007AFF' : colors.textMuted }]}>
{formatTime(conv.lastMessageAt, t('chat.just_now'))}
</Text>
</View>
<View style={styles.dmBottomRow}>
<Text
numberOfLines={1}
style={[
styles.dmLast,
{
fontFamily: hasUnread ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
color: hasUnread ? colors.text : colors.textMuted,
},
]}
>
{conv.isOwn ? `${t('chat.you')} ` : ''}
{conv.lastMessage}
</Text>
{hasUnread && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>
{conv.unreadCount > 99 ? '99+' : conv.unreadCount}
</Text>
</View>
)} )}
</View> </View>
<View style={styles.dmInfo}> </View>
<View style={styles.dmHeaderRow}>
<Text style={styles.dmName} numberOfLines={1}>
{conv.partnerName}
</Text>
<Text
style={[styles.dmTime, { color: hasUnread ? '#007AFF' : '#a3a3a3' }]}
>
{formatTime(conv.lastMessageAt, t('chat.just_now'))}
</Text>
</View>
<View style={styles.dmBottomRow}>
<Text
numberOfLines={1}
style={[
styles.dmLast,
{
fontFamily: hasUnread ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
color: hasUnread ? '#171717' : '#a3a3a3',
},
]}
>
{conv.isOwn ? t('chat.you') : ''}
{conv.lastMessage}
</Text>
{hasUnread && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>{conv.unreadCount}</Text>
</View>
)}
</View>
</View>
</View> </View>
</Pressable> </TouchableOpacity>
); );
} }
@ -352,28 +351,29 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
marginTop: 12, marginTop: 12,
}, },
dmRow: { dmRow: {
width: '100%',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 14, paddingHorizontal: 16,
paddingVertical: 11, paddingVertical: 12,
backgroundColor: colors.bg, backgroundColor: colors.bg,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border, borderBottomColor: colors.border,
minHeight: 68,
}, },
dmAvatar: { dmAvatar: {
width: 42, width: 48,
height: 42, height: 48,
borderRadius: 21, borderRadius: 24,
backgroundColor: colors.surfaceElevated, backgroundColor: colors.surfaceElevated,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
overflow: 'hidden', overflow: 'hidden',
marginRight: 10, marginRight: 12,
flexShrink: 0,
}, },
dmAvatarImg: { width: 42, height: 42 }, dmAvatarImg: { width: 48, height: 48 },
dmAvatarInitials: { dmAvatarInitials: {
fontSize: 13, fontSize: 15,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
color: colors.textMuted, color: colors.textMuted,
}, },
@ -384,13 +384,13 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
justifyContent: 'space-between', justifyContent: 'space-between',
}, },
dmName: { dmName: {
fontSize: 14, fontSize: 15,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
color: colors.text, color: colors.text,
flexShrink: 1, flexShrink: 1,
marginRight: 6, marginRight: 6,
}, },
dmTime: { fontSize: 11, fontFamily: 'Nunito_600SemiBold' }, dmTime: { fontSize: 11, fontFamily: 'Nunito_500Medium' },
dmBottomRow: { dmBottomRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@ -1,4 +1,4 @@
import { useState, useRef } from 'react'; import { useState } from 'react';
import { import {
View, View,
Text, Text,
@ -7,7 +7,6 @@ import {
Image, Image,
StyleSheet, StyleSheet,
Modal, Modal,
Alert,
Platform, Platform,
} from 'react-native'; } from 'react-native';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
@ -68,7 +67,6 @@ export function ChatBubble({
const colors = useColors(); const colors = useColors();
const styles = makeStyles(colors); const styles = makeStyles(colors);
const [actionsOpen, setActionsOpen] = useState(false); const [actionsOpen, setActionsOpen] = useState(false);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isImageOnly = const isImageOnly =
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo; !!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
@ -234,24 +232,25 @@ export function ChatBubble({
<Text <Text
style={[ style={[
styles.content, styles.content,
{ color: msg.isOwn ? '#ffffff' : '#171717', paddingRight: 48 }, { color: msg.isOwn ? '#ffffff' : colors.text },
]} ]}
> >
{msg.content} {msg.content}
</Text> </Text>
)} )}
{/* Footer */} {/* Footer: timestamp + read-receipt inline below content */}
{!isImageOnly && ( {!isImageOnly && (
<View style={styles.footer}> <View style={[styles.footer, { justifyContent: msg.isOwn ? 'flex-end' : 'flex-start' }]}>
{msg.likesCount > 0 && ( {msg.likesCount > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 3 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
<Ionicons name="heart" size={9} color="#f87171" /> <Ionicons name="heart" size={10} color="#f87171" />
<Text <Text
style={{ style={{
fontSize: 9, fontSize: 10,
marginLeft: 1, marginLeft: 2,
color: msg.isOwn ? 'rgba(255,255,255,0.7)' : '#a3a3a3', fontFamily: 'Nunito_600SemiBold',
color: msg.isOwn ? 'rgba(255,255,255,0.75)' : '#a3a3a3',
}} }}
> >
{msg.likesCount} {msg.likesCount}
@ -260,8 +259,9 @@ export function ChatBubble({
)} )}
<Text <Text
style={{ style={{
fontSize: 9, fontSize: 10,
color: msg.isOwn ? 'rgba(255,255,255,0.65)' : '#a3a3a3', fontFamily: 'Nunito_400Regular',
color: msg.isOwn ? 'rgba(255,255,255,0.7)' : colors.textMuted,
}} }}
> >
{formatTime(msg.createdAt)} {formatTime(msg.createdAt)}
@ -269,9 +269,9 @@ export function ChatBubble({
{msg.isOwn && !hideReadStatus && ( {msg.isOwn && !hideReadStatus && (
<Ionicons <Ionicons
name={msg.readAt ? 'checkmark-done' : 'checkmark'} name={msg.readAt ? 'checkmark-done' : 'checkmark'}
size={11} size={12}
color={msg.readAt ? '#93c5fd' : 'rgba(255,255,255,0.65)'} color={msg.readAt ? '#93c5fd' : 'rgba(255,255,255,0.7)'}
style={{ marginLeft: 2 }} style={{ marginLeft: 3 }}
/> />
)} )}
</View> </View>
@ -335,36 +335,37 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({ return StyleSheet.create({
row: { row: {
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 8, paddingHorizontal: 10,
}, },
avatarSlot: { avatarSlot: {
width: 30, width: 32,
marginRight: 4, marginRight: 6,
justifyContent: 'flex-end', justifyContent: 'flex-end',
}, },
avatar: { avatar: {
width: 26, width: 28,
height: 26, height: 28,
borderRadius: 13, borderRadius: 14,
backgroundColor: colors.surfaceElevated, backgroundColor: colors.surfaceElevated,
}, },
bubbleCol: { bubbleCol: {
maxWidth: '78%', maxWidth: '76%',
}, },
nickname: { nickname: {
fontSize: 10, fontSize: 11,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
color: '#007AFF', color: '#007AFF',
marginBottom: 2, marginBottom: 3,
marginLeft: 10, marginLeft: 12,
}, },
bubble: { bubble: {
borderRadius: 18, borderRadius: 20,
paddingHorizontal: 12, paddingHorizontal: 14,
paddingVertical: 6, paddingTop: 8,
paddingBottom: 6,
shadowColor: '#000', shadowColor: '#000',
shadowOpacity: 0.05, shadowOpacity: 0.06,
shadowRadius: 1, shadowRadius: 2,
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
}, },
bubbleOwn: { bubbleOwn: {
@ -380,10 +381,10 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 4, paddingVertical: 4,
marginBottom: 4, marginBottom: 6,
}, },
imageWrap: { imageWrap: {
borderRadius: 12, borderRadius: 14,
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
}, },
@ -405,27 +406,26 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
}, },
content: { content: {
fontSize: 14, fontSize: 14,
lineHeight: 20, lineHeight: 21,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_400Regular',
}, },
footer: { footer: {
position: 'absolute',
bottom: 4,
right: 8,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 3,
marginTop: 4,
}, },
sheetBackdrop: { sheetBackdrop: {
flex: 1, flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)', backgroundColor: 'rgba(0,0,0,0.35)',
justifyContent: 'flex-end', justifyContent: 'flex-end',
}, },
sheet: { sheet: {
backgroundColor: colors.bg, backgroundColor: colors.bg,
borderTopLeftRadius: 20, borderTopLeftRadius: 22,
borderTopRightRadius: 20, borderTopRightRadius: 22,
padding: 8, padding: 8,
paddingBottom: Platform.OS === 'ios' ? 32 : 16, paddingBottom: Platform.OS === 'ios' ? 34 : 16,
}, },
sheetGrabber: { sheetGrabber: {
width: 36, width: 36,

View File

@ -240,7 +240,7 @@ function decodeBase64(base64: string): Uint8Array {
function makeStyles(colors: ReturnType<typeof useColors>) { function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({ return StyleSheet.create({
container: { container: {
backgroundColor: colors.bg, backgroundColor: colors.surface,
borderTopWidth: StyleSheet.hairlineWidth, borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border, borderTopColor: colors.border,
}, },
@ -315,24 +315,26 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
}, },
inputWrap: { inputWrap: {
flex: 1, flex: 1,
backgroundColor: colors.surfaceElevated, backgroundColor: colors.bg,
borderRadius: 22, borderRadius: 22,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.border,
paddingHorizontal: 14, paddingHorizontal: 14,
minHeight: 36, minHeight: 38,
maxHeight: 120, maxHeight: 120,
justifyContent: 'center', justifyContent: 'center',
}, },
input: { input: {
fontSize: 14, fontSize: 15,
lineHeight: 19, lineHeight: 20,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_400Regular',
color: colors.text, color: colors.text,
paddingVertical: Platform.OS === 'ios' ? 8 : 4, paddingVertical: Platform.OS === 'ios' ? 9 : 5,
}, },
sendBtn: { sendBtn: {
width: 36, width: 38,
height: 36, height: 38,
borderRadius: 18, borderRadius: 19,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginLeft: 6, marginLeft: 6,

View File

@ -1,4 +1,4 @@
import { View, Text, Pressable, Image, StyleSheet } from 'react-native'; import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';
@ -39,25 +39,21 @@ export function RoomCard({ room, onPress }: Props) {
.join(''); .join('');
return ( return (
<Pressable onPress={onPress} android_ripple={{ color: '#f5f5f5' }}> <TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<View style={styles.row}> <View style={styles.row}>
<View <View style={[styles.avatar, { backgroundColor: room.isPublic ? '#EFF6FF' : colors.surfaceElevated }]}>
style={[ {room.avatarUrl ? (
styles.avatar, <Image source={{ uri: room.avatarUrl }} style={styles.avatarImg} />
{ backgroundColor: room.isPublic ? colors.surface : colors.surfaceElevated }, ) : !room.isPublic ? (
]} <Text style={styles.avatarInitials}>{initials}</Text>
> ) : (
{room.avatarUrl ? ( <Ionicons name="people" size={22} color="#3B82F6" />
<Image source={{ uri: room.avatarUrl }} style={styles.avatarImg} /> )}
) : !room.isPublic ? ( </View>
<Text style={styles.avatarInitials}>{initials}</Text>
) : (
<Ionicons name="globe-outline" size={20} color="#007AFF" />
)}
</View>
<View style={styles.info}> <View style={styles.info}>
<View style={styles.headerRow}> <View style={styles.headerRow}>
<View style={styles.nameWrap}>
<Text style={styles.name} numberOfLines={1}> <Text style={styles.name} numberOfLines={1}>
{room.name} {room.name}
</Text> </Text>
@ -66,29 +62,32 @@ export function RoomCard({ room, onPress }: Props) {
<Text style={styles.defaultBadgeText}>Standard</Text> <Text style={styles.defaultBadgeText}>Standard</Text>
</View> </View>
)} )}
</View>
<View style={styles.metaRight}>
{room.lastMessage && ( {room.lastMessage && (
<Text style={styles.time}> <Text style={styles.time}>
{formatTime(room.lastMessage.createdAt, t('chat.just_now'))} {formatTime(room.lastMessage.createdAt, t('chat.just_now'))}
</Text> </Text>
)} )}
</View> </View>
<View style={styles.footerRow}> </View>
<View style={styles.footerTextWrap}>
{room.lastMessage ? ( <View style={styles.footerRow}>
<Text style={styles.lastMessage} numberOfLines={1}> <View style={styles.footerTextWrap}>
<Text style={{ fontFamily: 'Nunito_700Bold' }}> {room.lastMessage ? (
{room.lastMessage.senderName}:{' '} <Text style={styles.lastMessage} numberOfLines={1}>
</Text> <Text style={styles.senderName}>{room.lastMessage.senderName}: </Text>
{room.lastMessage.content} {room.lastMessage.content}
</Text> </Text>
) : room.description ? ( ) : room.description ? (
<Text style={styles.description} numberOfLines={1}> <Text style={styles.description} numberOfLines={1}>
{room.description} {room.description}
</Text> </Text>
) : null} ) : null}
</View> </View>
<View style={styles.footerRight}>
<View style={styles.metaPill}> <View style={styles.metaPill}>
<Ionicons name="people" size={11} color="#a3a3a3" /> <Ionicons name="people" size={10} color={colors.textMuted} />
<Text style={styles.memberCount}>{room.memberCount}</Text> <Text style={styles.memberCount}>{room.memberCount}</Text>
</View> </View>
{!room.isMember && ( {!room.isMember && (
@ -98,38 +97,40 @@ export function RoomCard({ room, onPress }: Props) {
)} )}
</View> </View>
</View> </View>
</View>
</View> </View>
</Pressable> </TouchableOpacity>
); );
} }
function makeStyles(colors: ReturnType<typeof useColors>) { function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({ return StyleSheet.create({
row: { row: {
width: '100%',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 14, paddingHorizontal: 16,
paddingVertical: 11, paddingVertical: 12,
backgroundColor: colors.bg, backgroundColor: colors.bg,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border, borderBottomColor: colors.border,
minHeight: 68,
}, },
avatar: { avatar: {
width: 42, width: 48,
height: 42, height: 48,
borderRadius: 21, borderRadius: 24,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
overflow: 'hidden', overflow: 'hidden',
marginRight: 10, marginRight: 12,
flexShrink: 0,
}, },
avatarImg: { avatarImg: {
width: 42, width: 48,
height: 42, height: 48,
}, },
avatarInitials: { avatarInitials: {
fontSize: 13, fontSize: 15,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
color: colors.textMuted, color: colors.textMuted,
}, },
@ -140,79 +141,93 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
headerRow: { headerRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 3,
},
nameWrap: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
minWidth: 0,
},
metaRight: {
marginLeft: 8,
flexShrink: 0,
}, },
footerRow: { footerRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginTop: 3,
}, },
footerTextWrap: { footerTextWrap: {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
metaPill: { footerRight: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 6,
marginLeft: 8, marginLeft: 8,
paddingHorizontal: 6, flexShrink: 0,
paddingVertical: 2,
borderRadius: 8,
backgroundColor: colors.surfaceElevated,
}, },
name: { name: {
fontSize: 14, fontSize: 15,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
color: colors.text, color: colors.text,
flexShrink: 1, flexShrink: 1,
}, },
defaultBadge: { defaultBadge: {
marginLeft: 6,
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 1, paddingVertical: 2,
backgroundColor: colors.surface, backgroundColor: '#EFF6FF',
borderRadius: 8, borderRadius: 6,
flexShrink: 0,
}, },
defaultBadgeText: { defaultBadgeText: {
fontSize: 9, fontSize: 9,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
color: '#007AFF', color: '#3B82F6',
}, },
lastMessage: { lastMessage: {
fontSize: 12, fontSize: 13,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_400Regular',
color: colors.textMuted, color: colors.textMuted,
}, },
senderName: {
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
},
description: { description: {
fontSize: 12, fontSize: 13,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_400Regular',
color: colors.textMuted, color: colors.textMuted,
}, },
right: { metaPill: {
alignItems: 'flex-end', flexDirection: 'row',
marginLeft: 8, alignItems: 'center',
gap: 3,
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 8,
backgroundColor: colors.surfaceElevated,
}, },
memberCount: { memberCount: {
fontSize: 11, fontSize: 11,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
color: colors.textMuted, color: colors.textMuted,
marginLeft: 3,
}, },
time: { time: {
fontSize: 10, fontSize: 11,
fontFamily: 'Nunito_500Medium', fontFamily: 'Nunito_500Medium',
color: colors.textMuted, color: colors.textMuted,
marginLeft: 'auto',
paddingLeft: 6,
}, },
joinBadge: { joinBadge: {
marginLeft: 6,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 3, paddingVertical: 3,
backgroundColor: colors.surface, backgroundColor: '#EFF6FF',
borderRadius: 10, borderRadius: 10,
}, },
joinBadgeText: { joinBadgeText: {
fontSize: 10, fontSize: 11,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
color: '#007AFF', color: '#007AFF',
}, },