diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e2b1302 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Prism is a real-time messenger built with Expo (React Native) frontend and NestJS backend using GraphQL and PostgreSQL. The project is structured as a monorepo with separate frontend and backend directories. + +## Development Commands + +### Backend (NestJS) +```bash +cd backend +npm run start:dev # Development server with hot reload +npm run build # Build for production +npm run test # Run unit tests +npm run test:e2e # Run end-to-end tests +npm run lint # ESLint with auto-fix +npm run format # Prettier formatting +``` + +### Frontend (Expo/React Native) +```bash +cd frontend +npx expo start # Start Expo development server +npx expo start --ios # Start with iOS simulator +npx expo start --android # Start with Android emulator +``` + +## Architecture + +### Backend Architecture +- **Framework**: NestJS with TypeScript +- **API**: GraphQL using code-first approach with auto-generated schema +- **Database**: PostgreSQL with TypeORM (synchronize enabled in development) +- **Authentication**: JWT with Passport strategy +- **Real-time**: GraphQL Subscriptions for live updates +- **Module Structure**: + - `auth/` - JWT authentication and guards + - `users/` - User management and profiles + - `conversations/` - Chat/conversation management + - `messages/` - Message handling and real-time updates + +### Frontend Architecture +- **Framework**: Expo with React Native and TypeScript +- **State Management**: Apollo Client for GraphQL state management +- **UI Framework**: React Native Paper with custom theming +- **Navigation**: React Navigation with stack and tab navigators +- **Storage**: AsyncStorage for token persistence +- **Key Contexts**: AuthContext for authentication state + +### Data Flow +- Frontend communicates with backend exclusively through GraphQL +- Real-time updates via GraphQL subscriptions +- JWT tokens stored in AsyncStorage and included in GraphQL requests via authLink +- Apollo Client handles caching with cache-and-network fetch policy + +## Database Configuration + +The project supports both remote and local PostgreSQL: +- Remote database is the default (configured in backend .env) +- Local database can be enabled by uncommenting postgres service in docker-compose.yml +- TypeORM synchronize is enabled in development (automatic schema updates) + +## Environment Configuration + +### Backend Environment Variables +Required in `backend/.env`: +``` +DATABASE_HOST= +DATABASE_PORT=5432 +DATABASE_USERNAME= +DATABASE_PASSWORD= +DATABASE_NAME=prism_messenger +JWT_SECRET= +JWT_EXPIRATION=7d +``` + +### Frontend API Configuration +Update `frontend/src/config/api.ts` based on development environment: +- iOS simulator: http://localhost:3000/graphql +- Android emulator: http://10.0.2.2:3000/graphql +- Physical device: http://YOUR_IP:3000/graphql + +## Key Implementation Details + +- GraphQL schema is auto-generated at `backend/src/schema.gql` +- All GraphQL queries, mutations, and subscriptions are centralized in `frontend/src/graphql/` +- Authentication uses JWT with bearer token authorization +- Real-time features implemented via GraphQL subscriptions with graphql-ws +- Frontend theme system supports both light and dark modes +- Message features include send, edit, delete with real-time updates \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts index dc53af1..5ae5089 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -12,7 +12,9 @@ async function bootstrap() { methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], }); - await app.listen(process.env.PORT ?? 3000); - console.log(`Application is running on: http://localhost:${process.env.PORT ?? 3000}/graphql`); + const port = process.env.PORT ?? 3000; + await app.listen(port, '0.0.0.0'); + console.log(`Application is running on: http://0.0.0.0:${port}/graphql`); + console.log(`For mobile devices use: http://192.168.31.210:${port}/graphql`); } bootstrap(); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 57e6452..6abc505 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -25,7 +25,7 @@ export class AuthService { throw new UnauthorizedException('Неверный логин или пароль'); } - const payload = { username: user.username, sub: user.id }; + const payload = { username: user.username || user.email, sub: user.id }; return { access_token: this.jwtService.sign(payload), user, @@ -34,7 +34,7 @@ export class AuthService { async register(username: string, email: string, password: string) { const user = await this.usersService.create(username, email, password); - const payload = { username: user.username, sub: user.id }; + const payload = { username: user.username || user.email, sub: user.id }; return { access_token: this.jwtService.sign(payload), user, diff --git a/backend/src/modules/users/entities/user.entity.ts b/backend/src/modules/users/entities/user.entity.ts index 91af505..78d68f8 100644 --- a/backend/src/modules/users/entities/user.entity.ts +++ b/backend/src/modules/users/entities/user.entity.ts @@ -9,13 +9,13 @@ export class User { @PrimaryGeneratedColumn('uuid') id: string; - @Field() - @Column({ unique: true }) - username: string; + @Field(() => String, { nullable: true }) + @Column({ unique: true, nullable: true }) + username?: string; - @Field() - @Column({ unique: true }) - email: string; + @Field(() => String, { nullable: true }) + @Column({ unique: true, nullable: true }) + email?: string; @HideField() @Column() @@ -29,9 +29,9 @@ export class User { @Column({ nullable: true }) bio?: string; - @Field(() => Boolean) - @Column({ default: false }) - isOnline: boolean; + @Field(() => Boolean, { nullable: true }) + @Column({ default: false, nullable: true }) + isOnline?: boolean; @Field(() => Date, { nullable: true }) @Column({ nullable: true }) diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index 1df2a1f..370883f 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -12,9 +12,13 @@ export class UsersService { ) {} async create(username: string, email: string, password: string): Promise { - const existingUser = await this.usersRepository.findOne({ - where: [{ username }, { email }], - }); + const whereConditions: Array<{ username?: string; email?: string }> = []; + if (username) whereConditions.push({ username }); + if (email) whereConditions.push({ email }); + + const existingUser = whereConditions.length > 0 ? await this.usersRepository.findOne({ + where: whereConditions, + }) : null; if (existingUser) { throw new ConflictException('Пользователь с таким username или email уже существует'); @@ -43,10 +47,12 @@ export class UsersService { } async findByUsername(username: string): Promise { + if (!username) return null; return this.usersRepository.findOne({ where: { username } }); } async findByEmail(email: string): Promise { + if (!email) return null; return this.usersRepository.findOne({ where: { email } }); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0efd352..9659aac 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "expo-linear-gradient": "~14.1.5", "expo-status-bar": "~2.2.3", "graphql": "^16.11.0", + "graphql-ws": "^6.0.6", "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.5", @@ -4836,6 +4837,36 @@ "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, + "node_modules/graphql-ws": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.6.tgz", + "integrity": "sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fastify/websocket": "^10 || ^11", + "crossws": "~0.3", + "graphql": "^15.10.1 || ^16", + "uWebSockets.js": "^20", + "ws": "^8" + }, + "peerDependenciesMeta": { + "@fastify/websocket": { + "optional": true + }, + "crossws": { + "optional": true + }, + "uWebSockets.js": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index dea2790..4b520f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "expo-linear-gradient": "~14.1.5", "expo-status-bar": "~2.2.3", "graphql": "^16.11.0", + "graphql-ws": "^6.0.6", "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.5", @@ -29,9 +30,9 @@ "react-native-reanimated": "~3.17.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", + "react-native-svg": "15.11.2", "react-native-vector-icons": "^10.3.0", - "react-native-web": "^0.20.0", - "react-native-svg": "15.11.2" + "react-native-web": "^0.20.0" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 024288f..15b705c 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -17,4 +17,15 @@ const getApiUrl = () => { } }; -export const API_URL = getApiUrl(); \ No newline at end of file +// Для физических устройств в сети используйте IP адрес компьютера +export const DEV_SERVER_IP = '192.168.31.210'; + +export const getDeviceApiUrl = () => { + if (__DEV__) { + return `http://${DEV_SERVER_IP}:3000/graphql`; + } + return getApiUrl(); +}; + +// Используйте getDeviceApiUrl() для физических устройств, getApiUrl() для эмуляторов +export const API_URL = getDeviceApiUrl(); \ No newline at end of file diff --git a/frontend/src/navigation/MainNavigator.tsx b/frontend/src/navigation/MainNavigator.tsx index 0a59bbc..fb1e804 100644 --- a/frontend/src/navigation/MainNavigator.tsx +++ b/frontend/src/navigation/MainNavigator.tsx @@ -7,6 +7,8 @@ import { useTheme } from 'react-native-paper'; // Импортируем существующие экраны import { ConversationsScreen } from '../screens/ConversationsScreen'; import { ChatScreen } from '../screens/ChatScreen'; +import { NewMessageScreen } from '../screens/NewMessageScreen'; +import { UserInfoScreen } from '../screens/UserInfoScreen'; // Заглушки для новых экранов (создадим позже) import { ContactsScreen } from '../screens/ContactsScreen'; @@ -25,16 +27,29 @@ function ChatsStackNavigator() { name="ConversationsList" component={ConversationsScreen} options={{ - title: 'Чаты', - headerLargeTitle: true, + headerShown: false, }} /> ({ - title: route.params?.title || 'Чат', - })} + options={{ + headerShown: false, + }} + /> + + ); @@ -48,8 +63,7 @@ function ContactsStackNavigator() { name="ContactsList" component={ContactsScreen} options={{ - title: 'Контакты', - headerLargeTitle: true, + headerShown: false, }} /> diff --git a/frontend/src/screens/ChatScreen.tsx b/frontend/src/screens/ChatScreen.tsx index b2f474d..a88a717 100644 --- a/frontend/src/screens/ChatScreen.tsx +++ b/frontend/src/screens/ChatScreen.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { View, StyleSheet, FlatList, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native'; -import { TextInput, IconButton, Text, Avatar, Surface, Menu } from 'react-native-paper'; +import { TextInput, IconButton, Text, Avatar, Surface, Menu, useTheme } from 'react-native-paper'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useQuery, useMutation, useSubscription } from '@apollo/client'; import { GET_MESSAGES } from '../graphql/queries'; import { SEND_MESSAGE, DELETE_MESSAGE } from '../graphql/mutations'; @@ -16,18 +17,25 @@ import Animated, { Layout, SlideInRight, SlideInLeft, + FadeInUp, } from 'react-native-reanimated'; +import { BackgroundDesign } from '../components/BackgroundDesign'; -export const ChatScreen = ({ route }: any) => { - const { conversationId, title } = route.params; +export const ChatScreen = ({ route, navigation }: any) => { + const { conversationId, title, otherUser } = route.params; const { user } = useAuth(); + const theme = useTheme(); const [message, setMessage] = useState(''); const [selectedMessage, setSelectedMessage] = useState(null); const [menuVisible, setMenuVisible] = useState(false); + const [chatMenuVisible, setChatMenuVisible] = useState(false); const flatListRef = useRef(null); + const textInputRef = useRef(null); const { data, loading, error } = useQuery(GET_MESSAGES, { variables: { conversationId }, + pollInterval: 1000, // Опрос каждую секунду для лучшего UX + fetchPolicy: 'cache-and-network', // Всегда проверять сеть }); const [sendMessage] = useMutation(SEND_MESSAGE, { @@ -65,14 +73,54 @@ export const ChatScreen = ({ route }: any) => { }, }); - useSubscription(MESSAGE_ADDED, { - variables: { conversationId }, - onData: ({ data }) => { - if (data?.data?.messageAdded && data.data.messageAdded.sender.id !== user?.id) { - flatListRef.current?.scrollToEnd(); - } - }, - }); + // Временно отключаем подписки, используем только polling + // useSubscription(MESSAGE_ADDED, { + // variables: { conversationId }, + // onError: (error) => { + // console.error('Subscription error:', error); + // }, + // onData: ({ data, client }) => { + // console.log('Subscription data received:', data); + // const newMessage = data?.data?.messageAdded; + // if (newMessage) { + // console.log('New message received:', newMessage.content); + // // Обновляем кеш с новым сообщением + // const existingData = client.readQuery({ + // query: GET_MESSAGES, + // variables: { conversationId }, + // }); + + // if (existingData) { + // // Проверяем, не существует ли уже это сообщение в кеше + // const messageExists = existingData.messages.some((msg: any) => msg.id === newMessage.id); + + // if (!messageExists) { + // client.writeQuery({ + // query: GET_MESSAGES, + // variables: { conversationId }, + // data: { + // messages: [...existingData.messages, newMessage], + // }, + // }); + // } + // } + + // // Прокручиваем к концу только если это не наше сообщение + // if (newMessage.sender.id !== user?.id) { + // setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100); + // } + // } + // }, + // }); + + // Автопрокрутка при изменении сообщений + useEffect(() => { + if (data?.messages?.length > 0) { + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 100); + } + }, [data?.messages?.length]); const handleSend = async () => { if (!message.trim()) return; @@ -85,7 +133,7 @@ export const ChatScreen = ({ route }: any) => { }, }); setMessage(''); - setTimeout(() => flatListRef.current?.scrollToEnd(), 100); + setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100); } catch (error) { console.error('Error sending message:', error); } @@ -107,54 +155,102 @@ export const ChatScreen = ({ route }: any) => { return ( + {/* Аватар для входящих сообщений */} {!isOwnMessage && ( - + + + )} + + {/* Контейнер сообщения */} + + { + if (isOwnMessage) { + setSelectedMessage(item.id); + setMenuVisible(true); } }} - /> - )} - - {isOwnMessage ? ( - - + > + + + + {/* Имя отправителя для входящих сообщений */} + {!isOwnMessage && ( + + {item.sender.username || item.sender.email?.split('@')[0] || 'Пользователь'} + + )} + + {/* Текст сообщения */} + {item.content} - - {messageTime} - {item.isEdited && ' • изменено'} - - - ) : ( - - - {item.sender.username} - - - {item.content} - - - {messageTime} - {item.isEdited && ' • изменено'} - - - )} + + {/* Время и статус */} + + + {messageTime} + {item.isEdited && ' • изм.'} + + + {/* Статус прочтения для исходящих */} + {isOwnMessage && ( + + )} + + + + + {/* Меню для собственных сообщений */} {isOwnMessage && ( { setMenuVisible(false); setSelectedMessage(null); }} - anchor={ - { - setSelectedMessage(item.id); - setMenuVisible(true); - }} - style={styles.menuButton} - > - - - } - contentStyle={styles.menuContent} + anchor={} + contentStyle={[styles.messageMenuContent, { backgroundColor: theme.colors.surface }]} > handleDeleteMessage(item.id)} title="Удалить" - titleStyle={styles.menuItemText} + leadingIcon="delete" + titleStyle={{ color: theme.colors.error }} + /> + { + setMenuVisible(false); + setSelectedMessage(null); + }} + title="Редактировать" + leadingIcon="pencil" + titleStyle={{ color: theme.colors.onSurface }} /> )} @@ -200,15 +292,138 @@ export const ChatScreen = ({ route }: any) => { } return ( - - + + {/* Компактный заголовок */} + + + + + + {/* Кнопка назад */} + navigation.goBack()} + style={styles.backButton} + activeOpacity={0.7} + > + + + + {/* Информация о пользователе */} + navigation.navigate('UserInfo', { user: otherUser })} + > + + + {/* Статус онлайн */} + + + + + + + + {title || 'Пользователь'} + + + в сети + + + + + {/* Меню действий */} + setChatMenuVisible(false)} + anchor={ + setChatMenuVisible(true)} + activeOpacity={0.7} + > + + + } + > + { + setChatMenuVisible(false); + // TODO: Открыть поиск по чату + }} + title="Поиск по чату" + leadingIcon="magnify" + /> + { + setChatMenuVisible(false); + // TODO: Отметить как непрочитанное + }} + title="Не прочитано" + leadingIcon="message-badge" + /> + { + setChatMenuVisible(false); + // TODO: Информация о чате + }} + title="Информация о чате" + leadingIcon="information" + /> + { + setChatMenuVisible(false); + // TODO: Отключить уведомления + }} + title="Без уведомлений" + leadingIcon="bell-off" + /> + { + setChatMenuVisible(false); + // TODO: Очистить историю + }} + title="Очистить историю" + leadingIcon="delete-sweep" + /> + + + + + + {/* Сообщения */} { renderItem={renderMessage} keyExtractor={(item) => item.id} contentContainerStyle={styles.messagesList} - onContentSizeChange={() => flatListRef.current?.scrollToEnd()} + onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })} + onLayout={() => flatListRef.current?.scrollToEnd({ animated: false })} showsVerticalScrollIndicator={false} + removeClippedSubviews={false} + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + autoscrollToTopThreshold: 100, + }} /> - - + + {/* Поле ввода */} + + {/* Основной контейнер ввода */} - - } + + + {/* Левые кнопки */} + + { + // TODO: Прикрепить файл + }} + > + + + + { + // TODO: Камера + }} + > + + + + + {/* Поле ввода */} + + { + if (message.trim()) { + handleSend(); + } + }} + blurOnSubmit={false} + /> + + + {/* Правые кнопки */} + + {message.trim() ? ( + + + + + + ) : ( + <> + { + // TODO: Голосовое сообщение + }} + > + + + + { + // TODO: Эмодзи + }} + > + + + + )} + - + - + ); }; @@ -267,6 +572,105 @@ const styles = StyleSheet.create({ keyboardAvoidingView: { flex: 1, }, + + // Заголовок + headerContainer: { + paddingTop: 60, + paddingHorizontal: 20, + paddingBottom: 12, + }, + headerCard: { + borderRadius: 20, + backgroundColor: 'rgba(26, 26, 26, 0.8)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 30, + elevation: 20, + overflow: 'hidden', + }, + headerGradient: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 20, + }, + headerContent: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + }, + backButton: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + marginRight: 12, + }, + userInfo: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + }, + avatarContainer: { + position: 'relative', + marginRight: 12, + }, + headerAvatar: { + borderWidth: 2, + borderColor: 'rgba(255, 255, 255, 0.2)', + }, + onlineIndicator: { + position: 'absolute', + bottom: -2, + right: -2, + width: 16, + height: 16, + borderRadius: 8, + backgroundColor: 'rgba(26, 26, 26, 0.9)', + borderWidth: 2, + borderColor: 'rgba(26, 26, 26, 0.9)', + justifyContent: 'center', + alignItems: 'center', + }, + onlineDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#4CAF50', + }, + userDetails: { + flex: 1, + }, + userName: { + fontWeight: '600', + fontSize: 16, + marginBottom: 2, + }, + userStatus: { + fontSize: 12, + opacity: 0.8, + }, + menuButton: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + marginLeft: 8, + }, loadingContainer: { flex: 1, justifyContent: 'center', @@ -278,24 +682,30 @@ const styles = StyleSheet.create({ fontSize: 16, }, messagesList: { - padding: 16, + paddingHorizontal: 16, + paddingTop: 8, paddingBottom: 8, }, + // Сообщения messageContainer: { flexDirection: 'row', - marginBottom: 12, + marginBottom: 16, alignItems: 'flex-end', }, ownMessageContainer: { justifyContent: 'flex-end', }, - avatar: { - marginRight: 12, - backgroundColor: '#2d2d2d', + messageAvatarContainer: { + marginRight: 8, + alignSelf: 'flex-end', }, - avatarLabel: { - color: '#ffffff', - fontSize: 18, + messageAvatar: { + borderWidth: 2, + borderColor: 'rgba(255, 255, 255, 0.1)', + }, + messageAvatarLabel: { + fontSize: 14, + fontWeight: '600', }, messageBubbleContainer: { maxWidth: '75%', @@ -305,72 +715,151 @@ const styles = StyleSheet.create({ alignItems: 'flex-end', }, messageBubble: { - padding: 12, - paddingRight: 16, borderRadius: 18, + padding: 16, minWidth: 80, - }, - otherMessageBubble: { - backgroundColor: '#1a1a1a', + position: 'relative', + overflow: 'hidden', borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.1)', }, + ownMessageBubble: { + backgroundColor: 'rgba(26, 26, 26, 0.8)', + marginLeft: 60, + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 4, + }, + incomingMessageBubble: { + backgroundColor: 'rgba(26, 26, 26, 0.6)', + marginRight: 60, + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 8, + elevation: 2, + }, + messageBubbleGradient: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 18, + }, senderName: { - color: '#808080', - marginBottom: 4, fontSize: 12, - letterSpacing: 0.5, + fontWeight: '600', + marginBottom: 4, + letterSpacing: 0.3, }, messageText: { - color: '#ffffff', - fontSize: 15, - lineHeight: 20, + fontSize: 16, + lineHeight: 22, + marginBottom: 6, }, - ownMessageText: { - color: '#000000', + messageFooter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', }, messageTime: { - color: '#666666', - marginTop: 4, fontSize: 11, + fontWeight: '500', + opacity: 0.8, }, - ownMessageTime: { - color: 'rgba(0, 0, 0, 0.5)', + readStatus: { + marginLeft: 4, }, - menuButton: { - position: 'absolute', - top: -8, - right: -40, - padding: 0, - }, - menuContent: { - backgroundColor: '#1a1a1a', + messageMenuContent: { borderRadius: 12, borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.1)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 12, + elevation: 8, }, - menuItemText: { - color: '#ffffff', - }, + // Поле ввода inputContainer: { - position: 'relative', + backgroundColor: 'transparent', borderTopWidth: 1, - borderTopColor: 'rgba(255, 255, 255, 0.1)', + borderTopColor: 'rgba(255, 255, 255, 0.08)', }, - inputGradient: { + inputWrapper: { + flexDirection: 'row', + alignItems: 'flex-end', + paddingHorizontal: 16, + paddingVertical: 12, + position: 'relative', + minHeight: 60, + }, + inputWrapperGradient: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, }, - inputWrapper: { - padding: 12, + leftButtons: { + flexDirection: 'row', + alignItems: 'center', + marginRight: 8, }, - input: { - maxHeight: 100, - fontSize: 16, - backgroundColor: 'rgba(255, 255, 255, 0.05)', + rightButtons: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 8, + }, + actionButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.08)', + justifyContent: 'center', + alignItems: 'center', + marginHorizontal: 4, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.12)', + }, + textInputWrapper: { + flex: 1, + backgroundColor: 'rgba(255, 255, 255, 0.06)', borderRadius: 24, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.12)', + paddingHorizontal: 16, + paddingVertical: 4, + minHeight: 40, + maxHeight: 120, + }, + messageInput: { + fontSize: 16, + color: '#ffffff', + lineHeight: 20, + textAlignVertical: 'center', + paddingVertical: 8, + minHeight: 32, + }, + sendButtonContainer: { + width: 40, + height: 40, + borderRadius: 20, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, + }, + sendButtonGradient: { + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 20, }, }); \ No newline at end of file diff --git a/frontend/src/screens/ContactsScreen.tsx b/frontend/src/screens/ContactsScreen.tsx index bc17400..86cc151 100644 --- a/frontend/src/screens/ContactsScreen.tsx +++ b/frontend/src/screens/ContactsScreen.tsx @@ -1,19 +1,41 @@ 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 { View, StyleSheet, FlatList, TouchableOpacity, Dimensions } from 'react-native'; +import { Searchbar, Avatar, Text, Chip, IconButton, SegmentedButtons, ActivityIndicator, useTheme, Headline } 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'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { + FadeInDown, + FadeInRight, + FadeInUp, + useSharedValue, + useAnimatedStyle, + withTiming, + withSpring, + withSequence, + withRepeat, + withDelay, + interpolate, + Easing, +} from 'react-native-reanimated'; +import { BackgroundDesign } from '../components/BackgroundDesign'; 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 theme = useTheme(); + + // Анимации + const headerScale = useSharedValue(0.95); + const headerOpacity = useSharedValue(0); + const listAnimation = useSharedValue(0); + const statsAnimation = useSharedValue(0); const { data, loading, refetch } = useQuery(GET_USERS); @@ -34,16 +56,20 @@ export const ContactsScreen = ({ navigation }: any) => { }); useEffect(() => { - Animated.timing(fadeAnim, { - toValue: 1, - duration: 500, - useNativeDriver: true, - }).start(); + // Анимация появления заголовка + headerScale.value = withSpring(1, { damping: 15, stiffness: 100 }); + headerOpacity.value = withTiming(1, { duration: 800 }); + + // Анимация списка + listAnimation.value = withTiming(1, { duration: 600, easing: Easing.out(Easing.ease) }); + + // Анимация статистики + statsAnimation.value = withDelay(200, withTiming(1, { duration: 500 })); }, []); const getOtherParticipantName = (conversation: any) => { const otherParticipant = conversation.participants?.find((p: User) => p.id !== currentUser?.id); - return otherParticipant?.username || 'Чат'; + return otherParticipant?.username || otherParticipant?.email || 'Чат'; }; const handleStartChat = async (userId: string) => { @@ -57,12 +83,36 @@ export const ContactsScreen = ({ navigation }: any) => { } }; + // Анимированные стили + const headerAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [{ scale: headerScale.value }], + opacity: headerOpacity.value, + }; + }); + + const listAnimatedStyle = useAnimatedStyle(() => { + const translateY = interpolate(listAnimation.value, [0, 1], [30, 0]); + return { + transform: [{ translateY }], + opacity: listAnimation.value, + }; + }); + + const statsAnimatedStyle = useAnimatedStyle(() => { + const scale = interpolate(statsAnimation.value, [0, 1], [0.8, 1]); + return { + transform: [{ scale }], + opacity: statsAnimation.value, + }; + }); + 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 user.username?.toLowerCase().includes(searchQuery.toLowerCase()) || + user.email?.toLowerCase().includes(searchQuery.toLowerCase()); } return true; @@ -72,60 +122,59 @@ export const ContactsScreen = ({ navigation }: any) => { const isCreatingChat = creatingChatWithId === item.id; return ( - handleStartChat(item.id)} disabled={isCreatingChat} > - + + + - - {item.isOnline && ( + + {item.isOnline === true && ( - + )} - - @{item.username} + + @{item.username || item.email?.split('@')[0] || item.id.slice(0, 8)} - {item.isOnline && ( - - В сети - + {item.isOnline === true && ( + + В сети + )} - - {item.bio || `${item.email}`} + + {item.bio || item.email || 'Пользователь'} - {!item.isOnline && item.lastSeen && ( - + {item.isOnline !== true && item.lastSeen && ( + Был(а) {formatLastSeen(item.lastSeen)} )} @@ -133,15 +182,28 @@ export const ContactsScreen = ({ navigation }: any) => { {isCreatingChat ? ( - + + + ) : ( - handleStartChat(item.id)} - /> + style={styles.messageButton} + activeOpacity={0.7} + > + + + + )} @@ -152,9 +214,9 @@ export const ContactsScreen = ({ navigation }: any) => { }; // Вспомогательные функции - const getAvatarColor = (username: string) => { + const getAvatarColor = (identifier: string) => { const colors = ['#2196F3', '#4CAF50', '#FF5722', '#9C27B0', '#FF9800', '#00BCD4']; - const index = username.charCodeAt(0) % colors.length; + const index = identifier.charCodeAt(0) % colors.length; return colors[index]; }; @@ -174,132 +236,224 @@ export const ContactsScreen = ({ navigation }: any) => { }; // Подсчет статистики - const onlineUsersCount = data?.users?.filter((u: User) => u.isOnline && u.id !== currentUser?.id).length || 0; + const onlineUsersCount = data?.users?.filter((u: User) => u.isOnline === true && 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 && ( - - Проверьте правильность никнейма + {/* Статистика */} + + + + {totalUsersCount} - )} - - } - /> - + + пользователей + + + + + + + + {onlineUsersCount} + + + + в сети + + + + + + + {/* Табы */} + + + + + {/* Список пользователей */} + + { + if (selectedTab === 'online') return user.isOnline === true; + return true; + })} + renderItem={renderUser} + keyExtractor={(item) => item.id} + onRefresh={refetch} + refreshing={loading} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + + + + + {searchQuery + ? `@${searchQuery} не найден` + : selectedTab === 'online' + ? 'Нет пользователей онлайн' + : 'Нет доступных пользователей'} + + {searchQuery && ( + + Проверьте правильность никнейма + + )} + + } + /> + + ); }; const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#f5f5f5', + // Заголовок + headerContainer: { + paddingTop: 60, + paddingHorizontal: 20, + paddingBottom: 20, }, - header: { - backgroundColor: '#ffffff', - paddingBottom: 16, - elevation: 2, + headerCard: { + borderRadius: 24, + padding: 24, + backgroundColor: 'rgba(26, 26, 26, 0.8)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 30, + elevation: 20, }, - searchContainer: { - padding: 16, - paddingBottom: 8, + headerGradient: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 24, + }, + headerContent: { + alignItems: 'center', + marginBottom: 20, + }, + logoContainer: { + marginBottom: 12, + }, + logoPlaceholder: { + width: 60, + height: 60, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: 'rgba(255, 255, 255, 0.2)', + }, + title: { + fontSize: 28, + fontWeight: '300', + letterSpacing: 3, + marginBottom: 8, }, searchbar: { - backgroundColor: '#f8f9fa', borderRadius: 12, + paddingHorizontal: 16, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + elevation: 0, + marginBottom: 20, }, - searchHint: { - color: '#666', - marginTop: 8, - textAlign: 'center', - }, + + // Статистика statsContainer: { flexDirection: 'row', justifyContent: 'center', - paddingHorizontal: 16, - paddingTop: 8, + alignItems: 'center', }, statItem: { alignItems: 'center', @@ -308,120 +462,210 @@ const styles = StyleSheet.create({ statDivider: { width: 1, height: 40, - backgroundColor: '#e0e0e0', + marginHorizontal: 16, }, statNumber: { - fontWeight: 'bold', + fontWeight: '600', + fontSize: 24, }, statLabel: { - color: '#666', - marginTop: 2, + marginTop: 4, + fontSize: 12, + letterSpacing: 0.5, }, onlineStatContainer: { flexDirection: 'row', alignItems: 'center', }, - tabs: { - marginHorizontal: 16, - marginVertical: 16, - }, - listContent: { - paddingHorizontal: 16, + + // Табы + tabsContainer: { + paddingHorizontal: 20, paddingBottom: 16, }, + tabs: { + borderRadius: 12, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + }, + + // Список + listContainer: { + flex: 1, + paddingHorizontal: 20, + }, + listContent: { + flexGrow: 1, + paddingBottom: 100, + }, + + // Карточка пользователя userCard: { - backgroundColor: '#ffffff', - borderRadius: 16, - marginBottom: 12, + marginVertical: 6, + borderRadius: 20, + backgroundColor: 'rgba(26, 26, 26, 0.6)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.05, + shadowRadius: 15, + elevation: 5, + position: 'relative', + overflow: 'hidden', }, - 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, + userGradient: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 20, }, userContent: { flexDirection: 'row', - padding: 16, + padding: 20, alignItems: 'center', }, avatarContainer: { position: 'relative', marginRight: 16, }, + avatarGradient: { + position: 'absolute', + width: 60, + height: 60, + borderRadius: 30, + }, avatar: { - elevation: 2, + borderWidth: 2, + borderColor: 'rgba(255, 255, 255, 0.2)', + }, + avatarLabel: { + fontSize: 24, + fontWeight: '600', }, onlineIndicator: { position: 'absolute', - bottom: 2, - right: 2, - backgroundColor: '#ffffff', - borderRadius: 7, - padding: 1, + bottom: -2, + right: -2, + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 3, + borderColor: 'rgba(26, 26, 26, 0.8)', + justifyContent: 'center', + alignItems: 'center', + }, + onlineBadge: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: '#4CAF50', }, userInfo: { flex: 1, - marginRight: 8, + marginRight: 12, }, userHeader: { flexDirection: 'row', alignItems: 'center', - marginBottom: 4, + marginBottom: 6, }, username: { fontWeight: '600', - color: '#1a1a1a', + fontSize: 17, }, onlineChip: { marginLeft: 8, - backgroundColor: '#e8f5e9', - height: 22, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 12, + backgroundColor: 'rgba(76, 175, 80, 0.15)', + borderWidth: 1, + borderColor: 'rgba(76, 175, 80, 0.3)', }, onlineChipText: { fontSize: 11, - color: '#4CAF50', + fontWeight: '500', }, userBio: { - color: '#666', lineHeight: 20, + fontSize: 15, }, lastSeen: { - color: '#999', marginTop: 4, fontSize: 12, }, actionContainer: { justifyContent: 'center', - minWidth: 48, + alignItems: 'center', + }, + loadingContainer: { + width: 44, + height: 44, + justifyContent: 'center', alignItems: 'center', }, messageButton: { - margin: 0, + borderRadius: 12, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 4, }, + messageButtonGradient: { + width: 44, + height: 44, + justifyContent: 'center', + alignItems: 'center', + }, + + // Empty состояние emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', - paddingTop: 100, + paddingHorizontal: 40, + paddingTop: 80, + }, + emptyIconContainer: { + width: 120, + height: 120, + borderRadius: 60, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 32, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + backgroundColor: 'rgba(26, 26, 26, 0.6)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 20, + elevation: 10, + position: 'relative', + overflow: 'hidden', + }, + emptyIconGradient: { + position: 'absolute', + width: 120, + height: 120, + borderRadius: 60, }, emptyText: { - color: '#666', - fontSize: 16, textAlign: 'center', + marginBottom: 12, + fontWeight: '300', + letterSpacing: 0.5, + fontSize: 24, + }, + emptySubtext: { + textAlign: 'center', + maxWidth: 280, + fontSize: 16, + lineHeight: 24, }, }); \ No newline at end of file diff --git a/frontend/src/screens/ConversationsScreen.tsx b/frontend/src/screens/ConversationsScreen.tsx index 6fd015c..ca88ac5 100644 --- a/frontend/src/screens/ConversationsScreen.tsx +++ b/frontend/src/screens/ConversationsScreen.tsx @@ -1,26 +1,75 @@ -import React, { useState } from 'react'; -import { View, StyleSheet, FlatList, TouchableOpacity, Dimensions } from 'react-native'; -import { List, Avatar, Text, FAB, Divider, Badge, Searchbar, IconButton, useTheme } from 'react-native-paper'; -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'; -import { LinearGradient } from 'expo-linear-gradient'; +import { useQuery } from "@apollo/client"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { format, isToday, isYesterday } from "date-fns"; +import { ru } from "date-fns/locale"; +import { LinearGradient } from "expo-linear-gradient"; +import React, { useEffect, useState } from "react"; +import { + FlatList, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { + Avatar, + Badge, + Menu, + Searchbar, + Text, + useTheme, +} from "react-native-paper"; import Animated, { + Easing, FadeInDown, FadeInRight, - Layout, -} from 'react-native-reanimated'; -import { useAuth } from '../contexts/AuthContext'; + FadeInUp, + interpolate, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from "react-native-reanimated"; +import { BackgroundDesign } from "../components/BackgroundDesign"; +import { useAuth } from "../contexts/AuthContext"; +import { GET_CONVERSATIONS } from "../graphql/queries"; +import { Conversation } from "../types"; -const { width } = Dimensions.get('window'); +type UserStatus = "online" | "away" | "busy" | "invisible"; export const ConversationsScreen = ({ navigation }: any) => { - const [searchQuery, setSearchQuery] = useState(''); + const [searchQuery, setSearchQuery] = useState(""); + const [isSearchMode, setIsSearchMode] = useState(false); + const [userStatus, setUserStatus] = useState("online"); + const [statusMenuVisible, setStatusMenuVisible] = useState(false); const { user } = useAuth(); const theme = useTheme(); - + + // Анимации + const headerScale = useSharedValue(0.95); + const headerOpacity = useSharedValue(0); + const listAnimation = useSharedValue(0); + const searchAnimation = useSharedValue(0); + + useEffect(() => { + // Анимация появления заголовка + headerScale.value = withSpring(1, { damping: 15, stiffness: 100 }); + headerOpacity.value = withTiming(1, { duration: 800 }); + + // Анимация списка + listAnimation.value = withTiming(1, { + duration: 600, + easing: Easing.out(Easing.ease), + }); + }, []); + + useEffect(() => { + // Анимация поиска + searchAnimation.value = withTiming(isSearchMode ? 1 : 0, { + duration: 300, + easing: Easing.bezier(0.4, 0.0, 0.2, 1), + }); + }, [isSearchMode]); + const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS, { pollInterval: 5000, }); @@ -28,82 +77,185 @@ export const ConversationsScreen = ({ navigation }: any) => { const formatMessageTime = (date: string) => { const messageDate = new Date(date); if (isToday(messageDate)) { - return format(messageDate, 'HH:mm', { locale: ru }); + return format(messageDate, "HH:mm", { locale: ru }); } else if (isYesterday(messageDate)) { - return 'Вчера'; + return "Вчера"; } else { - return format(messageDate, 'dd.MM', { locale: ru }); + return format(messageDate, "dd.MM", { locale: ru }); } }; // Фильтрация чатов по поисковому запросу - 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 filteredConversations = + data?.conversations?.filter((conv: Conversation) => { + if (!searchQuery) return true; - const renderConversation = ({ item, index }: { item: Conversation; index: number }) => { - const otherParticipant = item.participants.find(p => p.id !== user?.id); - const displayName = item.isGroup ? item.name : otherParticipant?.username; - const lastMessageTime = item.lastMessage + const otherParticipant = conv.participants.find((p) => p.id !== user?.id); + const displayName = conv.isGroup ? conv.name : (otherParticipant?.username || otherParticipant?.email); + + return ( + displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || + conv.lastMessage?.content + .toLowerCase() + .includes(searchQuery.toLowerCase()) + ); + }) || []; + + // Функции статуса + const getStatusInfo = (status: UserStatus) => { + switch (status) { + case "online": + return { icon: "circle", color: "#4CAF50", label: "В сети" }; + case "away": + return { icon: "clock-outline", color: "#FF9800", label: "Отошел" }; + case "busy": + return { + icon: "minus-circle", + color: "#F44336", + label: "Не беспокоить", + }; + case "invisible": + return { + icon: "eye-off-outline", + color: "#9E9E9E", + label: "Невидимый", + }; + } + }; + + const handleStatusChange = (newStatus: UserStatus) => { + setUserStatus(newStatus); + setStatusMenuVisible(false); + // TODO: Здесь будет GraphQL мутация для обновления статуса + }; + + const toggleSearchMode = () => { + setIsSearchMode(!isSearchMode); + if (isSearchMode) { + setSearchQuery(""); + } + }; + + const handleNewMessage = () => { + navigation.navigate("NewMessage"); + }; + + // Анимированные стили + const headerAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [{ scale: headerScale.value }], + opacity: headerOpacity.value, + }; + }); + + const listAnimatedStyle = useAnimatedStyle(() => { + const translateY = interpolate(listAnimation.value, [0, 1], [30, 0]); + return { + transform: [{ translateY }], + opacity: listAnimation.value, + }; + }); + + const searchAnimatedStyle = useAnimatedStyle(() => { + const height = interpolate(searchAnimation.value, [0, 1], [0, 60]); + const opacity = searchAnimation.value; + return { + height, + opacity, + overflow: "hidden" as const, + }; + }); + + const renderConversation = ({ + item, + index, + }: { + item: Conversation; + index: number; + }) => { + const otherParticipant = item.participants.find((p) => p.id !== user?.id); + const displayName = item.isGroup ? item.name : (otherParticipant?.username || otherParticipant?.email); + const lastMessageTime = item.lastMessage ? formatMessageTime(item.lastMessage.createdAt) - : ''; - + : ""; + // Подсчет непрочитанных сообщений (в будущем добавить в GraphQL) const unreadCount = 0; return ( - + navigation.navigate('Chat', { conversationId: item.id, title: displayName })} - activeOpacity={0.7} + onPress={() => + navigation.navigate("Chat", { + conversationId: item.id, + title: displayName, + }) + } + activeOpacity={0.8} > - + + + - - {otherParticipant?.isOnline && ( - + {otherParticipant?.isOnline === true && ( + )} - + - - {displayName || 'Без имени'} + {displayName || "Без имени"} - + {lastMessageTime} - + - - {item.lastMessage?.content || 'Нет сообщений'} + {item.lastMessage?.content || "Нет сообщений"} {unreadCount > 0 && ( {unreadCount} @@ -118,91 +270,265 @@ export const ConversationsScreen = ({ navigation }: any) => { if (loading && !data) { return ( - - - Загрузка чатов... - + + + + + + Загрузка чатов... + + + + ); } if (error) { return ( - - - Ошибка загрузки чатов - refetch()} style={[styles.retryButton, { backgroundColor: theme.colors.surfaceVariant }]}> - Попробовать снова - - + + + + + + Ошибка загрузки чатов + + refetch()} + style={[styles.retryButton]} + > + + + Попробовать снова + + + + + + ); } return ( - - - - {/* Поисковая строка */} - - + {/* Заголовок */} + + + + + {/* Верхняя строка с статусом и поиском */} + + {/* Статус пользователя */} + setStatusMenuVisible(false)} + anchor={ + setStatusMenuVisible(true)} + activeOpacity={0.7} + > + + + {getStatusInfo(userStatus).label} + + + + } + > + {(["online", "away", "busy", "invisible"] as UserStatus[]).map( + (status) => ( + handleStatusChange(status)} + title={getStatusInfo(status).label} + leadingIcon={getStatusInfo(status).icon} + titleStyle={{ color: getStatusInfo(status).color }} + /> + ) + )} + + + + {/* Кнопка поиска */} + + + + + + + + {/* Поисковая строка (появляется при активации поиска) */} + + + + + + + {/* Список чатов */} + + item.id} + onRefresh={refetch} + refreshing={loading} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + + + + + Нет активных чатов + + + Начните новый чат, нажав на кнопку внизу + + + } + /> + + + {/* Разделитель */} + + - item.id} - onRefresh={refetch} - refreshing={loading} - contentContainerStyle={styles.listContent} - showsVerticalScrollIndicator={false} - ItemSeparatorComponent={() => } - ListEmptyComponent={ - + + - - - 💬 - - - Нет активных чатов - - - Начните новый чат, нажав на кнопку внизу - - - } - /> - - - navigation.navigate('Contacts')} - color={theme.colors.onPrimary} - /> + + + - + ); }; @@ -210,166 +536,360 @@ const styles = StyleSheet.create({ container: { flex: 1, }, + + // Loading состояние loadingContainer: { flex: 1, - justifyContent: 'center', - alignItems: 'center', + justifyContent: "center", + alignItems: "center", + padding: 20, }, - searchContainer: { - padding: 16, - paddingBottom: 8, - elevation: 2, + loadingCard: { + borderRadius: 24, + padding: 40, + backgroundColor: "rgba(26, 26, 26, 0.8)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + alignItems: "center", + shadowColor: "#ffffff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 30, + elevation: 20, }, - searchbar: { - elevation: 0, - }, - loadingText: { - fontSize: 16, - }, - errorContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - errorText: { - fontSize: 16, - marginBottom: 20, - }, - retryButton: { - paddingHorizontal: 24, - paddingVertical: 12, + loadingGradient: { + position: "absolute", + left: 0, + right: 0, + top: 0, + bottom: 0, borderRadius: 24, }, + loadingText: { + fontSize: 18, + fontWeight: "300", + letterSpacing: 1, + }, + + // Error состояние + errorContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 20, + }, + errorCard: { + borderRadius: 24, + padding: 40, + backgroundColor: "rgba(26, 26, 26, 0.8)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + alignItems: "center", + shadowColor: "#ffffff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 30, + elevation: 20, + }, + errorGradient: { + position: "absolute", + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 24, + }, + errorText: { + fontSize: 18, + marginBottom: 24, + textAlign: "center", + fontWeight: "300", + letterSpacing: 1, + }, + retryButton: { + borderRadius: 12, + overflow: "hidden", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 8, + }, + retryGradient: { + paddingVertical: 16, + paddingHorizontal: 32, + alignItems: "center", + }, retryText: { + fontSize: 16, + fontWeight: "700", + letterSpacing: 2, + }, + + // Заголовок + headerContainer: { + paddingTop: 60, + paddingHorizontal: 20, + paddingBottom: 8, + }, + + topRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, + }, + + // Статус + statusButton: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + }, + statusIndicator: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: 8, + }, + statusText: { fontSize: 14, - fontWeight: '600', + fontWeight: "500", + marginRight: 6, + }, + statusIcon: { + fontSize: 10, + color: "rgba(255, 255, 255, 0.6)", + }, + + // Поиск + searchToggle: { + borderRadius: 12, + overflow: "hidden", + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 4, + }, + searchToggleActive: { + shadowColor: "#ffffff", + shadowOpacity: 0.3, + }, + searchToggleGradient: { + width: 44, + height: 44, + justifyContent: "center", + alignItems: "center", + }, + + + searchContainer: { + marginTop: 8, + }, + headerCard: { + borderRadius: 24, + padding: 12, + backgroundColor: "rgba(26, 26, 26, 0.8)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + shadowColor: "#ffffff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 30, + elevation: 20, + }, + headerGradient: { + position: "absolute", + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 24, + }, + searchbar: { + borderRadius: 12, + paddingHorizontal: 16, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + elevation: 0, + }, + + // Список + listContainer: { + flex: 1, + paddingHorizontal: 20, }, listContent: { flexGrow: 1, - paddingVertical: 8, + paddingBottom: 8, }, conversationItem: { - flexDirection: 'row', - paddingVertical: 16, - paddingHorizontal: 16, - marginHorizontal: 12, - marginVertical: 4, - borderRadius: 16, + flexDirection: "row", + paddingVertical: 20, + paddingHorizontal: 20, + marginVertical: 6, + borderRadius: 20, + backgroundColor: "rgba(26, 26, 26, 0.6)", borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.05)', + borderColor: "rgba(255, 255, 255, 0.1)", + shadowColor: "#ffffff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.05, + shadowRadius: 15, + elevation: 5, + position: "relative", + overflow: "hidden", + }, + conversationGradient: { + position: "absolute", + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 20, }, avatarContainer: { - position: 'relative', - marginRight: 12, + position: "relative", + marginRight: 16, }, avatarGradient: { - position: 'absolute', - width: 52, - height: 52, - borderRadius: 26, + position: "absolute", + width: 56, + height: 56, + borderRadius: 28, }, avatar: { - backgroundColor: '#2d2d2d', + borderWidth: 2, + borderColor: "rgba(255, 255, 255, 0.2)", }, avatarLabel: { - fontSize: 20, - fontWeight: '600', + fontSize: 22, + fontWeight: "600", }, onlineBadge: { - position: 'absolute', - bottom: 0, - right: 0, - backgroundColor: '#4CAF50', - borderWidth: 2, - borderColor: '#0a0a0a', + position: "absolute", + bottom: -2, + right: -2, + backgroundColor: "#4CAF50", + borderWidth: 3, + borderColor: "rgba(26, 26, 26, 0.8)", }, contentContainer: { flex: 1, - justifyContent: 'center', + justifyContent: "center", }, headerRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 4, + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 6, }, conversationTitle: { - fontWeight: '600', + fontWeight: "600", flex: 1, marginRight: 12, + fontSize: 17, }, time: { - fontSize: 12, + fontSize: 13, + fontWeight: "500", }, messageRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", }, lastMessage: { flex: 1, + fontSize: 15, }, unreadBadge: { - backgroundColor: '#2196F3', - color: '#ffffff', + backgroundColor: "#2196F3", + color: "#ffffff", fontSize: 12, + marginLeft: 8, }, - separator: { + + // Разделитель + dividerContainer: { + paddingHorizontal: 20, + marginTop: 4, + marginBottom: 4, + }, + divider: { height: 1, - marginHorizontal: 28, - marginVertical: 4, + borderRadius: 0.5, }, - fab: { - position: 'absolute', - margin: 16, - right: 0, - bottom: 0, - borderRadius: 16, - // iOS тени - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 4, - }, + + // FAB + fabContainer: { + position: "absolute", + right: 20, + bottom: 100, + borderRadius: 20, + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 12, - // Android тень elevation: 8, }, + fab: { + width: 64, + height: 64, + borderRadius: 20, + justifyContent: "center", + alignItems: "center", + }, + + // Empty состояние emptyContainer: { flex: 1, - justifyContent: 'center', - alignItems: 'center', + justifyContent: "center", + alignItems: "center", paddingHorizontal: 40, - paddingTop: 100, + paddingTop: 80, }, emptyIconContainer: { width: 120, height: 120, borderRadius: 60, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 24, + justifyContent: "center", + alignItems: "center", + marginBottom: 32, borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.05)', + borderColor: "rgba(255, 255, 255, 0.1)", + backgroundColor: "rgba(26, 26, 26, 0.6)", + shadowColor: "#ffffff", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 20, + elevation: 10, + position: "relative", + overflow: "hidden", }, emptyIconGradient: { - position: 'absolute', + position: "absolute", width: 120, height: 120, borderRadius: 60, }, - emptyIcon: { - fontSize: 48, - }, emptyText: { - textAlign: 'center', + textAlign: "center", marginBottom: 12, - fontWeight: '300', + fontWeight: "300", letterSpacing: 0.5, + fontSize: 24, }, emptySubtext: { - textAlign: 'center', - maxWidth: 250, + textAlign: "center", + maxWidth: 280, + fontSize: 16, + lineHeight: 24, }, -}); \ No newline at end of file +}); diff --git a/frontend/src/screens/NewMessageScreen.tsx b/frontend/src/screens/NewMessageScreen.tsx new file mode 100644 index 0000000..8fc1c52 --- /dev/null +++ b/frontend/src/screens/NewMessageScreen.tsx @@ -0,0 +1,570 @@ +import React, { useState, useEffect } from 'react'; +import { View, StyleSheet, FlatList, TouchableOpacity, Share, Alert } from 'react-native'; +import { Searchbar, Avatar, Text, useTheme, Headline, IconButton, Divider } 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'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { + FadeInDown, + FadeInUp, + useSharedValue, + useAnimatedStyle, + withTiming, + withSpring, + interpolate, + Easing, +} from 'react-native-reanimated'; +import { BackgroundDesign } from '../components/BackgroundDesign'; + +export const NewMessageScreen = ({ navigation }: any) => { + const [searchQuery, setSearchQuery] = useState(''); + const { user: currentUser } = useAuth(); + const theme = useTheme(); + + // Анимации + const headerScale = useSharedValue(0.95); + const headerOpacity = useSharedValue(0); + const listAnimation = useSharedValue(0); + + useEffect(() => { + // Анимация появления + headerScale.value = withSpring(1, { damping: 15, stiffness: 100 }); + headerOpacity.value = withTiming(1, { duration: 800 }); + listAnimation.value = withTiming(1, { duration: 600, easing: Easing.out(Easing.ease) }); + }, []); + + const { data, loading } = useQuery(GET_USERS); + + const [createConversation] = useMutation(CREATE_PRIVATE_CONVERSATION, { + onCompleted: (data) => { + navigation.navigate('Chat', { + conversationId: data.createPrivateConversation.id, + title: getOtherParticipantName(data.createPrivateConversation), + }); + }, + }); + + const getOtherParticipantName = (conversation: any) => { + const otherParticipant = conversation.participants?.find((p: User) => p.id !== currentUser?.id); + return otherParticipant?.username || otherParticipant?.email || 'Чат'; + }; + + const handleStartChat = async (userId: string) => { + try { + await createConversation({ + variables: { recipientId: userId }, + }); + } catch (error) { + console.error('Error creating conversation:', error); + } + }; + + const handleCreateGroup = () => { + Alert.alert('Новый групповой чат', 'Функция в разработке'); + }; + + const handleCreateChannel = () => { + Alert.alert('Новый канал', 'Функция в разработке'); + }; + + const handleInviteFriends = async () => { + try { + await Share.share({ + message: 'Присоединяйся ко мне в Prism Messenger! Скачай приложение и давай общаться.', + title: 'Приглашение в Prism', + }); + } catch (error) { + console.error('Error sharing:', 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 headerAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [{ scale: headerScale.value }], + opacity: headerOpacity.value, + }; + }); + + const listAnimatedStyle = useAnimatedStyle(() => { + const translateY = interpolate(listAnimation.value, [0, 1], [30, 0]); + return { + transform: [{ translateY }], + opacity: listAnimation.value, + }; + }); + + const renderUser = ({ item, index }: { item: User; index: number }) => { + return ( + + handleStartChat(item.id)} + > + + + + + + + {item.isOnline === true && ( + + + + )} + + + + + @{item.username || item.email?.split('@')[0] || item.id.slice(0, 8)} + + + {item.bio || item.email || 'Пользователь'} + + {item.isOnline === true && ( + + В сети + + )} + + + + + + + + ); + }; + + const getAvatarColor = (identifier: string) => { + const colors = ['#2196F3', '#4CAF50', '#FF5722', '#9C27B0', '#FF9800', '#00BCD4']; + const index = identifier.charCodeAt(0) % colors.length; + return colors[index]; + }; + + return ( + + {/* Заголовок */} + + + + + + navigation.goBack()} + style={styles.backButton} + activeOpacity={0.7} + > + + + НОВОЕ СООБЩЕНИЕ + + + + {/* Поисковая строка */} + + + + + {/* Опции создания */} + + + + + + + + + + Новый групповой чат + Создать группу до 256 участников + + + + + + + + + + + Новый канал + Для публичных объявлений + + + + + + + + + + + Пригласить в мессенджер + Поделиться приглашением + + + + + + {/* Список пользователей */} + + item.id} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + + + + + {searchQuery + ? `@${searchQuery} не найден` + : 'Загружаем пользователей...'} + + {searchQuery && ( + + Проверьте правильность никнейма + + )} + + } + /> + + + ); +}; + +const styles = StyleSheet.create({ + // Заголовок + headerContainer: { + paddingTop: 70, + paddingHorizontal: 20, + paddingBottom: 16, + }, + headerCard: { + borderRadius: 24, + padding: 16, + backgroundColor: 'rgba(26, 26, 26, 0.8)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 30, + elevation: 20, + }, + headerGradient: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 24, + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 12, + }, + backButton: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + }, + title: { + fontSize: 20, + fontWeight: '300', + letterSpacing: 1, + flex: 1, + textAlign: 'center', + }, + placeholder: { + width: 44, + }, + searchbar: { + borderRadius: 12, + paddingHorizontal: 16, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + elevation: 0, + }, + + // Опции + optionsContainer: { + paddingHorizontal: 20, + paddingBottom: 16, + }, + optionsCard: { + borderRadius: 20, + backgroundColor: 'rgba(26, 26, 26, 0.6)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.05, + shadowRadius: 15, + elevation: 5, + position: 'relative', + overflow: 'hidden', + }, + optionsGradient: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 20, + }, + option: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 16, + }, + optionIcon: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: 'rgba(33, 150, 243, 0.1)', + justifyContent: 'center', + alignItems: 'center', + marginRight: 16, + }, + optionContent: { + flex: 1, + }, + optionTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 2, + }, + optionSubtitle: { + fontSize: 14, + }, + optionDivider: { + marginHorizontal: 20, + height: 1, + }, + + // Список + listContainer: { + flex: 1, + paddingHorizontal: 20, + }, + listContent: { + flexGrow: 1, + paddingBottom: 100, + }, + + // Карточка пользователя + userCard: { + marginVertical: 6, + borderRadius: 20, + backgroundColor: 'rgba(26, 26, 26, 0.6)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.05, + shadowRadius: 15, + elevation: 5, + position: 'relative', + overflow: 'hidden', + }, + userGradient: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 20, + }, + userContent: { + flexDirection: 'row', + padding: 16, + alignItems: 'center', + }, + avatarContainer: { + position: 'relative', + marginRight: 16, + }, + avatar: { + borderWidth: 2, + borderColor: 'rgba(255, 255, 255, 0.2)', + }, + avatarLabel: { + fontSize: 20, + fontWeight: '600', + }, + onlineIndicator: { + position: 'absolute', + bottom: -2, + right: -2, + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 3, + borderColor: 'rgba(26, 26, 26, 0.8)', + justifyContent: 'center', + alignItems: 'center', + }, + onlineBadge: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: '#4CAF50', + }, + userInfo: { + flex: 1, + }, + username: { + fontWeight: '600', + fontSize: 16, + marginBottom: 4, + }, + userBio: { + fontSize: 14, + marginBottom: 4, + }, + onlineChip: { + alignSelf: 'flex-start', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 12, + backgroundColor: 'rgba(76, 175, 80, 0.15)', + borderWidth: 1, + borderColor: 'rgba(76, 175, 80, 0.3)', + }, + onlineChipText: { + fontSize: 11, + fontWeight: '500', + }, + + // Empty состояние + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 40, + paddingTop: 80, + }, + emptyIconContainer: { + width: 120, + height: 120, + borderRadius: 60, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 32, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + backgroundColor: 'rgba(26, 26, 26, 0.6)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 20, + elevation: 10, + position: 'relative', + overflow: 'hidden', + }, + emptyIconGradient: { + position: 'absolute', + width: 120, + height: 120, + borderRadius: 60, + }, + emptyText: { + textAlign: 'center', + marginBottom: 12, + fontWeight: '300', + letterSpacing: 0.5, + fontSize: 20, + }, + emptySubtext: { + textAlign: 'center', + maxWidth: 280, + fontSize: 16, + lineHeight: 24, + }, +}); \ No newline at end of file diff --git a/frontend/src/screens/ProfileScreen.tsx b/frontend/src/screens/ProfileScreen.tsx index f2919f2..5055edf 100644 --- a/frontend/src/screens/ProfileScreen.tsx +++ b/frontend/src/screens/ProfileScreen.tsx @@ -114,15 +114,15 @@ export const ProfileScreen = ({ navigation }: any) => { - @{user?.username} + @{user?.username || user?.email?.split('@')[0] || user?.id?.slice(0, 8)} - {user?.email} + {user?.email || 'Не указан'} {user?.bio && ( @@ -133,10 +133,10 @@ export const ProfileScreen = ({ navigation }: any) => { - - {user?.isOnline ? 'В сети' : 'Не в сети'} + + {user?.isOnline === true ? 'В сети' : 'Не в сети'} diff --git a/frontend/src/screens/UserInfoScreen.tsx b/frontend/src/screens/UserInfoScreen.tsx new file mode 100644 index 0000000..debbdb8 --- /dev/null +++ b/frontend/src/screens/UserInfoScreen.tsx @@ -0,0 +1,424 @@ +import React from 'react'; +import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; +import { Text, Avatar, Divider, useTheme } from 'react-native-paper'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { FadeInUp, FadeInDown } from 'react-native-reanimated'; +import { BackgroundDesign } from '../components/BackgroundDesign'; +import { format } from 'date-fns'; +import { ru } from 'date-fns/locale'; + +export const UserInfoScreen = ({ route, navigation }: any) => { + const { user } = route.params; + const theme = useTheme(); + + const formatJoinDate = (date: string | Date) => { + return format(new Date(date), 'MMMM yyyy', { locale: ru }); + }; + + return ( + + {/* Заголовок */} + + + + + + navigation.goBack()} + style={styles.backButton} + activeOpacity={0.7} + > + + + + + Информация + + + + + + + {/* Профиль пользователя */} + + + + + + + + {user?.isOnline === true && ( + + + + )} + + + + + @{user?.username || user?.email?.split('@')[0] || user?.id?.slice(0, 8)} + + + {user?.bio && ( + + {user.bio} + + )} + + + + + {user?.isOnline === true ? 'В сети' : 'Не в сети'} + + + + + + + + {/* Дополнительная информация */} + + + + + + + Детали + + + {user?.email && ( + <> + + + + + Email + + + {user.email} + + + + + + )} + + {user?.createdAt && ( + <> + + + + + Присоединился + + + {formatJoinDate(user.createdAt)} + + + + + + )} + + {user?.lastSeen && user?.isOnline !== true && ( + + + + + Последний раз в сети + + + {format(new Date(user.lastSeen), 'dd MMMM, HH:mm', { locale: ru })} + + + + )} + + + + + + ); +}; + +const styles = StyleSheet.create({ + // Заголовок + headerContainer: { + paddingTop: 60, + paddingHorizontal: 20, + paddingBottom: 16, + }, + headerCard: { + borderRadius: 20, + backgroundColor: 'rgba(26, 26, 26, 0.8)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 30, + elevation: 20, + overflow: 'hidden', + }, + headerGradient: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 20, + }, + headerContent: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 16, + paddingHorizontal: 16, + }, + backButton: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + marginRight: 16, + }, + headerTitle: { + fontWeight: '300', + letterSpacing: 1, + }, + + // Контент + scrollContainer: { + flex: 1, + paddingHorizontal: 20, + }, + + // Профиль + profileSection: { + marginBottom: 20, + }, + profileCard: { + borderRadius: 24, + backgroundColor: 'rgba(26, 26, 26, 0.8)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 30, + elevation: 20, + overflow: 'hidden', + }, + profileGradient: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 24, + }, + profileContent: { + padding: 24, + alignItems: 'center', + }, + avatarSection: { + position: 'relative', + marginBottom: 20, + }, + avatar: { + borderWidth: 3, + borderColor: 'rgba(255, 255, 255, 0.2)', + }, + avatarLabel: { + fontSize: 32, + fontWeight: '600', + }, + onlineIndicator: { + position: 'absolute', + bottom: 2, + right: 2, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(26, 26, 26, 0.9)', + borderWidth: 3, + borderColor: 'rgba(26, 26, 26, 0.9)', + justifyContent: 'center', + alignItems: 'center', + }, + onlineDot: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: '#4CAF50', + }, + userDetails: { + alignItems: 'center', + }, + userName: { + fontWeight: '600', + marginBottom: 8, + textAlign: 'center', + }, + userBio: { + textAlign: 'center', + marginBottom: 12, + lineHeight: 22, + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + }, + statusText: { + marginLeft: 6, + fontSize: 14, + fontWeight: '500', + }, + + // Информация + infoSection: { + marginBottom: 20, + }, + infoCard: { + borderRadius: 20, + backgroundColor: 'rgba(26, 26, 26, 0.6)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.05, + shadowRadius: 15, + elevation: 5, + overflow: 'hidden', + }, + infoGradient: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 20, + }, + infoContent: { + padding: 20, + }, + sectionTitle: { + fontWeight: '600', + marginBottom: 16, + letterSpacing: 0.5, + }, + infoRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + }, + infoTextContainer: { + marginLeft: 16, + flex: 1, + }, + infoLabel: { + fontSize: 12, + opacity: 0.8, + marginBottom: 2, + }, + infoValue: { + fontSize: 15, + fontWeight: '500', + }, + divider: { + height: 1, + marginVertical: 4, + opacity: 0.3, + }, +}); \ No newline at end of file diff --git a/frontend/src/services/apollo-client.ts b/frontend/src/services/apollo-client.ts index 2d9c2c0..de8d449 100644 --- a/frontend/src/services/apollo-client.ts +++ b/frontend/src/services/apollo-client.ts @@ -1,7 +1,10 @@ -import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink } from '@apollo/client'; +import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink, split } from '@apollo/client'; import { setContext } from '@apollo/client/link/context'; +import { getMainDefinition } from '@apollo/client/utilities'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { createClient } from 'graphql-ws'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { API_URL } from '../config/api'; +import { API_URL, DEV_SERVER_IP } from '../config/api'; const httpLink = createHttpLink({ uri: API_URL, @@ -17,6 +20,41 @@ const authLink = setContext(async (_, { headers }) => { }; }); +// WebSocket линк для подписок +const wsLink = new GraphQLWsLink(createClient({ + url: API_URL.replace('http://', 'ws://'), + connectionParams: () => { + return new Promise(async (resolve) => { + try { + const token = await AsyncStorage.getItem('token'); + resolve({ + authorization: token ? `Bearer ${token}` : '', + }); + } catch (error) { + console.error('Error getting token for WebSocket:', error); + resolve({ + authorization: '', + }); + } + }); + }, + retryAttempts: 5, + retryWait: (attempt) => Math.min(1000 * Math.pow(2, attempt), 10000), +})); + +// Временно используем только HTTP линк, пока не настроим WebSocket +// const splitLink = split( +// ({ query }) => { +// const definition = getMainDefinition(query); +// return ( +// definition.kind === 'OperationDefinition' && +// definition.operation === 'subscription' +// ); +// }, +// wsLink, +// ApolloLink.from([authLink, httpLink]) +// ); + export const apolloClient = new ApolloClient({ link: ApolloLink.from([authLink, httpLink]), cache: new InMemoryCache(), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 018e7e5..5cd4391 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,10 +1,10 @@ export interface User { id: string; - username: string; - email: string; + username?: string; + email?: string; avatar?: string; bio?: string; - isOnline: boolean; + isOnline?: boolean; lastSeen?: Date; createdAt: Date; updatedAt: Date;