feat(native): chat v1.0 — DM-only layout, search field, theme colors
Removes 2-tab Groups/DMs layout; Chat screen is now DM-only for v1.0. Groups tab state, rooms query, RoomCard/CreateRoomSheet imports removed. Replaces static title+create-button header with sticky search field (client-side filter on partnerName + lastMessage). No create-DM button added — /dm-new route does not exist yet (follow-up task). All #007AFF in chat.tsx replaced with colors.brandOrange. Adds chat.search_placeholder to de/en/fr locales. Tab-bar styles kept in makeStyles (dead code, v1.1 Groups comeback path). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
500f673e53
commit
a8ccfab274
@ -4,6 +4,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
FlatList,
|
FlatList,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
TextInput,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image,
|
Image,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
@ -15,8 +16,6 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { AppHeader } from '../../components/AppHeader';
|
import { AppHeader } from '../../components/AppHeader';
|
||||||
import { RoomCard, type Room } from '../../components/chat/RoomCard';
|
|
||||||
import { CreateRoomSheet } from '../../components/chat/CreateRoomSheet';
|
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
type DmConversation = {
|
type DmConversation = {
|
||||||
@ -60,7 +59,7 @@ function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }
|
|||||||
<Text style={styles.dmName} numberOfLines={1}>
|
<Text style={styles.dmName} numberOfLines={1}>
|
||||||
{conv.partnerName}
|
{conv.partnerName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.dmTime, { color: hasUnread ? '#007AFF' : colors.textMuted }]}>
|
<Text style={[styles.dmTime, { color: hasUnread ? colors.brandOrange : colors.textMuted }]}>
|
||||||
{formatTime(conv.lastMessageAt, t('chat.just_now'))}
|
{formatTime(conv.lastMessageAt, t('chat.just_now'))}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -97,19 +96,7 @@ export default function ChatScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const styles = makeStyles(colors);
|
const styles = makeStyles(colors);
|
||||||
const [tab, setTab] = useState<'groups' | 'direct'>('groups');
|
const [search, setSearch] = useState('');
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: rooms = [],
|
|
||||||
isLoading: loadingRooms,
|
|
||||||
isRefetching: refetchingRooms,
|
|
||||||
refetch: refetchRooms,
|
|
||||||
} = useQuery<Room[]>({
|
|
||||||
queryKey: ['chat-rooms'],
|
|
||||||
queryFn: () => apiFetch('/api/chat/rooms'),
|
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: convs = [],
|
data: convs = [],
|
||||||
@ -120,17 +107,14 @@ export default function ChatScreen() {
|
|||||||
queryKey: ['dm-conversations'],
|
queryKey: ['dm-conversations'],
|
||||||
queryFn: () => apiFetch('/api/chat/dm-conversations'),
|
queryFn: () => apiFetch('/api/chat/dm-conversations'),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
enabled: tab === 'direct',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const unreadDms = convs.reduce((s, c) => s + (c.unreadCount ?? 0), 0);
|
const filtered = search.trim()
|
||||||
|
? convs.filter((c) =>
|
||||||
const openRoom = useCallback(
|
c.partnerName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
(roomId: string) => {
|
c.lastMessage.toLowerCase().includes(search.toLowerCase()),
|
||||||
router.push(`/room?roomId=${roomId}`);
|
)
|
||||||
},
|
: convs;
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
const openDm = useCallback(
|
const openDm = useCallback(
|
||||||
(userId: string) => {
|
(userId: string) => {
|
||||||
@ -143,100 +127,43 @@ export default function ChatScreen() {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Search header */}
|
||||||
<View style={styles.headerSection}>
|
<View style={styles.headerSection}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.searchRow}>
|
||||||
<Text style={styles.title}>{t('chat.title')}</Text>
|
<Ionicons name="search" size={16} color={colors.textMuted} style={styles.searchIcon} />
|
||||||
{tab === 'groups' && (
|
<TextInput
|
||||||
<TouchableOpacity
|
style={styles.searchInput}
|
||||||
onPress={() => setCreateOpen(true)}
|
value={search}
|
||||||
activeOpacity={0.7}
|
onChangeText={setSearch}
|
||||||
style={styles.createBtn}
|
placeholder={t('chat.search_placeholder')}
|
||||||
>
|
placeholderTextColor={colors.textMuted}
|
||||||
<Ionicons name="add" size={20} color="#fff" />
|
returnKeyType="search"
|
||||||
|
clearButtonMode="never"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
{search.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearch('')} activeOpacity={0.7} hitSlop={8}>
|
||||||
|
<Ionicons name="close-circle" size={16} color={colors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<View style={styles.tabs}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setTab('groups')}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
style={[styles.tab, tab === 'groups' && styles.tabActive]}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="people"
|
|
||||||
size={14}
|
|
||||||
color={tab === 'groups' ? '#007AFF' : '#737373'}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.tabText, tab === 'groups' && styles.tabTextActive]}>
|
|
||||||
{t('chat.groups')}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setTab('direct')}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
style={[styles.tab, tab === 'direct' && styles.tabActive]}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="chatbubbles"
|
|
||||||
size={14}
|
|
||||||
color={tab === 'direct' ? '#007AFF' : '#737373'}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.tabText, tab === 'direct' && styles.tabTextActive]}>
|
|
||||||
{t('chat.direct')}
|
|
||||||
</Text>
|
|
||||||
{unreadDms > 0 && (
|
|
||||||
<View style={styles.tabBadge}>
|
|
||||||
<Text style={styles.tabBadgeText}>{unreadDms}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{tab === 'groups' ? (
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={loadingRooms ? [] : rooms}
|
data={loadingDms ? [] : filtered}
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={refetchingRooms}
|
|
||||||
onRefresh={refetchRooms}
|
|
||||||
tintColor="#007AFF"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
ListEmptyComponent={
|
|
||||||
loadingRooms ? (
|
|
||||||
<View style={styles.emptyBox}>
|
|
||||||
<ActivityIndicator color="#007AFF" />
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={styles.emptyBox}>
|
|
||||||
<Ionicons name="people-outline" size={42} color="#d4d4d4" />
|
|
||||||
<Text style={styles.emptyText}>{t('chat.no_rooms')}</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
renderItem={({ item }) => <RoomCard room={item} onPress={() => openRoom(item.id)} />}
|
|
||||||
contentContainerStyle={{ paddingBottom: 100 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
data={loadingDms ? [] : convs}
|
|
||||||
keyExtractor={(item) => item.partnerId}
|
keyExtractor={(item) => item.partnerId}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refetchingDms}
|
refreshing={refetchingDms}
|
||||||
onRefresh={refetchDms}
|
onRefresh={refetchDms}
|
||||||
tintColor="#007AFF"
|
tintColor={colors.brandOrange}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
loadingDms ? (
|
loadingDms ? (
|
||||||
<View style={styles.emptyBox}>
|
<View style={styles.emptyBox}>
|
||||||
<ActivityIndicator color="#007AFF" />
|
<ActivityIndicator color={colors.brandOrange} />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.emptyBox}>
|
<View style={styles.emptyBox}>
|
||||||
@ -248,16 +175,6 @@ export default function ChatScreen() {
|
|||||||
renderItem={({ item }) => <DmItem conv={item} onPress={() => openDm(item.partnerId)} />}
|
renderItem={({ item }) => <DmItem conv={item} onPress={() => openDm(item.partnerId)} />}
|
||||||
contentContainerStyle={{ paddingBottom: 100 }}
|
contentContainerStyle={{ paddingBottom: 100 }}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateRoomSheet
|
|
||||||
visible={createOpen}
|
|
||||||
onClose={() => setCreateOpen(false)}
|
|
||||||
onCreated={(room) => {
|
|
||||||
refetchRooms();
|
|
||||||
openRoom(room.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -267,76 +184,27 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
container: { flex: 1, backgroundColor: colors.bg },
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
headerSection: {
|
headerSection: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 14,
|
paddingTop: 12,
|
||||||
paddingBottom: 10,
|
paddingBottom: 10,
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
titleRow: {
|
searchRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 22,
|
|
||||||
fontFamily: 'Nunito_800ExtraBold',
|
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
createBtn: {
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
borderRadius: 17,
|
|
||||||
backgroundColor: '#007AFF',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
tabs: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
marginTop: 12,
|
|
||||||
backgroundColor: colors.surfaceElevated,
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
padding: 3,
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
},
|
},
|
||||||
tab: {
|
searchIcon: { marginRight: 7 },
|
||||||
|
searchInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
fontSize: 14,
|
||||||
alignItems: 'center',
|
fontFamily: 'Nunito_500Medium',
|
||||||
justifyContent: 'center',
|
color: colors.text,
|
||||||
paddingVertical: 7,
|
paddingVertical: 0,
|
||||||
borderRadius: 8,
|
|
||||||
},
|
|
||||||
tabActive: {
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOpacity: 0.05,
|
|
||||||
shadowRadius: 2,
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
},
|
|
||||||
tabText: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
color: colors.textMuted,
|
|
||||||
marginLeft: 5,
|
|
||||||
},
|
|
||||||
tabTextActive: {
|
|
||||||
color: '#007AFF',
|
|
||||||
fontFamily: 'Nunito_700Bold',
|
|
||||||
},
|
|
||||||
tabBadge: {
|
|
||||||
minWidth: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: '#007AFF',
|
|
||||||
paddingHorizontal: 4,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginLeft: 5,
|
|
||||||
},
|
|
||||||
tabBadgeText: {
|
|
||||||
fontSize: 9,
|
|
||||||
fontFamily: 'Nunito_700Bold',
|
|
||||||
color: '#fff',
|
|
||||||
},
|
},
|
||||||
emptyBox: {
|
emptyBox: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -403,7 +271,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
height: 20,
|
height: 20,
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
backgroundColor: '#007AFF',
|
backgroundColor: colors.brandOrange,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
@ -413,5 +281,53 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
},
|
},
|
||||||
|
// Kept for v1.1 Groups comeback — tab styles no longer rendered
|
||||||
|
tabs: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: 12,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 3,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 7,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
tabActive: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
},
|
||||||
|
tabText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: colors.textMuted,
|
||||||
|
marginLeft: 5,
|
||||||
|
},
|
||||||
|
tabTextActive: {
|
||||||
|
color: colors.brandOrange,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
},
|
||||||
|
tabBadge: {
|
||||||
|
minWidth: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginLeft: 5,
|
||||||
|
},
|
||||||
|
tabBadgeText: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -741,7 +741,8 @@
|
|||||||
"approve": "Annehmen",
|
"approve": "Annehmen",
|
||||||
"reject": "Ablehnen",
|
"reject": "Ablehnen",
|
||||||
"avatar_updated": "Gruppenbild aktualisiert",
|
"avatar_updated": "Gruppenbild aktualisiert",
|
||||||
"send": "Senden"
|
"send": "Senden",
|
||||||
|
"search_placeholder": "Konversationen durchsuchen…"
|
||||||
},
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"compose_placeholder": "Was bewegt dich gerade?",
|
"compose_placeholder": "Was bewegt dich gerade?",
|
||||||
|
|||||||
@ -741,7 +741,8 @@
|
|||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
"avatar_updated": "Group photo updated",
|
"avatar_updated": "Group photo updated",
|
||||||
"send": "Send"
|
"send": "Send",
|
||||||
|
"search_placeholder": "Search conversations…"
|
||||||
},
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"compose_placeholder": "What's on your mind?",
|
"compose_placeholder": "What's on your mind?",
|
||||||
|
|||||||
@ -741,7 +741,8 @@
|
|||||||
"approve": "Accepter",
|
"approve": "Accepter",
|
||||||
"reject": "Refuser",
|
"reject": "Refuser",
|
||||||
"avatar_updated": "Photo du groupe mise à jour",
|
"avatar_updated": "Photo du groupe mise à jour",
|
||||||
"send": "Envoyer"
|
"send": "Envoyer",
|
||||||
|
"search_placeholder": "Rechercher des conversations…"
|
||||||
},
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"compose_placeholder": "Qu'est-ce qui vous préoccupe en ce moment ?",
|
"compose_placeholder": "Qu'est-ce qui vous préoccupe en ce moment ?",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user