Implement beautiful contacts screen with user search and instant messaging
This commit is contained in:
@ -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> {
|
||||||
|
@ -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',
|
||||||
},
|
},
|
||||||
});
|
});
|
Reference in New Issue
Block a user