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