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

@ -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<string | null>(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 }) => (
<>
<List.Item
title={item.username}
description={item.bio || item.email}
left={() => (
<View style={styles.avatarContainer}>
<Avatar.Text
size={48}
label={item.username.charAt(0).toUpperCase()}
/>
{item.isOnline && (
<View style={styles.onlineIndicator} />
)}
</View>
)}
right={() => (
<View style={styles.actions}>
<IconButton
icon="message-outline"
size={24}
onPress={() => handleStartChat(item.id)}
/>
</View>
)}
style={styles.listItem}
/>
<Divider />
</>
);
const renderUser = ({ item, index }: { item: User; index: number }) => {
const isCreatingChat = creatingChatWithId === item.id;
return (
<Animated.View
style={{
opacity: fadeAnim,
transform: [{
translateY: fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: [50, 0],
}),
}],
}}
>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => handleStartChat(item.id)}
disabled={isCreatingChat}
>
<Surface style={styles.userCard} elevation={1}>
<View style={styles.userContent}>
<View style={styles.avatarContainer}>
<Avatar.Text
size={56}
label={item.username.charAt(0).toUpperCase()}
style={[styles.avatar, { backgroundColor: getAvatarColor(item.username) }]}
/>
{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 (
<View style={styles.container}>
<View style={styles.searchContainer}>
<Searchbar
placeholder="Поиск пользователей..."
onChangeText={setSearchQuery}
value={searchQuery}
style={styles.searchbar}
/>
</View>
{/* Заголовок с поиском */}
<Surface style={styles.header} elevation={2}>
<View style={styles.searchContainer}>
<Searchbar
placeholder="Поиск по @username..."
onChangeText={setSearchQuery}
value={searchQuery}
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
value={selectedTab}
onValueChange={setSelectedTab}
buttons={[
{ value: 'all', label: 'Все пользователи' },
{ value: 'contacts', label: 'Мои контакты' },
{ value: 'online', label: 'Онлайн' },
{
value: 'all',
label: 'Все',
icon: 'account-group',
},
{
value: 'online',
label: 'Онлайн',
icon: 'circle',
},
{
value: 'contacts',
label: 'Контакты',
icon: 'account-star',
},
]}
style={styles.tabs}
/>
@ -118,13 +248,23 @@ export const ContactsScreen = ({ navigation }: any) => {
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<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} не найден`
: selectedTab === 'online'
? 'Нет пользователей онлайн'
: 'Нет пользователей'}
: 'Нет доступных пользователей'}
</Text>
{searchQuery && (
<Text variant="bodyMedium" style={[styles.emptyText, { marginTop: 8 }]}>
Проверьте правильность никнейма
</Text>
)}
</View>
}
/>
@ -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',
},
});