Implement beautiful contacts screen with user search and instant messaging

This commit is contained in:
Bivekich
2025-08-06 05:27:17 +03:00
parent 8d7b3718ce
commit 592aa33f21
2 changed files with 301 additions and 68 deletions

View File

@ -54,21 +54,33 @@ export class ConversationsService {
} }
async findOrCreatePrivate(user1Id: string, user2Id: string): Promise<Conversation> { async findOrCreatePrivate(user1Id: string, user2Id: string): Promise<Conversation> {
// Проверяем существующую приватную беседу между двумя пользователями
const existingConversation = await this.conversationsRepository const existingConversation = await this.conversationsRepository
.createQueryBuilder('conversation') .createQueryBuilder('conversation')
.leftJoin('conversation.participants', 'p1') .leftJoinAndSelect('conversation.participants', 'participants')
.leftJoin('conversation.participants', 'p2')
.where('conversation.isGroup = false') .where('conversation.isGroup = false')
.andWhere('p1.id = :user1Id', { user1Id }) .andWhere((qb) => {
.andWhere('p2.id = :user2Id', { user2Id }) const subQuery = qb.subQuery()
.select('c.id')
.from('conversations', 'c')
.leftJoin('conversation_participants', 'cp1', 'cp1.conversationId = c.id')
.leftJoin('conversation_participants', 'cp2', 'cp2.conversationId = c.id')
.where('c.isGroup = false')
.andWhere('cp1.userId = :user1Id', { user1Id })
.andWhere('cp2.userId = :user2Id', { user2Id })
.andWhere('(SELECT COUNT(*) FROM conversation_participants WHERE conversationId = c.id) = 2')
.getQuery();
return 'conversation.id IN ' + subQuery;
})
.getOne(); .getOne();
if (existingConversation) { if (existingConversation) {
return existingConversation; return existingConversation;
} }
// Создаем новую приватную беседу // Создаем новую приватную беседу с двумя участниками
return this.create([], undefined, false); const participants = [{ id: user1Id }, { id: user2Id }] as any;
return this.create(participants, undefined, false);
} }
async addParticipant(conversationId: string, userId: string, participantId: string): Promise<Conversation> { async addParticipant(conversationId: string, userId: string, participantId: string): Promise<Conversation> {

View File

@ -1,16 +1,19 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { View, StyleSheet, FlatList, ScrollView } from 'react-native'; import { View, StyleSheet, FlatList, ScrollView, Animated, TouchableOpacity } from 'react-native';
import { Searchbar, List, Avatar, Text, Chip, FAB, Divider, IconButton, SegmentedButtons } from 'react-native-paper'; import { Searchbar, List, Avatar, Text, Chip, FAB, Divider, IconButton, SegmentedButtons, Surface, Button, ActivityIndicator, Badge } from 'react-native-paper';
import { useQuery, useMutation } from '@apollo/client'; import { useQuery, useMutation } from '@apollo/client';
import { GET_USERS } from '../graphql/queries'; import { GET_USERS } from '../graphql/queries';
import { CREATE_PRIVATE_CONVERSATION } from '../graphql/mutations'; import { CREATE_PRIVATE_CONVERSATION } from '../graphql/mutations';
import { User } from '../types'; import { User } from '../types';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { MaterialCommunityIcons } from '@expo/vector-icons';
export const ContactsScreen = ({ navigation }: any) => { export const ContactsScreen = ({ navigation }: any) => {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedTab, setSelectedTab] = useState('all'); const [selectedTab, setSelectedTab] = useState('all');
const [creatingChatWithId, setCreatingChatWithId] = useState<string | null>(null);
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const fadeAnim = useState(new Animated.Value(0))[0];
const { data, loading, refetch } = useQuery(GET_USERS); const { data, loading, refetch } = useQuery(GET_USERS);
@ -23,15 +26,28 @@ export const ContactsScreen = ({ navigation }: any) => {
title: getOtherParticipantName(data.createPrivateConversation), title: getOtherParticipantName(data.createPrivateConversation),
}, },
}); });
setCreatingChatWithId(null);
}, },
onError: () => {
setCreatingChatWithId(null);
}
}); });
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 500,
useNativeDriver: true,
}).start();
}, []);
const getOtherParticipantName = (conversation: any) => { const getOtherParticipantName = (conversation: any) => {
const otherParticipant = conversation.participants?.find((p: User) => p.id !== currentUser?.id); const otherParticipant = conversation.participants?.find((p: User) => p.id !== currentUser?.id);
return otherParticipant?.username || 'Чат'; return otherParticipant?.username || 'Чат';
}; };
const handleStartChat = async (userId: string) => { const handleStartChat = async (userId: string) => {
setCreatingChatWithId(userId);
try { try {
await createConversation({ await createConversation({
variables: { recipientId: userId }, variables: { recipientId: userId },
@ -52,55 +68,169 @@ export const ContactsScreen = ({ navigation }: any) => {
return true; return true;
}) || []; }) || [];
const renderUser = ({ item }: { item: User }) => ( const renderUser = ({ item, index }: { item: User; index: number }) => {
<> const isCreatingChat = creatingChatWithId === item.id;
<List.Item
title={item.username} return (
description={item.bio || item.email} <Animated.View
left={() => ( style={{
<View style={styles.avatarContainer}> opacity: fadeAnim,
<Avatar.Text transform: [{
size={48} translateY: fadeAnim.interpolate({
label={item.username.charAt(0).toUpperCase()} inputRange: [0, 1],
/> outputRange: [50, 0],
{item.isOnline && ( }),
<View style={styles.onlineIndicator} /> }],
)} }}
</View> >
)} <TouchableOpacity
right={() => ( activeOpacity={0.7}
<View style={styles.actions}> onPress={() => handleStartChat(item.id)}
<IconButton disabled={isCreatingChat}
icon="message-outline" >
size={24} <Surface style={styles.userCard} elevation={1}>
onPress={() => handleStartChat(item.id)} <View style={styles.userContent}>
/> <View style={styles.avatarContainer}>
</View> <Avatar.Text
)} size={56}
style={styles.listItem} label={item.username.charAt(0).toUpperCase()}
/> style={[styles.avatar, { backgroundColor: getAvatarColor(item.username) }]}
<Divider /> />
</> {item.isOnline && (
); <View style={styles.onlineIndicator}>
<MaterialCommunityIcons name="circle" size={14} color="#4CAF50" />
</View>
)}
</View>
<View style={styles.userInfo}>
<View style={styles.userHeader}>
<Text variant="titleMedium" style={styles.username}>
@{item.username}
</Text>
{item.isOnline && (
<Chip
mode="flat"
compact
style={styles.onlineChip}
textStyle={styles.onlineChipText}
>
В сети
</Chip>
)}
</View>
<Text variant="bodyMedium" style={styles.userBio} numberOfLines={2}>
{item.bio || `${item.email}`}
</Text>
{!item.isOnline && item.lastSeen && (
<Text variant="bodySmall" style={styles.lastSeen}>
Был(а) {formatLastSeen(item.lastSeen)}
</Text>
)}
</View>
<View style={styles.actionContainer}>
{isCreatingChat ? (
<ActivityIndicator size="small" color="#2196F3" />
) : (
<IconButton
icon="message-plus-outline"
size={28}
iconColor="#2196F3"
style={styles.messageButton}
onPress={() => handleStartChat(item.id)}
/>
)}
</View>
</View>
</Surface>
</TouchableOpacity>
</Animated.View>
);
};
// Вспомогательные функции
const getAvatarColor = (username: string) => {
const colors = ['#2196F3', '#4CAF50', '#FF5722', '#9C27B0', '#FF9800', '#00BCD4'];
const index = username.charCodeAt(0) % colors.length;
return colors[index];
};
const formatLastSeen = (date: Date | string) => {
const lastSeen = new Date(date);
const now = new Date();
const diffMs = now.getTime() - lastSeen.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'только что';
if (diffMins < 60) return `${diffMins} мин. назад`;
if (diffHours < 24) return `${diffHours} ч. назад`;
if (diffDays < 7) return `${diffDays} дн. назад`;
return lastSeen.toLocaleDateString('ru-RU');
};
// Подсчет статистики
const onlineUsersCount = data?.users?.filter((u: User) => u.isOnline && u.id !== currentUser?.id).length || 0;
const totalUsersCount = data?.users?.filter((u: User) => u.id !== currentUser?.id).length || 0;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.searchContainer}> {/* Заголовок с поиском */}
<Searchbar <Surface style={styles.header} elevation={2}>
placeholder="Поиск пользователей..." <View style={styles.searchContainer}>
onChangeText={setSearchQuery} <Searchbar
value={searchQuery} placeholder="Поиск по @username..."
style={styles.searchbar} onChangeText={setSearchQuery}
/> value={searchQuery}
</View> style={styles.searchbar}
icon="account-search"
elevation={0}
/>
<Text variant="bodySmall" style={styles.searchHint}>
Введите никнейм для поиска пользователя
</Text>
</View>
{/* Статистика */}
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text variant="headlineSmall" style={styles.statNumber}>{totalUsersCount}</Text>
<Text variant="bodySmall" style={styles.statLabel}>пользователей</Text>
</View>
<View style={styles.statDivider} />
<View style={styles.statItem}>
<View style={styles.onlineStatContainer}>
<MaterialCommunityIcons name="circle" size={10} color="#4CAF50" style={{ marginRight: 4 }} />
<Text variant="headlineSmall" style={[styles.statNumber, { color: '#4CAF50' }]}>{onlineUsersCount}</Text>
</View>
<Text variant="bodySmall" style={styles.statLabel}>в сети</Text>
</View>
</View>
</Surface>
<SegmentedButtons <SegmentedButtons
value={selectedTab} value={selectedTab}
onValueChange={setSelectedTab} onValueChange={setSelectedTab}
buttons={[ buttons={[
{ value: 'all', label: 'Все пользователи' }, {
{ value: 'contacts', label: 'Мои контакты' }, value: 'all',
{ value: 'online', label: 'Онлайн' }, label: 'Все',
icon: 'account-group',
},
{
value: 'online',
label: 'Онлайн',
icon: 'circle',
},
{
value: 'contacts',
label: 'Контакты',
icon: 'account-star',
},
]} ]}
style={styles.tabs} style={styles.tabs}
/> />
@ -118,13 +248,23 @@ export const ContactsScreen = ({ navigation }: any) => {
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
ListEmptyComponent={ ListEmptyComponent={
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Text variant="bodyLarge" style={styles.emptyText}> <MaterialCommunityIcons
name={searchQuery ? 'account-search' : 'account-group-outline'}
size={64}
color="#ccc"
/>
<Text variant="titleMedium" style={styles.emptyText}>
{searchQuery {searchQuery
? 'Пользователи не найдены' ? `Пользователь @${searchQuery} не найден`
: selectedTab === 'online' : selectedTab === 'online'
? 'Нет пользователей онлайн' ? 'Нет пользователей онлайн'
: 'Нет пользователей'} : 'Нет доступных пользователей'}
</Text> </Text>
{searchQuery && (
<Text variant="bodyMedium" style={[styles.emptyText, { marginTop: 8 }]}>
Проверьте правильность никнейма
</Text>
)}
</View> </View>
} }
/> />
@ -135,44 +275,123 @@ export const ContactsScreen = ({ navigation }: any) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
paddingBottom: 16,
}, },
searchContainer: { searchContainer: {
padding: 16, padding: 16,
paddingBottom: 8, paddingBottom: 8,
}, },
searchbar: { searchbar: {
elevation: 0, backgroundColor: '#f8f9fa',
backgroundColor: '#f5f5f5', borderRadius: 12,
},
searchHint: {
color: '#666',
marginTop: 8,
textAlign: 'center',
},
statsContainer: {
flexDirection: 'row',
justifyContent: 'center',
paddingHorizontal: 16,
paddingTop: 8,
},
statItem: {
alignItems: 'center',
paddingHorizontal: 24,
},
statDivider: {
width: 1,
height: 40,
backgroundColor: '#e0e0e0',
},
statNumber: {
fontWeight: 'bold',
},
statLabel: {
color: '#666',
marginTop: 2,
},
onlineStatContainer: {
flexDirection: 'row',
alignItems: 'center',
}, },
tabs: { tabs: {
marginHorizontal: 16, marginHorizontal: 16,
marginBottom: 8, marginVertical: 16,
}, },
listContent: { listContent: {
flexGrow: 1,
},
listItem: {
paddingVertical: 8,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 16,
},
userCard: {
backgroundColor: '#ffffff',
borderRadius: 16,
marginBottom: 12,
overflow: 'hidden',
},
userContent: {
flexDirection: 'row',
padding: 16,
alignItems: 'center',
}, },
avatarContainer: { avatarContainer: {
position: 'relative', position: 'relative',
marginRight: 16,
},
avatar: {
elevation: 2,
}, },
onlineIndicator: { onlineIndicator: {
position: 'absolute', position: 'absolute',
bottom: 0, bottom: 2,
right: 0, right: 2,
width: 14, backgroundColor: '#ffffff',
height: 14,
borderRadius: 7, borderRadius: 7,
backgroundColor: '#4CAF50', padding: 1,
borderWidth: 2,
borderColor: '#ffffff',
}, },
actions: { userInfo: {
flex: 1,
marginRight: 8,
},
userHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 4,
},
username: {
fontWeight: '600',
color: '#1a1a1a',
},
onlineChip: {
marginLeft: 8,
backgroundColor: '#e8f5e9',
height: 22,
},
onlineChipText: {
fontSize: 11,
color: '#4CAF50',
},
userBio: {
color: '#666',
lineHeight: 20,
},
lastSeen: {
color: '#999',
marginTop: 4,
fontSize: 12,
},
actionContainer: {
justifyContent: 'center',
minWidth: 48,
alignItems: 'center',
},
messageButton: {
margin: 0,
}, },
emptyContainer: { emptyContainer: {
flex: 1, flex: 1,
@ -182,5 +401,7 @@ const styles = StyleSheet.create({
}, },
emptyText: { emptyText: {
color: '#666', color: '#666',
fontSize: 16,
textAlign: 'center',
}, },
}); });