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

View File

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

View File

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