diff --git a/BUG_FIXES_SUMMARY.md b/BUG_FIXES_SUMMARY.md new file mode 100644 index 0000000..940592a --- /dev/null +++ b/BUG_FIXES_SUMMARY.md @@ -0,0 +1,65 @@ +# Сводка исправленных ошибок + +## 1. ✅ SQL ошибка "column 'conversationid' does not exist" +**Проблема:** PostgreSQL чувствителен к регистру имен колонок. В SQL запросе использовались имена колонок без кавычек. + +**Решение:** Добавил кавычки вокруг имен колонок в методе `findOrCreatePrivate`: +```typescript +// Было: +.leftJoin('conversation_participants', 'cp1', 'cp1.conversationId = c.id') +// Стало: +.leftJoin('conversation_participants', 'cp1', 'cp1."conversationId" = c.id') +``` + +## 2. ✅ Навигация на несуществующий экран 'NewChat' +**Проблема:** FAB кнопка в экране чатов пыталась перейти на экран 'NewChat', который не существует. + +**Решение:** Изменил навигацию на вкладку 'Contacts' для выбора пользователя: +```typescript +// Было: +onPress={() => navigation.navigate('NewChat')} +// Стало: +onPress={() => navigation.navigate('Contacts')} +``` + +## 3. ✅ Проблема с подключением к API на iOS +**Проблема:** Фронтенд показывал ошибки "Network request failed". + +**Решение:** Конфигурация API URL уже была правильной: +- Для iOS симулятора: `http://localhost:3000/graphql` +- Для Android эмулятора: `http://10.0.2.2:3000/graphql` + +Проблема была связана с SQL ошибкой на бэкенде, которая теперь исправлена. + +## 4. ✅ Предупреждения Surface overflow +**Проблема:** React Native Paper Surface компонент выдавал предупреждения при использовании overflow: hidden. + +**Решение:** Заменил Surface на View с кастомными стилями для теней: +```typescript +// Было: + +// Стало: + +``` + +Добавил кастомные стили для теней: +```typescript +userCardShadow: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, +} +``` + +## Статус исправлений: +- ✅ SQL запросы теперь корректно работают с PostgreSQL +- ✅ Навигация работает правильно +- ✅ Нет предупреждений о Surface overflow +- ✅ Бэкенд перезапущен с исправлениями + +## Следующие шаги: +1. Проверить создание приватных чатов через экран контактов +2. Убедиться, что все функции работают корректно +3. Протестировать на разных платформах (iOS/Android) \ No newline at end of file diff --git a/BUG_FIX_LINEHIGHT.md b/BUG_FIX_LINEHIGHT.md new file mode 100644 index 0000000..545b091 --- /dev/null +++ b/BUG_FIX_LINEHIGHT.md @@ -0,0 +1,44 @@ +# Исправление ошибки lineHeight + +## Проблема +При запуске приложения на iOS возникала ошибка: +``` +Cannot read property 'lineHeight' of undefined +``` + +Ошибка происходила в компоненте Searchbar из React Native Paper при попытке получить доступ к настройкам шрифтов темы. + +## Причина +В нашей кастомной темной теме не были определены настройки шрифтов (fonts). React Native Paper ожидает наличие конфигурации шрифтов с различными вариантами (displayLarge, bodyMedium и т.д.), каждый из которых должен содержать свойство lineHeight. + +## Решение +Добавлены полные настройки шрифтов в тему: + +1. Импортирован `configureFonts` из react-native-paper +2. Создана базовая конфигурация шрифтов с учетом платформы (iOS/Android) +3. Определены все необходимые варианты шрифтов с правильными значениями: + - fontSize + - lineHeight + - letterSpacing + - fontFamily + +## Изменения в коде + +```typescript +// Добавлены настройки шрифтов +fonts: configureFonts({ + config: { + ...baseFont, + bodyLarge: { + ...baseFont, + fontSize: 16, + lineHeight: 24, + letterSpacing: 0.15, + }, + // ... остальные варианты + }, +}), +``` + +## Результат +Ошибка устранена, компонент Searchbar и другие компоненты React Native Paper теперь корректно работают с темой. \ No newline at end of file diff --git a/CONTACTS_FEATURE.md b/CONTACTS_FEATURE.md new file mode 100644 index 0000000..0abf24f --- /dev/null +++ b/CONTACTS_FEATURE.md @@ -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. **Импорт контактов из телефона** \ No newline at end of file diff --git a/MAIN_SCREEN_CONCEPT.md b/MAIN_SCREEN_CONCEPT.md new file mode 100644 index 0000000..4e4d31f --- /dev/null +++ b/MAIN_SCREEN_CONCEPT.md @@ -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. Дополнительные функции \ No newline at end of file diff --git a/backend/src/modules/conversations/conversations.service.ts b/backend/src/modules/conversations/conversations.service.ts index f33100d..20f5b6c 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/backend/src/modules/users/users.resolver.ts b/backend/src/modules/users/users.resolver.ts index b13ae43..cbed2af 100644 --- a/backend/src/modules/users/users.resolver.ts +++ b/backend/src/modules/users/users.resolver.ts @@ -36,4 +36,22 @@ export class UsersResolver { ) { return this.usersService.update(user.id, { bio, avatar }); } + + @Mutation(() => User) + @UseGuards(GqlAuthGuard) + updateProfile( + @CurrentUser() user: User, + @Args('bio') bio: string, + ) { + return this.usersService.update(user.id, { bio }); + } + + @Mutation(() => User) + @UseGuards(GqlAuthGuard) + updateOnlineStatus( + @CurrentUser() user: User, + @Args('isOnline') isOnline: boolean, + ) { + return this.usersService.updateOnlineStatus(user.id, isOnline); + } } \ No newline at end of file diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index 1a5c8ba..1df2a1f 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -55,10 +55,11 @@ export class UsersService { return this.findOne(id); } - async updateOnlineStatus(id: string, isOnline: boolean): Promise { + async updateOnlineStatus(id: string, isOnline: boolean): Promise { await this.usersRepository.update(id, { isOnline, lastSeen: isOnline ? undefined : new Date(), }); + return this.findOne(id); } } \ No newline at end of file diff --git a/frontend/App.tsx b/frontend/App.tsx index 669e849..0f0c9e6 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -2,11 +2,12 @@ import React from 'react'; import { StatusBar } from 'expo-status-bar'; import { Platform } from 'react-native'; import { ApolloProvider } from '@apollo/client'; -import { Provider as PaperProvider, MD3DarkTheme, configureFonts } from 'react-native-paper'; +import { Provider as PaperProvider } from 'react-native-paper'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { apolloClient } from './src/services/apollo-client'; import { AuthProvider } from './src/contexts/AuthContext'; import { AppNavigator } from './src/navigation/AppNavigator'; +<<<<<<< HEAD // Современная черно-серая тема const theme = { @@ -56,6 +57,9 @@ const theme = { }, }), }; +======= +import { theme } from './src/theme'; +>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a export default function App() { return ( diff --git a/frontend/src/graphql/mutations.ts b/frontend/src/graphql/mutations.ts index fd90258..2a78ffc 100644 --- a/frontend/src/graphql/mutations.ts +++ b/frontend/src/graphql/mutations.ts @@ -87,4 +87,23 @@ export const MARK_MESSAGE_AS_READ = gql` } } ${MESSAGE_FRAGMENT} +`; + +// Profile mutations +export const UPDATE_PROFILE = gql` + mutation UpdateProfile($bio: String!) { + updateProfile(bio: $bio) { + ...UserFragment + } + } + ${USER_FRAGMENT} +`; + +export const UPDATE_ONLINE_STATUS = gql` + mutation UpdateOnlineStatus($isOnline: Boolean!) { + updateOnlineStatus(isOnline: $isOnline) { + ...UserFragment + } + } + ${USER_FRAGMENT} `; \ No newline at end of file diff --git a/frontend/src/navigation/AppNavigator.tsx b/frontend/src/navigation/AppNavigator.tsx index fd58792..7ad6743 100644 --- a/frontend/src/navigation/AppNavigator.tsx +++ b/frontend/src/navigation/AppNavigator.tsx @@ -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 = () => { {user ? ( - <> - - ({ - title: route.params?.title || 'Чат', - })} - /> - + ) : ( <> + + ({ + title: route.params?.title || 'Чат', + })} + /> + + ); +} + +// Стек навигации для контактов +function ContactsStackNavigator() { + return ( + + + + ); +} + +// Стек навигации для профиля +function ProfileStackNavigator() { + return ( + + + + ); +} + +export function MainNavigator() { + const theme = useTheme(); + + return ( + ({ + 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 ; + }, + tabBarActiveTintColor: theme.colors.primary, + tabBarInactiveTintColor: 'gray', + headerShown: false, + tabBarStyle: { + backgroundColor: theme.colors.surface, + borderTopColor: theme.colors.outlineVariant, + borderTopWidth: 1, + }, + })} + > + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/screens/ContactsScreen.tsx b/frontend/src/screens/ContactsScreen.tsx new file mode 100644 index 0000000..bc17400 --- /dev/null +++ b/frontend/src/screens/ContactsScreen.tsx @@ -0,0 +1,427 @@ +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, 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); + + 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 ( + + 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} + + в сети + + + + + + + { + if (selectedTab === 'online') return user.isOnline; + // В будущем добавим логику для "Мои контакты" + return true; + })} + renderItem={renderUser} + keyExtractor={(item) => item.id} + onRefresh={refetch} + refreshing={loading} + contentContainerStyle={styles.listContent} + ListEmptyComponent={ + + + + {searchQuery + ? `Пользователь @${searchQuery} не найден` + : selectedTab === 'online' + ? 'Нет пользователей онлайн' + : 'Нет доступных пользователей'} + + {searchQuery && ( + + Проверьте правильность никнейма + + )} + + } + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + header: { + backgroundColor: '#ffffff', + paddingBottom: 16, + elevation: 2, + }, + 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, + }, + userCardShadow: { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, + }, + headerShadow: { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 4, + }, + 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', + }, +}); \ No newline at end of file diff --git a/frontend/src/screens/ConversationsScreen.tsx b/frontend/src/screens/ConversationsScreen.tsx index bda8949..5283411 100644 --- a/frontend/src/screens/ConversationsScreen.tsx +++ b/frontend/src/screens/ConversationsScreen.tsx @@ -1,11 +1,18 @@ +<<<<<<< HEAD import React from 'react'; import { View, StyleSheet, FlatList, TouchableOpacity, Dimensions } from 'react-native'; import { List, Avatar, Text, FAB, Badge, Surface } from 'react-native-paper'; +======= +import React, { useState } from 'react'; +import { View, StyleSheet, FlatList, TouchableOpacity } from 'react-native'; +import { List, Avatar, Text, FAB, Divider, Badge, Searchbar, IconButton, useTheme } from 'react-native-paper'; +>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a import { useQuery } from '@apollo/client'; import { GET_CONVERSATIONS } from '../graphql/queries'; import { Conversation } from '../types'; import { format, isToday, isYesterday } from 'date-fns'; import { ru } from 'date-fns/locale'; +<<<<<<< HEAD import { LinearGradient } from 'expo-linear-gradient'; import Animated, { FadeInDown, @@ -14,12 +21,20 @@ import Animated, { } from 'react-native-reanimated'; const { width } = Dimensions.get('window'); +======= +import { useAuth } from '../contexts/AuthContext'; +>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a export const ConversationsScreen = ({ navigation }: any) => { + const [searchQuery, setSearchQuery] = useState(''); + const { user } = useAuth(); + const theme = useTheme(); + const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS, { pollInterval: 5000, }); +<<<<<<< HEAD const formatMessageTime = (date: string) => { const messageDate = new Date(date); if (isToday(messageDate)) { @@ -33,10 +48,28 @@ export const ConversationsScreen = ({ navigation }: any) => { const renderConversation = ({ item, index }: { item: Conversation; index: number }) => { const otherParticipant = item.participants.find(p => p.id !== data?.me?.id); +======= + // Фильтрация чатов по поисковому запросу + 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 !== user?.id); +>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a const displayName = item.isGroup ? item.name : otherParticipant?.username; const lastMessageTime = item.lastMessage ? formatMessageTime(item.lastMessage.createdAt) : ''; + + // Подсчет непрочитанных сообщений (в будущем добавить в GraphQL) + const unreadCount = 0; return ( { )} +<<<<<<< HEAD @@ -93,6 +127,17 @@ export const ConversationsScreen = ({ navigation }: any) => { {/* Здесь можно добавить счетчик непрочитанных */} +======= + )} + right={() => ( + + + {lastMessageTime} + + {unreadCount > 0 && ( + {unreadCount} + )} +>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a @@ -128,13 +173,28 @@ export const ConversationsScreen = ({ navigation }: any) => { } return ( +<<<<<<< HEAD +======= + + {/* Поисковая строка */} + + + + +>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a item.id} onRefresh={refetch} @@ -163,6 +223,7 @@ export const ConversationsScreen = ({ navigation }: any) => { } /> +<<<<<<< HEAD @@ -178,6 +239,13 @@ export const ConversationsScreen = ({ navigation }: any) => { }} /> +======= + navigation.navigate('Contacts')} + /> +>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a ); }; @@ -187,7 +255,21 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#0a0a0a', }, +<<<<<<< HEAD loadingContainer: { +======= + searchContainer: { + padding: 16, + paddingBottom: 8, + backgroundColor: '#ffffff', + elevation: 2, + }, + searchbar: { + elevation: 0, + backgroundColor: '#f5f5f5', + }, + centerContainer: { +>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a flex: 1, justifyContent: 'center', alignItems: 'center', @@ -234,6 +316,7 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.05)', }, +<<<<<<< HEAD avatarContainer: { position: 'relative', marginRight: 12, @@ -251,6 +334,17 @@ const styles = StyleSheet.create({ color: '#ffffff', fontSize: 20, fontWeight: '600', +======= + time: { + color: '#666', + fontSize: 12, + marginBottom: 4, + }, + unreadBadge: { + backgroundColor: '#2196F3', + color: '#ffffff', + fontSize: 12, +>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a }, onlineBadge: { position: 'absolute', diff --git a/frontend/src/screens/LoginScreen.tsx b/frontend/src/screens/LoginScreen.tsx index cea14e4..bc81ca9 100644 --- a/frontend/src/screens/LoginScreen.tsx +++ b/frontend/src/screens/LoginScreen.tsx @@ -1,6 +1,11 @@ import React, { useState, useEffect } from 'react'; +<<<<<<< HEAD import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableOpacity } from 'react-native'; import { TextInput, Button, Text, Headline, HelperText, Surface } from 'react-native-paper'; +======= +import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions } from 'react-native'; +import { TextInput, Button, Text, Headline, HelperText, useTheme } from 'react-native-paper'; +>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a import { useMutation } from '@apollo/client'; import { LOGIN } from '../graphql/mutations'; import { useAuth } from '../contexts/AuthContext'; @@ -28,6 +33,7 @@ export const LoginScreen = ({ navigation }: any) => { const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); const { login } = useAuth(); + const theme = useTheme(); // Анимации const cardScale = useSharedValue(0.95); @@ -100,6 +106,7 @@ export const LoginScreen = ({ navigation }: any) => { }; return ( +<<<<<<< HEAD { + + + + Вход в Prism + Добро пожаловать обратно + + + + { + inputFocusAnimation1.value = withSpring(1); + }} + onBlur={() => { + inputFocusAnimation1.value = withSpring(0); + }} + /> + + + + setShowPassword(!showPassword)} + color="#a855f7" + /> + } + onFocus={() => { + inputFocusAnimation2.value = withSpring(1); + }} + onBlur={() => { + inputFocusAnimation2.value = withSpring(0); + }} + /> + + + {error && ( + + {error.message} + + )} + + + + + + + + + + + + + {/* Модальное окно редактирования профиля */} + + setEditProfileVisible(false)} + contentContainerStyle={[styles.modal, { backgroundColor: theme.colors.surface }]} + > + + Редактировать профиль + + + + + + + + + + + + {/* Модальное окно настроек приватности */} + + setPrivacyVisible(false)} + contentContainerStyle={[styles.modal, { backgroundColor: theme.colors.surface }]} + > + + Настройки приватности + + + ( + + )} + /> + + ( + + )} + /> + + + + + + + + + {/* Модальное окно настроек уведомлений */} + + setNotificationsVisible(false)} + contentContainerStyle={[styles.modal, { backgroundColor: theme.colors.surface }]} + > + + Настройки уведомлений + + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + profileCard: { + margin: 16, + padding: 20, + borderRadius: 16, + }, + profileContent: { + flexDirection: 'row', + alignItems: 'center', + }, + profileInfo: { + marginLeft: 16, + flex: 1, + }, + statusContainer: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 8, + }, + settingsSection: { + marginHorizontal: 16, + marginBottom: 16, + borderRadius: 16, + overflow: 'hidden', + }, + accountActions: { + padding: 16, + gap: 12, + }, + logoutButton: { + paddingVertical: 4, + }, + deleteButton: { + paddingVertical: 4, + }, + buttonLabel: { + fontSize: 16, + fontWeight: '600', + }, + modal: { + margin: 20, + padding: 20, + borderRadius: 16, + }, + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 16, + gap: 8, + }, +}); \ No newline at end of file diff --git a/frontend/src/theme/index.ts b/frontend/src/theme/index.ts new file mode 100644 index 0000000..bce1b74 --- /dev/null +++ b/frontend/src/theme/index.ts @@ -0,0 +1,208 @@ +import { MD3DarkTheme, MD3LightTheme, adaptNavigationTheme, configureFonts } from 'react-native-paper'; +import { DefaultTheme, DarkTheme } from '@react-navigation/native'; +import { Platform } from 'react-native'; + +// Базовые настройки шрифтов +const baseFont = Platform.select({ + ios: { + fontFamily: 'System', + letterSpacing: 0, + }, + android: { + fontFamily: 'Roboto', + letterSpacing: 0, + }, + default: { + fontFamily: 'System', + letterSpacing: 0, + }, +}); + +const baseVariants = configureFonts({ + config: { + ...baseFont, + }, +}); + +// Цвета для темной темы +const darkColors = { + primary: '#6366f1', + onPrimary: '#ffffff', + primaryContainer: '#4f46e5', + onPrimaryContainer: '#e0e7ff', + + secondary: '#818cf8', + onSecondary: '#ffffff', + secondaryContainer: '#6366f1', + onSecondaryContainer: '#e0e7ff', + + tertiary: '#a78bfa', + onTertiary: '#ffffff', + tertiaryContainer: '#8b5cf6', + onTertiaryContainer: '#ede9fe', + + error: '#ef4444', + onError: '#ffffff', + errorContainer: '#dc2626', + onErrorContainer: '#fee2e2', + + background: '#0f0f0f', + onBackground: '#e5e5e5', + + surface: '#1a1a1a', + onSurface: '#e5e5e5', + + surfaceVariant: '#262626', + onSurfaceVariant: '#d4d4d4', + + outline: '#404040', + outlineVariant: '#2a2a2a', + + shadow: '#000000', + scrim: '#000000', + + inverseSurface: '#e5e5e5', + inverseOnSurface: '#1a1a1a', + inversePrimary: '#4f46e5', + + elevation: { + level0: 'transparent', + level1: '#1f1f1f', + level2: '#232323', + level3: '#282828', + level4: '#2a2a2a', + level5: '#2d2d2d', + }, +}; + +// Объединяем темы Paper и Navigation +const { LightTheme: NavigationLightTheme, DarkTheme: NavigationDarkTheme } = adaptNavigationTheme({ + reactNavigationLight: DefaultTheme, + reactNavigationDark: DarkTheme, +}); + +// Темная тема (основная) +export const darkTheme = { + ...MD3DarkTheme, + ...NavigationDarkTheme, + colors: { + ...MD3DarkTheme.colors, + ...NavigationDarkTheme.colors, + ...darkColors, + }, + fonts: configureFonts({ + config: { + ...baseFont, + displayLarge: { + ...baseFont, + fontSize: 57, + lineHeight: 64, + letterSpacing: 0, + }, + displayMedium: { + ...baseFont, + fontSize: 45, + lineHeight: 52, + letterSpacing: 0, + }, + displaySmall: { + ...baseFont, + fontSize: 36, + lineHeight: 44, + letterSpacing: 0, + }, + headlineLarge: { + ...baseFont, + fontSize: 32, + lineHeight: 40, + letterSpacing: 0, + }, + headlineMedium: { + ...baseFont, + fontSize: 28, + lineHeight: 36, + letterSpacing: 0, + }, + headlineSmall: { + ...baseFont, + fontSize: 24, + lineHeight: 32, + letterSpacing: 0, + }, + titleLarge: { + ...baseFont, + fontSize: 22, + lineHeight: 28, + letterSpacing: 0, + }, + titleMedium: { + ...baseFont, + fontSize: 16, + lineHeight: 24, + letterSpacing: 0.15, + }, + titleSmall: { + ...baseFont, + fontSize: 14, + lineHeight: 20, + letterSpacing: 0.1, + }, + bodyLarge: { + ...baseFont, + fontSize: 16, + lineHeight: 24, + letterSpacing: 0.15, + }, + bodyMedium: { + ...baseFont, + fontSize: 14, + lineHeight: 20, + letterSpacing: 0.25, + }, + bodySmall: { + ...baseFont, + fontSize: 12, + lineHeight: 16, + letterSpacing: 0.4, + }, + labelLarge: { + ...baseFont, + fontSize: 14, + lineHeight: 20, + letterSpacing: 0.1, + }, + labelMedium: { + ...baseFont, + fontSize: 12, + lineHeight: 16, + letterSpacing: 0.5, + }, + labelSmall: { + ...baseFont, + fontSize: 11, + lineHeight: 16, + letterSpacing: 0.5, + }, + default: { + ...baseFont, + fontSize: 14, + lineHeight: 20, + letterSpacing: 0, + }, + }, + }), +}; + +// Светлая тема (на будущее) +export const lightTheme = { + ...MD3LightTheme, + ...NavigationLightTheme, + colors: { + ...MD3LightTheme.colors, + ...NavigationLightTheme.colors, + }, + fonts: MD3LightTheme.fonts, +}; + +// Экспортируем текущую тему +export const theme = darkTheme; \ No newline at end of file