diff --git a/backend/src/modules/conversations/conversations.service.ts b/backend/src/modules/conversations/conversations.service.ts index f33100d..ac9dbb4 100644 --- a/backend/src/modules/conversations/conversations.service.ts +++ b/backend/src/modules/conversations/conversations.service.ts @@ -54,21 +54,33 @@ export class ConversationsService { } async findOrCreatePrivate(user1Id: string, user2Id: string): Promise { + // Проверяем существующую приватную беседу между двумя пользователями const existingConversation = await this.conversationsRepository .createQueryBuilder('conversation') - .leftJoin('conversation.participants', 'p1') - .leftJoin('conversation.participants', 'p2') + .leftJoinAndSelect('conversation.participants', 'participants') .where('conversation.isGroup = false') - .andWhere('p1.id = :user1Id', { user1Id }) - .andWhere('p2.id = :user2Id', { user2Id }) + .andWhere((qb) => { + 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(); if (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 { diff --git a/frontend/src/screens/ContactsScreen.tsx b/frontend/src/screens/ContactsScreen.tsx index a8316c4..8eeaa5b 100644 --- a/frontend/src/screens/ContactsScreen.tsx +++ b/frontend/src/screens/ContactsScreen.tsx @@ -1,16 +1,19 @@ -import React, { useState } from 'react'; -import { View, StyleSheet, FlatList, ScrollView } from 'react-native'; -import { Searchbar, List, Avatar, Text, Chip, FAB, Divider, IconButton, SegmentedButtons } from 'react-native-paper'; +import React, { useState, useEffect } from 'react'; +import { View, StyleSheet, FlatList, ScrollView, Animated, TouchableOpacity } from 'react-native'; +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 { GET_USERS } from '../graphql/queries'; import { CREATE_PRIVATE_CONVERSATION } from '../graphql/mutations'; import { User } from '../types'; import { useAuth } from '../contexts/AuthContext'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; export const ContactsScreen = ({ navigation }: any) => { const [searchQuery, setSearchQuery] = useState(''); const [selectedTab, setSelectedTab] = useState('all'); + const [creatingChatWithId, setCreatingChatWithId] = useState(null); const { user: currentUser } = useAuth(); + const fadeAnim = useState(new Animated.Value(0))[0]; const { data, loading, refetch } = useQuery(GET_USERS); @@ -23,15 +26,28 @@ export const ContactsScreen = ({ navigation }: any) => { 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 otherParticipant = conversation.participants?.find((p: User) => p.id !== currentUser?.id); return otherParticipant?.username || 'Чат'; }; const handleStartChat = async (userId: string) => { + setCreatingChatWithId(userId); try { await createConversation({ variables: { recipientId: userId }, @@ -52,55 +68,169 @@ export const ContactsScreen = ({ navigation }: any) => { return true; }) || []; - const renderUser = ({ item }: { item: User }) => ( - <> - ( - - - {item.isOnline && ( - - )} - - )} - right={() => ( - - handleStartChat(item.id)} - /> - - )} - style={styles.listItem} - /> - - - ); + const renderUser = ({ item, index }: { item: User; index: number }) => { + const isCreatingChat = creatingChatWithId === item.id; + + return ( + + handleStartChat(item.id)} + disabled={isCreatingChat} + > + + + + + {item.isOnline && ( + + + + )} + + + + + + @{item.username} + + {item.isOnline && ( + + В сети + + )} + + + + {item.bio || `${item.email}`} + + + {!item.isOnline && item.lastSeen && ( + + Был(а) {formatLastSeen(item.lastSeen)} + + )} + + + + {isCreatingChat ? ( + + ) : ( + handleStartChat(item.id)} + /> + )} + + + + + + ); + }; + + // Вспомогательные функции + 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 ( - - - + {/* Заголовок с поиском */} + + + + + Введите никнейм для поиска пользователя + + + + {/* Статистика */} + + + {totalUsersCount} + пользователей + + + + + + {onlineUsersCount} + + в сети + + + @@ -118,13 +248,23 @@ export const ContactsScreen = ({ navigation }: any) => { contentContainerStyle={styles.listContent} ListEmptyComponent={ - + + {searchQuery - ? 'Пользователи не найдены' + ? `Пользователь @${searchQuery} не найден` : selectedTab === 'online' ? 'Нет пользователей онлайн' - : 'Нет пользователей'} + : 'Нет доступных пользователей'} + {searchQuery && ( + + Проверьте правильность никнейма + + )} } /> @@ -135,44 +275,123 @@ export const ContactsScreen = ({ navigation }: any) => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: '#f5f5f5', + }, + header: { backgroundColor: '#ffffff', + paddingBottom: 16, }, searchContainer: { padding: 16, paddingBottom: 8, }, searchbar: { - elevation: 0, - backgroundColor: '#f5f5f5', + backgroundColor: '#f8f9fa', + 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: { marginHorizontal: 16, - marginBottom: 8, + marginVertical: 16, }, listContent: { - flexGrow: 1, - }, - listItem: { - paddingVertical: 8, paddingHorizontal: 16, + paddingBottom: 16, + }, + userCard: { + backgroundColor: '#ffffff', + borderRadius: 16, + marginBottom: 12, + overflow: 'hidden', + }, + userContent: { + flexDirection: 'row', + padding: 16, + alignItems: 'center', }, avatarContainer: { position: 'relative', + marginRight: 16, + }, + avatar: { + elevation: 2, }, onlineIndicator: { position: 'absolute', - bottom: 0, - right: 0, - width: 14, - height: 14, + bottom: 2, + right: 2, + backgroundColor: '#ffffff', borderRadius: 7, - backgroundColor: '#4CAF50', - borderWidth: 2, - borderColor: '#ffffff', + padding: 1, }, - actions: { + userInfo: { + flex: 1, + marginRight: 8, + }, + userHeader: { flexDirection: 'row', 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: { flex: 1, @@ -182,5 +401,7 @@ const styles = StyleSheet.create({ }, emptyText: { color: '#666', + fontSize: 16, + textAlign: 'center', }, }); \ No newline at end of file