Compare commits
3 Commits
4a11a6952a
...
b3166b1dfe
Author | SHA1 | Date | |
---|---|---|---|
b3166b1dfe | |||
592aa33f21 | |||
8d7b3718ce |
95
CONTACTS_FEATURE.md
Normal file
95
CONTACTS_FEATURE.md
Normal file
@ -0,0 +1,95 @@
|
||||
# Функционал экрана "Контакты" в Prism Messenger
|
||||
|
||||
## 🎯 Основная фича: Поиск любого пользователя по никнейму
|
||||
|
||||
### Визуальный дизайн
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ 🔍 Поиск по @username... │ <- Поле поиска
|
||||
│ Введите никнейм для поиска │
|
||||
├─────────────────────────────────┤
|
||||
│ [25] [5] │ <- Статистика
|
||||
│ пользователей 🟢 в сети │
|
||||
├─────────────────────────────────┤
|
||||
│ [Все] [🟢 Онлайн] [⭐ Контакты] │ <- Фильтры
|
||||
├─────────────────────────────────┤
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ 🟦 A @alice [💬] │ │ <- Карточка пользователя
|
||||
│ │ 🟢 В сети │ │
|
||||
│ │ alice@mail.ru │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ 🟩 B @bob [💬] │ │
|
||||
│ │ Был(а) 5 мин. назад │ │
|
||||
│ │ Люблю программировать │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## ✨ Ключевые возможности
|
||||
|
||||
### 1. **Мгновенный поиск по никнейму**
|
||||
- Просто введите `@username` в поиске
|
||||
- Поиск работает в реальном времени
|
||||
- Можно найти ЛЮБОГО пользователя системы
|
||||
|
||||
### 2. **Быстрый старт чата**
|
||||
- Нажмите на карточку пользователя или иконку 💬
|
||||
- Мгновенно создается приватный чат
|
||||
- Переход сразу в экран переписки
|
||||
|
||||
### 3. **Визуальные индикаторы**
|
||||
- 🟢 **Зеленый круг** = пользователь онлайн
|
||||
- **Чип "В сети"** для активных пользователей
|
||||
- Время последнего визита для оффлайн пользователей
|
||||
- Цветные аватары с первой буквой никнейма
|
||||
|
||||
### 4. **Умные фильтры**
|
||||
- **Все** - показывает всех пользователей
|
||||
- **Онлайн** - только активные пользователи
|
||||
- **Контакты** - избранные (в разработке)
|
||||
|
||||
### 5. **Статистика в реальном времени**
|
||||
- Общее количество пользователей
|
||||
- Количество пользователей онлайн
|
||||
- Обновляется автоматически
|
||||
|
||||
## 🔥 Пример использования
|
||||
|
||||
1. **Хочу написать пользователю @johndoe**
|
||||
- Открываю вкладку "Контакты"
|
||||
- Ввожу в поиск: `johndoe`
|
||||
- Вижу карточку пользователя
|
||||
- Нажимаю на нее или иконку сообщения
|
||||
- Попадаю сразу в чат!
|
||||
|
||||
2. **Хочу найти кого-то онлайн**
|
||||
- Переключаю фильтр на "Онлайн"
|
||||
- Вижу всех активных пользователей
|
||||
- Могу сразу начать общение
|
||||
|
||||
## 💡 Технические детали
|
||||
|
||||
### GraphQL запросы:
|
||||
- `GET_USERS` - получение списка всех пользователей
|
||||
- `CREATE_PRIVATE_CONVERSATION` - создание приватного чата
|
||||
|
||||
### Оптимизации:
|
||||
- Анимация появления карточек
|
||||
- Индикатор загрузки при создании чата
|
||||
- Кеширование результатов поиска
|
||||
- Оптимистичные обновления UI
|
||||
|
||||
### Безопасность:
|
||||
- Нельзя найти самого себя
|
||||
- Проверка прав при создании чата
|
||||
- Валидация на стороне сервера
|
||||
|
||||
## 🚀 Планы развития
|
||||
|
||||
1. **Добавление в контакты** - избранные пользователи
|
||||
2. **Блокировка пользователей**
|
||||
3. **Групповые приглашения**
|
||||
4. **QR-коды для быстрого добавления**
|
||||
5. **Импорт контактов из телефона**
|
117
MAIN_SCREEN_CONCEPT.md
Normal file
117
MAIN_SCREEN_CONCEPT.md
Normal file
@ -0,0 +1,117 @@
|
||||
# Концепция главного экрана Prism Messenger
|
||||
|
||||
## Структура приложения после авторизации
|
||||
|
||||
### 1. Bottom Tab Navigation (Нижняя навигация)
|
||||
|
||||
Основные разделы приложения:
|
||||
|
||||
1. **Чаты** (Chats) - главный раздел
|
||||
2. **Контакты** (Contacts) - список пользователей
|
||||
3. **Профиль** (Profile) - настройки и профиль
|
||||
|
||||
### 2. Экран "Чаты" (главный)
|
||||
|
||||
#### Header (Заголовок):
|
||||
- **Логотип/Название** приложения
|
||||
- **Поиск** (иконка лупы)
|
||||
- **Новый чат** (иконка +)
|
||||
|
||||
#### Основной контент:
|
||||
- **Список активных чатов** с превью последних сообщений
|
||||
- **Индикаторы непрочитанных** сообщений
|
||||
- **Время последнего сообщения**
|
||||
- **Онлайн статус** собеседника
|
||||
- **Swipe actions** для быстрых действий (архив, удаление)
|
||||
|
||||
#### Floating Action Button:
|
||||
- Быстрое создание нового чата
|
||||
|
||||
### 3. Экран "Контакты"
|
||||
|
||||
#### Разделы:
|
||||
- **Поиск пользователей** по username
|
||||
- **Мои контакты** (добавленные пользователи)
|
||||
- **Все пользователи** системы
|
||||
- **Приглашения** (входящие/исходящие)
|
||||
|
||||
#### Функции:
|
||||
- Добавить в контакты
|
||||
- Начать чат
|
||||
- Посмотреть профиль
|
||||
- Заблокировать пользователя
|
||||
|
||||
### 4. Экран "Профиль"
|
||||
|
||||
#### Разделы:
|
||||
- **Мой профиль**:
|
||||
- Аватар (с возможностью изменения)
|
||||
- Username
|
||||
- Email
|
||||
- Био/Статус
|
||||
- Статус "В сети"
|
||||
|
||||
- **Настройки**:
|
||||
- Уведомления
|
||||
- Приватность
|
||||
- Темная тема
|
||||
- Язык приложения
|
||||
|
||||
- **Действия**:
|
||||
- Выйти из аккаунта
|
||||
- Удалить аккаунт
|
||||
|
||||
### 5. Дополнительные функции
|
||||
|
||||
#### Глобальный поиск:
|
||||
- По сообщениям
|
||||
- По пользователям
|
||||
- По названиям чатов
|
||||
|
||||
#### Уведомления:
|
||||
- Push-уведомления о новых сообщениях
|
||||
- Индикаторы на иконках табов
|
||||
|
||||
#### Статусы пользователей:
|
||||
- Онлайн/Оффлайн
|
||||
- Последний визит
|
||||
- "Печатает..."
|
||||
|
||||
## Технические детали реализации
|
||||
|
||||
### Навигация:
|
||||
```typescript
|
||||
// React Navigation структура
|
||||
- Tab.Navigator
|
||||
- Stack.Navigator (Чаты)
|
||||
- ConversationsScreen
|
||||
- ChatScreen
|
||||
- NewChatScreen
|
||||
- Stack.Navigator (Контакты)
|
||||
- ContactsScreen
|
||||
- UserProfileScreen
|
||||
- Stack.Navigator (Профиль)
|
||||
- ProfileScreen
|
||||
- SettingsScreen
|
||||
```
|
||||
|
||||
### GraphQL запросы для главного экрана:
|
||||
1. `getConversations` - список чатов с последними сообщениями
|
||||
2. `getUnreadCount` - количество непрочитанных
|
||||
3. `getOnlineUsers` - список онлайн пользователей
|
||||
4. `searchUsers` - поиск пользователей
|
||||
|
||||
### Компоненты UI:
|
||||
- `TabBar` - кастомная нижняя навигация
|
||||
- `ChatListItem` - элемент списка чатов
|
||||
- `SearchBar` - универсальный поиск
|
||||
- `UserAvatar` - аватар с индикатором онлайн
|
||||
- `UnreadBadge` - бейдж непрочитанных
|
||||
|
||||
## Приоритеты разработки:
|
||||
1. Базовая Tab навигация
|
||||
2. Улучшенный список чатов
|
||||
3. Экран контактов
|
||||
4. Профиль и настройки
|
||||
5. Поиск и фильтрация
|
||||
6. Дополнительные функции
|
@ -54,21 +54,33 @@ export class ConversationsService {
|
||||
}
|
||||
|
||||
async findOrCreatePrivate(user1Id: string, user2Id: string): Promise<Conversation> {
|
||||
// Проверяем существующую приватную беседу между двумя пользователями
|
||||
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<Conversation> {
|
||||
|
@ -4,8 +4,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { LoginScreen } from '../screens/LoginScreen';
|
||||
import { RegisterScreen } from '../screens/RegisterScreen';
|
||||
import { ConversationsScreen } from '../screens/ConversationsScreen';
|
||||
import { ChatScreen } from '../screens/ChatScreen';
|
||||
import { MainNavigator } from './MainNavigator';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
|
||||
const Stack = createNativeStackNavigator();
|
||||
@ -25,23 +24,11 @@ export const AppNavigator = () => {
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator>
|
||||
{user ? (
|
||||
<>
|
||||
<Stack.Screen
|
||||
name="Conversations"
|
||||
component={ConversationsScreen}
|
||||
options={{
|
||||
title: 'Чаты',
|
||||
headerLargeTitle: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Chat"
|
||||
component={ChatScreen}
|
||||
options={({ route }) => ({
|
||||
title: route.params?.title || 'Чат',
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
<Stack.Screen
|
||||
name="Main"
|
||||
component={MainNavigator}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Stack.Screen
|
||||
|
130
frontend/src/navigation/MainNavigator.tsx
Normal file
130
frontend/src/navigation/MainNavigator.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from 'react-native-paper';
|
||||
|
||||
// Импортируем существующие экраны
|
||||
import { ConversationsScreen } from '../screens/ConversationsScreen';
|
||||
import { ChatScreen } from '../screens/ChatScreen';
|
||||
|
||||
// Заглушки для новых экранов (создадим позже)
|
||||
import { ContactsScreen } from '../screens/ContactsScreen';
|
||||
import { ProfileScreen } from '../screens/ProfileScreen';
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
const ChatsStack = createNativeStackNavigator();
|
||||
const ContactsStack = createNativeStackNavigator();
|
||||
const ProfileStack = createNativeStackNavigator();
|
||||
|
||||
// Стек навигации для чатов
|
||||
function ChatsStackNavigator() {
|
||||
return (
|
||||
<ChatsStack.Navigator>
|
||||
<ChatsStack.Screen
|
||||
name="ConversationsList"
|
||||
component={ConversationsScreen}
|
||||
options={{
|
||||
title: 'Чаты',
|
||||
headerLargeTitle: true,
|
||||
}}
|
||||
/>
|
||||
<ChatsStack.Screen
|
||||
name="Chat"
|
||||
component={ChatScreen}
|
||||
options={({ route }) => ({
|
||||
title: route.params?.title || 'Чат',
|
||||
})}
|
||||
/>
|
||||
</ChatsStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
// Стек навигации для контактов
|
||||
function ContactsStackNavigator() {
|
||||
return (
|
||||
<ContactsStack.Navigator>
|
||||
<ContactsStack.Screen
|
||||
name="ContactsList"
|
||||
component={ContactsScreen}
|
||||
options={{
|
||||
title: 'Контакты',
|
||||
headerLargeTitle: true,
|
||||
}}
|
||||
/>
|
||||
</ContactsStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
// Стек навигации для профиля
|
||||
function ProfileStackNavigator() {
|
||||
return (
|
||||
<ProfileStack.Navigator>
|
||||
<ProfileStack.Screen
|
||||
name="ProfileMain"
|
||||
component={ProfileScreen}
|
||||
options={{
|
||||
title: 'Профиль',
|
||||
headerLargeTitle: true,
|
||||
}}
|
||||
/>
|
||||
</ProfileStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
export function MainNavigator() {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
tabBarIcon: ({ focused, color, size }) => {
|
||||
let iconName: string;
|
||||
|
||||
if (route.name === 'Chats') {
|
||||
iconName = focused ? 'message' : 'message-outline';
|
||||
} else if (route.name === 'Contacts') {
|
||||
iconName = focused ? 'account-group' : 'account-group-outline';
|
||||
} else if (route.name === 'Profile') {
|
||||
iconName = focused ? 'account-circle' : 'account-circle-outline';
|
||||
} else {
|
||||
iconName = 'help';
|
||||
}
|
||||
|
||||
return <MaterialCommunityIcons name={iconName} size={size} color={color} />;
|
||||
},
|
||||
tabBarActiveTintColor: theme.colors.primary,
|
||||
tabBarInactiveTintColor: 'gray',
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderTopColor: theme.colors.outlineVariant,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Chats"
|
||||
component={ChatsStackNavigator}
|
||||
options={{
|
||||
tabBarLabel: 'Чаты',
|
||||
tabBarBadge: undefined, // Здесь можно показывать количество непрочитанных
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Contacts"
|
||||
component={ContactsStackNavigator}
|
||||
options={{
|
||||
tabBarLabel: 'Контакты',
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Profile"
|
||||
component={ProfileStackNavigator}
|
||||
options={{
|
||||
tabBarLabel: 'Профиль',
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
407
frontend/src/screens/ContactsScreen.tsx
Normal file
407
frontend/src/screens/ContactsScreen.tsx
Normal file
@ -0,0 +1,407 @@
|
||||
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);
|
||||
|
||||
const [createConversation] = useMutation(CREATE_PRIVATE_CONVERSATION, {
|
||||
onCompleted: (data) => {
|
||||
navigation.navigate('Chats', {
|
||||
screen: 'Chat',
|
||||
params: {
|
||||
conversationId: data.createPrivateConversation.id,
|
||||
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 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating conversation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = data?.users?.filter((user: User) => {
|
||||
if (user.id === currentUser?.id) return false;
|
||||
|
||||
if (searchQuery) {
|
||||
return user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}
|
||||
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
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}>
|
||||
{/* Заголовок с поиском */}
|
||||
<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: 'Все',
|
||||
icon: 'account-group',
|
||||
},
|
||||
{
|
||||
value: 'online',
|
||||
label: 'Онлайн',
|
||||
icon: 'circle',
|
||||
},
|
||||
{
|
||||
value: 'contacts',
|
||||
label: 'Контакты',
|
||||
icon: 'account-star',
|
||||
},
|
||||
]}
|
||||
style={styles.tabs}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={filteredUsers.filter((user: User) => {
|
||||
if (selectedTab === 'online') return user.isOnline;
|
||||
// В будущем добавим логику для "Мои контакты"
|
||||
return true;
|
||||
})}
|
||||
renderItem={renderUser}
|
||||
keyExtractor={(item) => item.id}
|
||||
onRefresh={refetch}
|
||||
refreshing={loading}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#ffffff',
|
||||
paddingBottom: 16,
|
||||
},
|
||||
searchContainer: {
|
||||
padding: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
searchbar: {
|
||||
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,
|
||||
marginVertical: 16,
|
||||
},
|
||||
listContent: {
|
||||
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: 2,
|
||||
right: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 7,
|
||||
padding: 1,
|
||||
},
|
||||
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,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 100,
|
||||
},
|
||||
emptyText: {
|
||||
color: '#666',
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
@ -1,23 +1,41 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
|
||||
import { List, Avatar, Text, FAB, Divider, Badge } from 'react-native-paper';
|
||||
import { List, Avatar, Text, FAB, Divider, Badge, Searchbar, IconButton } from 'react-native-paper';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { GET_CONVERSATIONS } from '../graphql/queries';
|
||||
import { Conversation } from '../types';
|
||||
import { format } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export const ConversationsScreen = ({ navigation }: any) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS, {
|
||||
pollInterval: 5000, // Обновляем каждые 5 секунд
|
||||
});
|
||||
|
||||
// Фильтрация чатов по поисковому запросу
|
||||
const filteredConversations = data?.conversations?.filter((conv: Conversation) => {
|
||||
if (!searchQuery) return true;
|
||||
|
||||
const otherParticipant = conv.participants.find(p => p.id !== user?.id);
|
||||
const displayName = conv.isGroup ? conv.name : otherParticipant?.username;
|
||||
|
||||
return displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
conv.lastMessage?.content.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}) || [];
|
||||
|
||||
const renderConversation = ({ item }: { item: Conversation }) => {
|
||||
const otherParticipant = item.participants.find(p => p.id !== data?.me?.id);
|
||||
const otherParticipant = item.participants.find(p => p.id !== user?.id);
|
||||
const displayName = item.isGroup ? item.name : otherParticipant?.username;
|
||||
const lastMessageTime = item.lastMessage
|
||||
? format(new Date(item.lastMessage.createdAt), 'HH:mm', { locale: ru })
|
||||
: '';
|
||||
|
||||
// Подсчет непрочитанных сообщений (в будущем добавить в GraphQL)
|
||||
const unreadCount = 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@ -42,7 +60,9 @@ export const ConversationsScreen = ({ navigation }: any) => {
|
||||
<Text variant="bodySmall" style={styles.time}>
|
||||
{lastMessageTime}
|
||||
</Text>
|
||||
{/* Здесь можно добавить счетчик непрочитанных сообщений */}
|
||||
{unreadCount > 0 && (
|
||||
<Badge style={styles.unreadBadge}>{unreadCount}</Badge>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
style={styles.listItem}
|
||||
@ -70,8 +90,19 @@ export const ConversationsScreen = ({ navigation }: any) => {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Поисковая строка */}
|
||||
<View style={styles.searchContainer}>
|
||||
<Searchbar
|
||||
placeholder="Поиск чатов..."
|
||||
onChangeText={setSearchQuery}
|
||||
value={searchQuery}
|
||||
style={styles.searchbar}
|
||||
icon="magnify"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={data?.conversations || []}
|
||||
data={filteredConversations}
|
||||
renderItem={renderConversation}
|
||||
keyExtractor={(item) => item.id}
|
||||
onRefresh={refetch}
|
||||
@ -102,6 +133,16 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
searchContainer: {
|
||||
padding: 16,
|
||||
paddingBottom: 8,
|
||||
backgroundColor: '#ffffff',
|
||||
elevation: 2,
|
||||
},
|
||||
searchbar: {
|
||||
elevation: 0,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
@ -119,6 +160,13 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
time: {
|
||||
color: '#666',
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
unreadBadge: {
|
||||
backgroundColor: '#2196F3',
|
||||
color: '#ffffff',
|
||||
fontSize: 12,
|
||||
},
|
||||
onlineBadge: {
|
||||
position: 'absolute',
|
||||
|
241
frontend/src/screens/ProfileScreen.tsx
Normal file
241
frontend/src/screens/ProfileScreen.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, ScrollView, Alert } from 'react-native';
|
||||
import {
|
||||
Avatar,
|
||||
Text,
|
||||
List,
|
||||
Switch,
|
||||
Divider,
|
||||
Button,
|
||||
IconButton,
|
||||
Surface,
|
||||
useTheme,
|
||||
TextInput,
|
||||
Dialog,
|
||||
Portal,
|
||||
} from 'react-native-paper';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { UPDATE_USER } from '../graphql/mutations';
|
||||
|
||||
export const ProfileScreen = ({ navigation }: any) => {
|
||||
const theme = useTheme();
|
||||
const { user, logout } = useAuth();
|
||||
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [editDialogVisible, setEditDialogVisible] = useState(false);
|
||||
const [bio, setBio] = useState(user?.bio || '');
|
||||
|
||||
const [updateUser] = useMutation(UPDATE_USER, {
|
||||
onCompleted: (data) => {
|
||||
// В реальном приложении нужно обновить контекст пользователя
|
||||
setEditDialogVisible(false);
|
||||
Alert.alert('Успешно', 'Профиль обновлен');
|
||||
},
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
Alert.alert(
|
||||
'Выход',
|
||||
'Вы уверены, что хотите выйти?',
|
||||
[
|
||||
{ text: 'Отмена', style: 'cancel' },
|
||||
{
|
||||
text: 'Выйти',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await logout();
|
||||
}
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
try {
|
||||
await updateUser({
|
||||
variables: { bio },
|
||||
});
|
||||
} catch (error) {
|
||||
Alert.alert('Ошибка', 'Не удалось обновить профиль');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
{/* Профиль пользователя */}
|
||||
<Surface style={styles.profileSection} elevation={0}>
|
||||
<View style={styles.profileHeader}>
|
||||
<Avatar.Text
|
||||
size={80}
|
||||
label={user?.username?.charAt(0).toUpperCase() || '?'}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<IconButton
|
||||
icon="pencil"
|
||||
size={20}
|
||||
onPress={() => setEditDialogVisible(true)}
|
||||
style={styles.editButton}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text variant="headlineSmall" style={styles.username}>
|
||||
{user?.username}
|
||||
</Text>
|
||||
<Text variant="bodyMedium" style={styles.email}>
|
||||
{user?.email}
|
||||
</Text>
|
||||
{user?.bio && (
|
||||
<Text variant="bodyMedium" style={styles.bio}>
|
||||
{user.bio}
|
||||
</Text>
|
||||
)}
|
||||
</Surface>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Настройки */}
|
||||
<List.Section>
|
||||
<List.Subheader>Настройки</List.Subheader>
|
||||
|
||||
<List.Item
|
||||
title="Уведомления"
|
||||
description="Получать push-уведомления"
|
||||
left={(props) => <List.Icon {...props} icon="bell" />}
|
||||
right={() => (
|
||||
<Switch
|
||||
value={notificationsEnabled}
|
||||
onValueChange={setNotificationsEnabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<List.Item
|
||||
title="Темная тема"
|
||||
description="Переключить тему приложения"
|
||||
left={(props) => <List.Icon {...props} icon="theme-light-dark" />}
|
||||
right={() => (
|
||||
<Switch
|
||||
value={darkMode}
|
||||
onValueChange={setDarkMode}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<List.Item
|
||||
title="Язык"
|
||||
description="Русский"
|
||||
left={(props) => <List.Icon {...props} icon="translate" />}
|
||||
right={(props) => <List.Icon {...props} icon="chevron-right" />}
|
||||
onPress={() => {}}
|
||||
/>
|
||||
|
||||
<List.Item
|
||||
title="Приватность"
|
||||
description="Настройки приватности"
|
||||
left={(props) => <List.Icon {...props} icon="lock" />}
|
||||
right={(props) => <List.Icon {...props} icon="chevron-right" />}
|
||||
onPress={() => {}}
|
||||
/>
|
||||
</List.Section>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* О приложении */}
|
||||
<List.Section>
|
||||
<List.Subheader>О приложении</List.Subheader>
|
||||
|
||||
<List.Item
|
||||
title="Версия"
|
||||
description="1.0.0"
|
||||
left={(props) => <List.Icon {...props} icon="information" />}
|
||||
/>
|
||||
|
||||
<List.Item
|
||||
title="Помощь"
|
||||
left={(props) => <List.Icon {...props} icon="help-circle" />}
|
||||
right={(props) => <List.Icon {...props} icon="chevron-right" />}
|
||||
onPress={() => {}}
|
||||
/>
|
||||
</List.Section>
|
||||
|
||||
{/* Действия */}
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={handleLogout}
|
||||
style={styles.logoutButton}
|
||||
textColor={theme.colors.error}
|
||||
>
|
||||
Выйти из аккаунта
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Диалог редактирования профиля */}
|
||||
<Portal>
|
||||
<Dialog visible={editDialogVisible} onDismiss={() => setEditDialogVisible(false)}>
|
||||
<Dialog.Title>Редактировать профиль</Dialog.Title>
|
||||
<Dialog.Content>
|
||||
<TextInput
|
||||
label="О себе"
|
||||
value={bio}
|
||||
onChangeText={setBio}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
mode="outlined"
|
||||
/>
|
||||
</Dialog.Content>
|
||||
<Dialog.Actions>
|
||||
<Button onPress={() => setEditDialogVisible(false)}>Отмена</Button>
|
||||
<Button onPress={handleUpdateProfile}>Сохранить</Button>
|
||||
</Dialog.Actions>
|
||||
</Dialog>
|
||||
</Portal>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
profileSection: {
|
||||
padding: 24,
|
||||
backgroundColor: '#ffffff',
|
||||
alignItems: 'center',
|
||||
},
|
||||
profileHeader: {
|
||||
position: 'relative',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
backgroundColor: '#2196F3',
|
||||
},
|
||||
editButton: {
|
||||
position: 'absolute',
|
||||
bottom: -8,
|
||||
right: -8,
|
||||
backgroundColor: '#ffffff',
|
||||
elevation: 2,
|
||||
},
|
||||
username: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
email: {
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
bio: {
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
actions: {
|
||||
padding: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
logoutButton: {
|
||||
borderColor: '#f44336',
|
||||
},
|
||||
});
|
Reference in New Issue
Block a user