Compare commits

..

5 Commits

Author SHA1 Message Date
bd731f1f57 Add comprehensive messenger features and improvements
- Backend improvements: flexible user authentication, nullable fields
- Frontend enhancements: new screens (NewMessage, UserInfo), improved UI/UX
- Chat functionality: real-time messaging with polling, message actions
- Visual upgrades: gradient backgrounds, better themes, modern design
- Added project documentation (CLAUDE.md) and development guidelines

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 21:42:36 +03:00
5a3fdc01f8 Fix Git merge conflict in LoginScreen.tsx - combined beautiful design with theme support 2025-08-06 11:31:15 +03:00
1eea449a30 Fix Git merge conflict in ConversationsScreen.tsx - combined both versions 2025-08-06 11:29:25 +03:00
1441440247 Add expo-linear-gradient dependency 2025-08-06 11:25:40 +03:00
3f2e8e96f5 Resolve merge conflict in App.tsx - use imported theme 2025-08-06 11:22:16 +03:00
19 changed files with 3127 additions and 945 deletions

92
CLAUDE.md Normal file
View File

@ -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

View File

@ -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();

View File

@ -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,

View File

@ -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 })

View File

@ -12,9 +12,13 @@ export class UsersService {
) {}
async create(username: string, email: string, password: string): Promise<User> {
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<User | null> {
if (!username) return null;
return this.usersRepository.findOne({ where: { username } });
}
async findByEmail(email: string): Promise<User | null> {
if (!email) return null;
return this.usersRepository.findOne({ where: { email } });
}

View File

@ -1,65 +1,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 } 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 = {
...MD3DarkTheme,
colors: {
...MD3DarkTheme.colors,
primary: '#ffffff',
secondary: '#b3b3b3',
tertiary: '#808080',
background: '#0a0a0a',
surface: '#1a1a1a',
surfaceVariant: '#2d2d2d',
onSurface: '#ffffff',
onSurfaceVariant: '#e5e5e5',
onPrimary: '#000000',
elevation: {
level0: 'transparent',
level1: '#1a1a1a',
level2: '#242424',
level3: '#2e2e2e',
level4: '#383838',
level5: '#424242',
},
outline: '#666666',
outlineVariant: '#4d4d4d',
error: '#ff6b6b',
inverseSurface: '#e6e6e6',
inverseOnSurface: '#1a1a1a',
inversePrimary: '#000000',
backdrop: 'rgba(0, 0, 0, 0.8)',
notification: '#ffffff',
card: '#1a1a1a',
text: '#ffffff',
border: '#333333',
placeholder: '#808080',
},
roundness: 16,
fonts: configureFonts({
customVariant: {
fontFamily: Platform.select({
ios: 'System',
android: 'Roboto',
default: 'sans-serif',
}),
fontWeight: '500',
letterSpacing: 0.5,
},
}),
};
=======
import { theme } from './src/theme';
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
export default function App() {
return (
@ -74,4 +21,4 @@ export default function App() {
</ApolloProvider>
</SafeAreaProvider>
);
}
}

View File

@ -16,9 +16,10 @@
"@react-navigation/native-stack": "^7.3.24",
"date-fns": "^4.1.0",
"expo": "~53.0.20",
"expo-linear-gradient": "^14.1.5",
"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",

View File

@ -17,9 +17,10 @@
"@react-navigation/native-stack": "^7.3.24",
"date-fns": "^4.1.0",
"expo": "~53.0.20",
"expo-linear-gradient": "^14.1.5",
"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",

View File

@ -17,4 +17,15 @@ const getApiUrl = () => {
}
};
export const API_URL = getApiUrl();
// Для физических устройств в сети используйте 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();

View File

@ -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,
}}
/>
<ChatsStack.Screen
name="Chat"
component={ChatScreen}
options={({ route }) => ({
title: route.params?.title || 'Чат',
})}
options={{
headerShown: false,
}}
/>
<ChatsStack.Screen
name="NewMessage"
component={NewMessageScreen}
options={{
headerShown: false,
}}
/>
<ChatsStack.Screen
name="UserInfo"
component={UserInfoScreen}
options={{
headerShown: false,
}}
/>
</ChatsStack.Navigator>
);
@ -48,8 +63,7 @@ function ContactsStackNavigator() {
name="ContactsList"
component={ContactsScreen}
options={{
title: 'Контакты',
headerLargeTitle: true,
headerShown: false,
}}
/>
</ContactsStack.Navigator>

View File

@ -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<string | null>(null);
const [menuVisible, setMenuVisible] = useState(false);
const [chatMenuVisible, setChatMenuVisible] = useState(false);
const flatListRef = useRef<FlatList>(null);
const textInputRef = useRef<TextInput>(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 (
<Animated.View
entering={isOwnMessage ? SlideInRight.delay(index * 50) : SlideInLeft.delay(index * 50)}
entering={isOwnMessage ? SlideInRight.delay(index * 30) : SlideInLeft.delay(index * 30)}
exiting={FadeOutDown}
layout={Layout.springify()}
style={[styles.messageContainer, isOwnMessage && styles.ownMessageContainer]}
>
{/* Аватар для входящих сообщений */}
{!isOwnMessage && (
<Avatar.Text
size={40}
label={item.sender.username.charAt(0).toUpperCase()}
style={styles.avatar}
labelStyle={styles.avatarLabel}
theme={{
colors: {
primary: '#2d2d2d',
<View style={styles.messageAvatarContainer}>
<Avatar.Text
size={36}
label={(item.sender.username || item.sender.email || item.sender.id).charAt(0).toUpperCase()}
style={[styles.messageAvatar, { backgroundColor: theme.colors.surfaceVariant }]}
labelStyle={[styles.messageAvatarLabel, { color: theme.colors.onSurfaceVariant }]}
/>
</View>
)}
{/* Контейнер сообщения */}
<View style={[styles.messageBubbleContainer, isOwnMessage && styles.ownMessageBubbleContainer]}>
<TouchableOpacity
activeOpacity={0.9}
onLongPress={() => {
if (isOwnMessage) {
setSelectedMessage(item.id);
setMenuVisible(true);
}
}}
/>
)}
<View style={[styles.messageBubbleContainer, isOwnMessage && styles.ownMessageBubbleContainer]}>
{isOwnMessage ? (
<LinearGradient
colors={['#ffffff', '#f0f0f0']}
style={styles.messageBubble}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={[styles.messageText, styles.ownMessageText]}>
>
<View style={[
styles.messageBubble,
isOwnMessage ? styles.ownMessageBubble : styles.incomingMessageBubble
]}>
<LinearGradient
colors={isOwnMessage
? [theme.colors.primary, theme.colors.primaryContainer]
: ['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']
}
style={styles.messageBubbleGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Имя отправителя для входящих сообщений */}
{!isOwnMessage && (
<Text
variant="labelSmall"
style={[styles.senderName, { color: theme.colors.primary }]}
>
{item.sender.username || item.sender.email?.split('@')[0] || 'Пользователь'}
</Text>
)}
{/* Текст сообщения */}
<Text
style={[
styles.messageText,
{
color: isOwnMessage
? theme.colors.onPrimary
: theme.colors.onSurface
}
]}
>
{item.content}
</Text>
<Text variant="bodySmall" style={[styles.messageTime, styles.ownMessageTime]}>
{messageTime}
{item.isEdited && ' • изменено'}
</Text>
</LinearGradient>
) : (
<Surface style={[styles.messageBubble, styles.otherMessageBubble]} elevation={1}>
<Text variant="labelSmall" style={styles.senderName}>
{item.sender.username}
</Text>
<Text style={styles.messageText}>
{item.content}
</Text>
<Text variant="bodySmall" style={styles.messageTime}>
{messageTime}
{item.isEdited && ' • изменено'}
</Text>
</Surface>
)}
{/* Время и статус */}
<View style={styles.messageFooter}>
<Text
variant="bodySmall"
style={[
styles.messageTime,
{
color: isOwnMessage
? 'rgba(255,255,255,0.7)'
: theme.colors.onSurfaceVariant
}
]}
>
{messageTime}
{item.isEdited && ' • изм.'}
</Text>
{/* Статус прочтения для исходящих */}
{isOwnMessage && (
<MaterialCommunityIcons
name={item.isRead ? "check-all" : "check"}
size={16}
color={item.isRead ? theme.colors.primary : 'rgba(255,255,255,0.5)'}
style={styles.readStatus}
/>
)}
</View>
</View>
</TouchableOpacity>
{/* Меню для собственных сообщений */}
{isOwnMessage && (
<Menu
visible={menuVisible && selectedMessage === item.id}
@ -162,27 +258,23 @@ export const ChatScreen = ({ route }: any) => {
setMenuVisible(false);
setSelectedMessage(null);
}}
anchor={
<TouchableOpacity
onPress={() => {
setSelectedMessage(item.id);
setMenuVisible(true);
}}
style={styles.menuButton}
>
<IconButton
icon="dots-vertical"
size={20}
iconColor="#666666"
/>
</TouchableOpacity>
}
contentStyle={styles.menuContent}
anchor={<View />}
contentStyle={[styles.messageMenuContent, { backgroundColor: theme.colors.surface }]}
>
<Menu.Item
onPress={() => handleDeleteMessage(item.id)}
title="Удалить"
titleStyle={styles.menuItemText}
leadingIcon="delete"
titleStyle={{ color: theme.colors.error }}
/>
<Menu.Item
onPress={() => {
setMenuVisible(false);
setSelectedMessage(null);
}}
title="Редактировать"
leadingIcon="pencil"
titleStyle={{ color: theme.colors.onSurface }}
/>
</Menu>
)}
@ -200,15 +292,138 @@ export const ChatScreen = ({ route }: any) => {
}
return (
<View style={styles.container}>
<LinearGradient
colors={['#0a0a0a', '#1a1a1a']}
style={StyleSheet.absoluteFillObject}
/>
<BackgroundDesign variant="chat">
{/* Компактный заголовок */}
<Animated.View
style={styles.headerContainer}
entering={FadeInUp.duration(600)}
>
<View style={styles.headerCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.headerContent}>
{/* Кнопка назад */}
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="arrow-left"
size={24}
color={theme.colors.onSurface}
/>
</TouchableOpacity>
{/* Информация о пользователе */}
<TouchableOpacity
style={styles.userInfo}
activeOpacity={0.7}
onPress={() => navigation.navigate('UserInfo', { user: otherUser })}
>
<View style={styles.avatarContainer}>
<Avatar.Text
size={40}
label={(title || 'U').charAt(0).toUpperCase()}
style={[styles.headerAvatar, { backgroundColor: theme.colors.primaryContainer }]}
labelStyle={[styles.avatarLabel, { color: theme.colors.onPrimaryContainer }]}
/>
{/* Статус онлайн */}
<View style={styles.onlineIndicator}>
<View style={styles.onlineDot} />
</View>
</View>
<View style={styles.userDetails}>
<Text
variant="titleMedium"
style={[styles.userName, { color: theme.colors.onSurface }]}
numberOfLines={1}
>
{title || 'Пользователь'}
</Text>
<Text
variant="bodySmall"
style={[styles.userStatus, { color: theme.colors.onSurfaceVariant }]}
>
в сети
</Text>
</View>
</TouchableOpacity>
{/* Меню действий */}
<Menu
visible={chatMenuVisible}
onDismiss={() => setChatMenuVisible(false)}
anchor={
<TouchableOpacity
style={styles.menuButton}
onPress={() => setChatMenuVisible(true)}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="dots-vertical"
size={24}
color={theme.colors.onSurface}
/>
</TouchableOpacity>
}
>
<Menu.Item
onPress={() => {
setChatMenuVisible(false);
// TODO: Открыть поиск по чату
}}
title="Поиск по чату"
leadingIcon="magnify"
/>
<Menu.Item
onPress={() => {
setChatMenuVisible(false);
// TODO: Отметить как непрочитанное
}}
title="Не прочитано"
leadingIcon="message-badge"
/>
<Menu.Item
onPress={() => {
setChatMenuVisible(false);
// TODO: Информация о чате
}}
title="Информация о чате"
leadingIcon="information"
/>
<Menu.Item
onPress={() => {
setChatMenuVisible(false);
// TODO: Отключить уведомления
}}
title="Без уведомлений"
leadingIcon="bell-off"
/>
<Menu.Item
onPress={() => {
setChatMenuVisible(false);
// TODO: Очистить историю
}}
title="Очистить историю"
leadingIcon="delete-sweep"
/>
</Menu>
</View>
</View>
</Animated.View>
{/* Сообщения */}
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={90}
keyboardVerticalOffset={100}
>
<FlatList
ref={flatListRef}
@ -216,46 +431,136 @@ export const ChatScreen = ({ route }: any) => {
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,
}}
/>
<View style={styles.inputContainer}>
<LinearGradient
colors={['rgba(26, 26, 26, 0.95)', 'rgba(26, 26, 26, 0.98)']}
style={styles.inputGradient}
/>
{/* Поле ввода */}
<Animated.View
style={styles.inputContainer}
entering={FadeInUp.delay(400).duration(600)}
>
{/* Основной контейнер ввода */}
<View style={styles.inputWrapper}>
<TextInput
value={message}
onChangeText={setMessage}
placeholder="Введите сообщение..."
placeholderTextColor="#666666"
mode="outlined"
style={styles.input}
multiline
maxLength={1000}
theme={{
colors: {
primary: '#ffffff',
placeholder: '#666666',
text: '#ffffff',
background: 'rgba(255, 255, 255, 0.05)',
outline: '#333333',
}
}}
right={
<TextInput.Icon
icon="send"
onPress={handleSend}
disabled={!message.trim()}
color={message.trim() ? '#ffffff' : '#666666'}
/>
}
<LinearGradient
colors={['rgba(26, 26, 26, 0.95)', 'rgba(26, 26, 26, 0.98)']}
style={styles.inputWrapperGradient}
/>
{/* Левые кнопки */}
<View style={styles.leftButtons}>
<TouchableOpacity
style={styles.actionButton}
activeOpacity={0.7}
onPress={() => {
// TODO: Прикрепить файл
}}
>
<MaterialCommunityIcons
name="paperclip"
size={22}
color={theme.colors.onSurfaceVariant}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionButton}
activeOpacity={0.7}
onPress={() => {
// TODO: Камера
}}
>
<MaterialCommunityIcons
name="camera"
size={22}
color={theme.colors.onSurfaceVariant}
/>
</TouchableOpacity>
</View>
{/* Поле ввода */}
<View style={styles.textInputWrapper}>
<TextInput
ref={textInputRef}
value={message}
onChangeText={setMessage}
placeholder="Сообщение..."
placeholderTextColor="rgba(255,255,255,0.5)"
style={styles.messageInput}
multiline
maxLength={1000}
onSubmitEditing={() => {
if (message.trim()) {
handleSend();
}
}}
blurOnSubmit={false}
/>
</View>
{/* Правые кнопки */}
<View style={styles.rightButtons}>
{message.trim() ? (
<TouchableOpacity
style={styles.sendButtonContainer}
activeOpacity={0.8}
onPress={handleSend}
>
<LinearGradient
colors={[theme.colors.primary, theme.colors.primaryContainer]}
style={styles.sendButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<MaterialCommunityIcons
name="send"
size={20}
color={theme.colors.onPrimary}
/>
</LinearGradient>
</TouchableOpacity>
) : (
<>
<TouchableOpacity
style={styles.actionButton}
activeOpacity={0.7}
onPress={() => {
// TODO: Голосовое сообщение
}}
>
<MaterialCommunityIcons
name="microphone"
size={22}
color={theme.colors.onSurfaceVariant}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionButton}
activeOpacity={0.7}
onPress={() => {
// TODO: Эмодзи
}}
>
<MaterialCommunityIcons
name="emoticon-outline"
size={22}
color={theme.colors.onSurfaceVariant}
/>
</TouchableOpacity>
</>
)}
</View>
</View>
</View>
</Animated.View>
</KeyboardAvoidingView>
</View>
</BackgroundDesign>
);
};
@ -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,
},
});

View File

@ -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<string | null>(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 (
<Animated.View
style={{
opacity: fadeAnim,
transform: [{
translateY: fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: [50, 0],
}),
}],
}}
<Animated.View
entering={FadeInDown.delay(index * 50).springify()}
>
<TouchableOpacity
activeOpacity={0.7}
activeOpacity={0.8}
onPress={() => handleStartChat(item.id)}
disabled={isCreatingChat}
>
<View style={[styles.userCard, styles.userCardShadow]}>
<View style={styles.userCard}>
<LinearGradient
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']}
style={styles.userGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.userContent}>
<View style={styles.avatarContainer}>
<Avatar.Text
size={56}
label={item.username.charAt(0).toUpperCase()}
style={[styles.avatar, { backgroundColor: getAvatarColor(item.username) }]}
<LinearGradient
colors={['rgba(255,255,255,0.1)', 'rgba(255,255,255,0.05)']}
style={styles.avatarGradient}
/>
{item.isOnline && (
<Avatar.Text
size={60}
label={(item.username || item.email || item.id).charAt(0).toUpperCase()}
style={[styles.avatar, { backgroundColor: getAvatarColor(item.username || item.email || item.id) }]}
labelStyle={[styles.avatarLabel, { color: '#ffffff' }]}
/>
{item.isOnline === true && (
<View style={styles.onlineIndicator}>
<MaterialCommunityIcons name="circle" size={14} color="#4CAF50" />
<View style={styles.onlineBadge} />
</View>
)}
</View>
<View style={styles.userInfo}>
<View style={styles.userHeader}>
<Text variant="titleMedium" style={styles.username}>
@{item.username}
<Text variant="titleMedium" style={[styles.username, { color: theme.colors.onSurface }]}>
@{item.username || item.email?.split('@')[0] || item.id.slice(0, 8)}
</Text>
{item.isOnline && (
<Chip
mode="flat"
compact
style={styles.onlineChip}
textStyle={styles.onlineChipText}
>
В сети
</Chip>
{item.isOnline === true && (
<View style={styles.onlineChip}>
<Text style={[styles.onlineChipText, { color: '#4CAF50' }]}>В сети</Text>
</View>
)}
</View>
<Text variant="bodyMedium" style={styles.userBio} numberOfLines={2}>
{item.bio || `${item.email}`}
<Text variant="bodyMedium" style={[styles.userBio, { color: theme.colors.onSurfaceVariant }]} numberOfLines={2}>
{item.bio || item.email || 'Пользователь'}
</Text>
{!item.isOnline && item.lastSeen && (
<Text variant="bodySmall" style={styles.lastSeen}>
{item.isOnline !== true && item.lastSeen && (
<Text variant="bodySmall" style={[styles.lastSeen, { color: theme.colors.onSurfaceVariant }]}>
Был(а) {formatLastSeen(item.lastSeen)}
</Text>
)}
@ -133,15 +182,28 @@ export const ContactsScreen = ({ navigation }: any) => {
<View style={styles.actionContainer}>
{isCreatingChat ? (
<ActivityIndicator size="small" color="#2196F3" />
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.primary} />
</View>
) : (
<IconButton
icon="message-plus-outline"
size={28}
iconColor="#2196F3"
style={styles.messageButton}
<TouchableOpacity
onPress={() => handleStartChat(item.id)}
/>
style={styles.messageButton}
activeOpacity={0.7}
>
<LinearGradient
colors={[theme.colors.primary, theme.colors.primaryContainer]}
style={styles.messageButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<MaterialCommunityIcons
name="message-plus-outline"
size={20}
color={theme.colors.onPrimary}
/>
</LinearGradient>
</TouchableOpacity>
)}
</View>
</View>
@ -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 (
<View style={styles.container}>
{/* Заголовок с поиском */}
<View style={[styles.header, styles.headerShadow]}>
<View style={styles.searchContainer}>
<BackgroundDesign variant="chat">
{/* Заголовок */}
<Animated.View
style={[styles.headerContainer, headerAnimatedStyle]}
entering={FadeInUp.delay(200).duration(600)}
>
<View style={styles.headerCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.headerContent}>
<View style={styles.logoContainer}>
<View style={[styles.logoPlaceholder, { backgroundColor: theme.colors.surfaceVariant }]}>
<MaterialCommunityIcons name="account-group" size={32} color={theme.colors.onSurface} />
</View>
</View>
<Headline style={[styles.title, { color: theme.colors.onSurface }]}>КОНТАКТЫ</Headline>
</View>
{/* Поисковая строка */}
<Searchbar
placeholder="Поиск по @username..."
onChangeText={setSearchQuery}
value={searchQuery}
style={styles.searchbar}
icon="account-search"
elevation={0}
style={[styles.searchbar, { backgroundColor: theme.colors.surfaceVariant }]}
inputStyle={{ color: theme.colors.onSurface }}
placeholderTextColor={theme.colors.onSurfaceVariant}
iconColor={theme.colors.onSurfaceVariant}
theme={{
colors: {
primary: theme.colors.primary,
text: theme.colors.onSurface,
placeholder: theme.colors.onSurfaceVariant,
}
}}
/>
<Text variant="bodySmall" style={styles.searchHint}>
Введите никнейм для поиска пользователя
</Text>
</View>
{/* Статистика */}
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text variant="headlineSmall" style={styles.statNumber}>{totalUsersCount}</Text>
<Text variant="bodySmall" style={styles.statLabel}>пользователей</Text>
</View>
<View style={styles.statDivider} />
<View style={styles.statItem}>
<View style={styles.onlineStatContainer}>
<MaterialCommunityIcons name="circle" size={10} color="#4CAF50" style={{ marginRight: 4 }} />
<Text variant="headlineSmall" style={[styles.statNumber, { color: '#4CAF50' }]}>{onlineUsersCount}</Text>
</View>
<Text variant="bodySmall" style={styles.statLabel}>в сети</Text>
</View>
</View>
</View>
<SegmentedButtons
value={selectedTab}
onValueChange={setSelectedTab}
buttons={[
{
value: 'all',
label: 'Все',
icon: 'account-group',
},
{
value: 'online',
label: 'Онлайн',
icon: 'circle',
},
{
value: 'contacts',
label: 'Контакты',
icon: 'account-star',
},
]}
style={styles.tabs}
/>
<FlatList
data={filteredUsers.filter((user: User) => {
if (selectedTab === 'online') return user.isOnline;
// В будущем добавим логику для "Мои контакты"
return true;
})}
renderItem={renderUser}
keyExtractor={(item) => item.id}
onRefresh={refetch}
refreshing={loading}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name={searchQuery ? 'account-search' : 'account-group-outline'}
size={64}
color="#ccc"
/>
<Text variant="titleMedium" style={styles.emptyText}>
{searchQuery
? `Пользователь @${searchQuery} не найден`
: selectedTab === 'online'
? 'Нет пользователей онлайн'
: 'Нет доступных пользователей'}
</Text>
{searchQuery && (
<Text variant="bodyMedium" style={[styles.emptyText, { marginTop: 8 }]}>
Проверьте правильность никнейма
{/* Статистика */}
<Animated.View style={[styles.statsContainer, statsAnimatedStyle]}>
<View style={styles.statItem}>
<Text variant="headlineSmall" style={[styles.statNumber, { color: theme.colors.onSurface }]}>
{totalUsersCount}
</Text>
)}
</View>
}
/>
</View>
<Text variant="bodySmall" style={[styles.statLabel, { color: theme.colors.onSurfaceVariant }]}>
пользователей
</Text>
</View>
<View style={[styles.statDivider, { backgroundColor: theme.colors.outlineVariant }]} />
<View style={styles.statItem}>
<View style={styles.onlineStatContainer}>
<MaterialCommunityIcons name="circle" size={10} color="#4CAF50" style={{ marginRight: 4 }} />
<Text variant="headlineSmall" style={[styles.statNumber, { color: '#4CAF50' }]}>
{onlineUsersCount}
</Text>
</View>
<Text variant="bodySmall" style={[styles.statLabel, { color: theme.colors.onSurfaceVariant }]}>
в сети
</Text>
</View>
</Animated.View>
</View>
</Animated.View>
{/* Табы */}
<Animated.View style={[styles.tabsContainer, listAnimatedStyle]}>
<SegmentedButtons
value={selectedTab}
onValueChange={setSelectedTab}
buttons={[
{
value: 'all',
label: 'Все',
icon: 'account-group',
},
{
value: 'online',
label: 'Онлайн',
icon: 'circle',
},
{
value: 'contacts',
label: 'Контакты',
icon: 'account-star',
},
]}
style={[styles.tabs, { backgroundColor: theme.colors.surfaceVariant }]}
theme={{
colors: {
primary: theme.colors.primary,
onSurface: theme.colors.onSurface,
surface: theme.colors.surface,
}
}}
/>
</Animated.View>
{/* Список пользователей */}
<Animated.View style={[styles.listContainer, listAnimatedStyle]}>
<FlatList
data={filteredUsers.filter((user: User) => {
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={
<Animated.View
style={styles.emptyContainer}
entering={FadeInDown.duration(600)}
>
<View style={styles.emptyIconContainer}>
<LinearGradient
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']}
style={styles.emptyIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<MaterialCommunityIcons
name={searchQuery ? 'account-search' : 'account-group-outline'}
size={48}
color={theme.colors.onSurfaceVariant}
/>
</View>
<Text variant="headlineSmall" style={[styles.emptyText, { color: theme.colors.onSurface }]}>
{searchQuery
? `@${searchQuery} не найден`
: selectedTab === 'online'
? 'Нет пользователей онлайн'
: 'Нет доступных пользователей'}
</Text>
{searchQuery && (
<Text variant="bodyLarge" style={[styles.emptySubtext, { color: theme.colors.onSurfaceVariant }]}>
Проверьте правильность никнейма
</Text>
)}
</Animated.View>
}
/>
</Animated.View>
</BackgroundDesign>
);
};
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,
},
});

View File

@ -1,145 +1,268 @@
<<<<<<< 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 { 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';
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');
=======
import { useAuth } from '../contexts/AuthContext';
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
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<UserStatus>("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,
});
<<<<<<< HEAD
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 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 filteredConversations =
data?.conversations?.filter((conv: Conversation) => {
if (!searchQuery) return true;
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
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 (
<Animated.View
entering={FadeInDown.delay(index * 50).springify()}
layout={Layout.springify()}
>
<Animated.View entering={FadeInDown.delay(index * 50).springify()}>
<TouchableOpacity
onPress={() => navigation.navigate('Chat', { conversationId: item.id, title: displayName })}
activeOpacity={0.7}
onPress={() =>
navigation.navigate("Chat", {
conversationId: item.id,
title: displayName,
})
}
activeOpacity={0.8}
>
<Surface style={styles.conversationItem} elevation={0}>
<View style={[styles.conversationItem]}>
<LinearGradient
colors={["rgba(255,255,255,0.03)", "rgba(255,255,255,0.08)"]}
style={styles.conversationGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.avatarContainer}>
<LinearGradient
colors={['rgba(255,255,255,0.1)', 'rgba(255,255,255,0.05)']}
colors={["rgba(255,255,255,0.1)", "rgba(255,255,255,0.05)"]}
style={styles.avatarGradient}
/>
<Avatar.Text
size={52}
label={displayName?.charAt(0).toUpperCase() || '?'}
style={styles.avatar}
labelStyle={styles.avatarLabel}
theme={{
colors: {
primary: '#2d2d2d',
}
}}
<Avatar.Text
size={56}
label={displayName?.charAt(0).toUpperCase() || "?"}
style={[
styles.avatar,
{ backgroundColor: theme.colors.surfaceVariant },
]}
labelStyle={[
styles.avatarLabel,
{ color: theme.colors.onSurface },
]}
/>
{otherParticipant?.isOnline && (
<Badge style={styles.onlineBadge} size={14} />
{otherParticipant?.isOnline === true && (
<Badge style={styles.onlineBadge} size={16} />
)}
</View>
<<<<<<< HEAD
<View style={styles.contentContainer}>
<View style={styles.headerRow}>
<Text
variant="titleMedium"
style={styles.conversationTitle}
<Text
variant="titleMedium"
style={[
styles.conversationTitle,
{ color: theme.colors.onSurface },
]}
numberOfLines={1}
>
{displayName || 'Без имени'}
{displayName || "Без имени"}
</Text>
<Text variant="bodySmall" style={styles.time}>
<Text
variant="bodySmall"
style={[
styles.time,
{ color: theme.colors.onSurfaceVariant },
]}
>
{lastMessageTime}
</Text>
</View>
<View style={styles.messageRow}>
<Text
variant="bodyMedium"
style={styles.lastMessage}
<Text
variant="bodyMedium"
style={[
styles.lastMessage,
{ color: theme.colors.onSurfaceVariant },
]}
numberOfLines={2}
>
{item.lastMessage?.content || 'Нет сообщений'}
{item.lastMessage?.content || "Нет сообщений"}
</Text>
{/* Здесь можно добавить счетчик непрочитанных */}
{unreadCount > 0 && (
<Badge style={styles.unreadBadge}>{unreadCount}</Badge>
)}
</View>
=======
)}
right={() => (
<View style={styles.rightContent}>
<Text variant="bodySmall" style={styles.time}>
{lastMessageTime}
</Text>
{unreadCount > 0 && (
<Badge style={styles.unreadBadge}>{unreadCount}</Badge>
)}
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
</View>
</Surface>
</View>
</TouchableOpacity>
</Animated.View>
);
@ -147,303 +270,626 @@ export const ConversationsScreen = ({ navigation }: any) => {
if (loading && !data) {
return (
<View style={styles.loadingContainer}>
<LinearGradient
colors={['#0a0a0a', '#1a1a1a']}
style={StyleSheet.absoluteFillObject}
/>
<Text style={styles.loadingText}>Загрузка чатов...</Text>
</View>
<BackgroundDesign variant="chat">
<View style={styles.loadingContainer}>
<Animated.View
style={[styles.loadingCard, headerAnimatedStyle]}
entering={FadeInDown.duration(800)}
>
<LinearGradient
colors={["rgba(255,255,255,0.02)", "rgba(255,255,255,0.05)"]}
style={styles.loadingGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text
style={[styles.loadingText, { color: theme.colors.onSurface }]}
>
Загрузка чатов...
</Text>
</Animated.View>
</View>
</BackgroundDesign>
);
}
if (error) {
return (
<View style={styles.errorContainer}>
<LinearGradient
colors={['#0a0a0a', '#1a1a1a']}
style={StyleSheet.absoluteFillObject}
/>
<Text style={styles.errorText}>Ошибка загрузки чатов</Text>
<TouchableOpacity onPress={() => refetch()} style={styles.retryButton}>
<Text style={styles.retryText}>Попробовать снова</Text>
</TouchableOpacity>
</View>
<BackgroundDesign variant="chat">
<View style={styles.errorContainer}>
<Animated.View
style={[styles.errorCard, headerAnimatedStyle]}
entering={FadeInDown.duration(800)}
>
<LinearGradient
colors={["rgba(255,255,255,0.02)", "rgba(255,255,255,0.05)"]}
style={styles.errorGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={[styles.errorText, { color: theme.colors.error }]}>
Ошибка загрузки чатов
</Text>
<TouchableOpacity
onPress={() => refetch()}
style={[styles.retryButton]}
>
<LinearGradient
colors={[theme.colors.primary, theme.colors.primaryContainer]}
style={styles.retryGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text
style={[styles.retryText, { color: theme.colors.onPrimary }]}
>
Попробовать снова
</Text>
</LinearGradient>
</TouchableOpacity>
</Animated.View>
</View>
</BackgroundDesign>
);
}
return (
<<<<<<< HEAD
<View style={styles.container}>
<LinearGradient
colors={['#0a0a0a', '#1a1a1a']}
style={StyleSheet.absoluteFillObject}
/>
=======
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
{/* Поисковая строка */}
<View style={[styles.searchContainer, { backgroundColor: theme.colors.surface }]}>
<Searchbar
placeholder="Поиск чатов..."
onChangeText={setSearchQuery}
value={searchQuery}
style={[styles.searchbar, { backgroundColor: theme.colors.surfaceVariant }]}
icon="magnify"
<BackgroundDesign variant="chat">
{/* Заголовок */}
<Animated.View
style={[styles.headerContainer, headerAnimatedStyle]}
entering={FadeInUp.delay(200).duration(600)}
>
<View style={[styles.headerCard]}>
<LinearGradient
colors={["rgba(255,255,255,0.02)", "rgba(255,255,255,0.05)"]}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Верхняя строка с статусом и поиском */}
<View style={styles.topRow}>
{/* Статус пользователя */}
<Menu
visible={statusMenuVisible}
onDismiss={() => setStatusMenuVisible(false)}
anchor={
<TouchableOpacity
style={styles.statusButton}
onPress={() => setStatusMenuVisible(true)}
activeOpacity={0.7}
>
<View
style={[
styles.statusIndicator,
{ backgroundColor: getStatusInfo(userStatus).color },
]}
/>
<Text
style={[
styles.statusText,
{ color: theme.colors.onSurface },
]}
>
{getStatusInfo(userStatus).label}
</Text>
<Text style={styles.statusIcon}></Text>
</TouchableOpacity>
}
>
{(["online", "away", "busy", "invisible"] as UserStatus[]).map(
(status) => (
<Menu.Item
key={status}
onPress={() => handleStatusChange(status)}
title={getStatusInfo(status).label}
leadingIcon={getStatusInfo(status).icon}
titleStyle={{ color: getStatusInfo(status).color }}
/>
)
)}
</Menu>
{/* Кнопка поиска */}
<TouchableOpacity
style={[
styles.searchToggle,
isSearchMode && styles.searchToggleActive,
]}
onPress={toggleSearchMode}
activeOpacity={0.7}
>
<LinearGradient
colors={
isSearchMode
? [theme.colors.primary, theme.colors.primaryContainer]
: ["rgba(255,255,255,0.1)", "rgba(255,255,255,0.05)"]
}
style={styles.searchToggleGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<MaterialCommunityIcons
name="magnify"
size={20}
color={
isSearchMode
? theme.colors.onPrimary
: theme.colors.onSurface
}
/>
</LinearGradient>
</TouchableOpacity>
</View>
{/* Поисковая строка (появляется при активации поиска) */}
<Animated.View style={[styles.searchContainer, searchAnimatedStyle]}>
<Searchbar
placeholder="Поиск в чатах..."
onChangeText={setSearchQuery}
value={searchQuery}
style={[
styles.searchbar,
{ backgroundColor: theme.colors.surfaceVariant },
]}
inputStyle={{ color: theme.colors.onSurface }}
placeholderTextColor={theme.colors.onSurfaceVariant}
iconColor={theme.colors.onSurfaceVariant}
autoFocus={isSearchMode}
theme={{
colors: {
primary: theme.colors.primary,
text: theme.colors.onSurface,
placeholder: theme.colors.onSurfaceVariant,
},
}}
/>
</Animated.View>
</View>
</Animated.View>
{/* Список чатов */}
<Animated.View style={[styles.listContainer, listAnimatedStyle]}>
<FlatList
data={filteredConversations}
renderItem={renderConversation}
keyExtractor={(item) => item.id}
onRefresh={refetch}
refreshing={loading}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<Animated.View
style={styles.emptyContainer}
entering={FadeInDown.duration(600)}
>
<View style={[styles.emptyIconContainer]}>
<LinearGradient
colors={["rgba(255,255,255,0.03)", "rgba(255,255,255,0.08)"]}
style={styles.emptyIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<MaterialCommunityIcons
name="message-outline"
size={48}
color={theme.colors.onSurfaceVariant}
/>
</View>
<Text
variant="headlineSmall"
style={[styles.emptyText, { color: theme.colors.onSurface }]}
>
Нет активных чатов
</Text>
<Text
variant="bodyLarge"
style={[
styles.emptySubtext,
{ color: theme.colors.onSurfaceVariant },
]}
>
Начните новый чат, нажав на кнопку внизу
</Text>
</Animated.View>
}
/>
</Animated.View>
{/* Разделитель */}
<View style={styles.dividerContainer}>
<LinearGradient
colors={["transparent", theme.colors.outlineVariant, "transparent"]}
style={styles.divider}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
/>
</View>
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
<FlatList
data={filteredConversations}
renderItem={renderConversation}
keyExtractor={(item) => item.id}
onRefresh={refetch}
refreshing={loading}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={
<Animated.View
style={styles.emptyContainer}
entering={FadeInDown.duration(600)}
<Animated.View entering={FadeInRight.delay(300).springify()}>
<TouchableOpacity
onPress={handleNewMessage}
style={styles.fabContainer}
activeOpacity={0.8}
>
<LinearGradient
colors={[theme.colors.primary, theme.colors.primaryContainer]}
style={styles.fab}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<View style={styles.emptyIconContainer}>
<LinearGradient
colors={['rgba(255,255,255,0.05)', 'rgba(255,255,255,0.02)']}
style={styles.emptyIconGradient}
/>
<Text style={styles.emptyIcon}>💬</Text>
</View>
<Text variant="headlineSmall" style={styles.emptyText}>
Нет активных чатов
</Text>
<Text variant="bodyLarge" style={styles.emptySubtext}>
Начните новый чат, нажав на кнопку внизу
</Text>
</Animated.View>
}
/>
<<<<<<< HEAD
<Animated.View
entering={FadeInRight.delay(300).springify()}
>
<FAB
icon="plus"
style={styles.fab}
onPress={() => navigation.navigate('NewChat')}
theme={{
colors: {
primaryContainer: '#ffffff',
onPrimaryContainer: '#000000',
}
}}
/>
<MaterialCommunityIcons
name="pencil"
size={28}
color={theme.colors.onPrimary}
/>
</LinearGradient>
</TouchableOpacity>
</Animated.View>
=======
<FAB
icon="plus"
style={styles.fab}
onPress={() => navigation.navigate('Contacts')}
/>
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
</View>
</BackgroundDesign>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0a0a0a',
},
<<<<<<< HEAD
// Loading состояние
loadingContainer: {
=======
searchContainer: {
padding: 16,
paddingBottom: 8,
backgroundColor: '#ffffff',
elevation: 2,
},
searchbar: {
elevation: 0,
backgroundColor: '#f5f5f5',
},
centerContainer: {
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#0a0a0a',
justifyContent: "center",
alignItems: "center",
padding: 20,
},
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,
},
loadingGradient: {
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 24,
},
loadingText: {
color: '#666666',
fontSize: 16,
fontSize: 18,
fontWeight: "300",
letterSpacing: 1,
},
// Error состояние
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#0a0a0a',
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: {
color: '#ff6b6b',
fontSize: 16,
marginBottom: 20,
fontSize: 18,
marginBottom: 24,
textAlign: "center",
fontWeight: "300",
letterSpacing: 1,
},
retryButton: {
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 24,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
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: {
color: '#ffffff',
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,
backgroundColor: 'transparent',
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,
},
<<<<<<< HEAD
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: {
color: '#ffffff',
fontSize: 20,
fontWeight: '600',
=======
time: {
color: '#666',
fontSize: 12,
marginBottom: 4,
},
unreadBadge: {
backgroundColor: '#2196F3',
color: '#ffffff',
fontSize: 12,
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
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: {
color: '#ffffff',
fontWeight: '600',
fontWeight: "600",
flex: 1,
marginRight: 12,
fontSize: 17,
},
time: {
color: '#666666',
fontSize: 12,
fontSize: 13,
fontWeight: "500",
},
messageRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
lastMessage: {
color: '#999999',
flex: 1,
fontSize: 15,
},
separator: {
unreadBadge: {
backgroundColor: "#2196F3",
color: "#ffffff",
fontSize: 12,
marginLeft: 8,
},
// Разделитель
dividerContainer: {
paddingHorizontal: 20,
marginTop: 4,
marginBottom: 4,
},
divider: {
height: 1,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
marginHorizontal: 28,
marginVertical: 4,
borderRadius: 0.5,
},
fab: {
position: 'absolute',
margin: 16,
right: 0,
bottom: 0,
backgroundColor: '#ffffff',
borderRadius: 16,
// iOS тени
shadowColor: '#ffffff',
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,
backgroundColor: 'rgba(255, 255, 255, 0.03)',
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,
color: '#ffffff',
fontWeight: '300',
fontWeight: "300",
letterSpacing: 0.5,
fontSize: 24,
},
emptySubtext: {
textAlign: 'center',
color: '#666666',
maxWidth: 250,
textAlign: "center",
maxWidth: 280,
fontSize: 16,
lineHeight: 24,
},
});
});

View File

@ -1,11 +1,6 @@
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 { TextInput, Button, Text, Headline, HelperText, Surface, useTheme } from 'react-native-paper';
import { useMutation } from '@apollo/client';
import { LOGIN } from '../graphql/mutations';
import { useAuth } from '../contexts/AuthContext';
@ -95,8 +90,6 @@ export const LoginScreen = ({ navigation }: any) => {
};
});
const handleButtonPressIn = () => {
buttonScale.value = withSpring(0.95);
};
@ -106,7 +99,6 @@ export const LoginScreen = ({ navigation }: any) => {
};
return (
<<<<<<< HEAD
<BackgroundDesign variant="login">
<KeyboardAvoidingView
style={styles.container}
@ -116,118 +108,6 @@ export const LoginScreen = ({ navigation }: any) => {
<Animated.View
style={[styles.content, cardAnimatedStyle]}
entering={FadeInDown.duration(800).springify()}
=======
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: theme.colors.background }]}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
<Animated.View style={[styles.content, containerAnimatedStyle]}>
<Animated.View style={[styles.glowContainer, glowContainerStyle]}>
<Headline style={styles.title}>Вход в Prism</Headline>
<Text style={styles.subtitle}>Добро пожаловать обратно</Text>
</Animated.View>
<AnimatedView style={inputStyle1}>
<TextInput
label="Имя пользователя"
value={username}
onChangeText={setUsername}
mode="outlined"
style={styles.input}
autoCapitalize="none"
disabled={loading}
theme={{
colors: {
primary: theme.colors.primary,
placeholder: theme.colors.secondary,
text: theme.colors.onSurface,
background: theme.colors.surface,
outline: theme.colors.outline,
}
}}
onFocus={() => {
inputFocusAnimation1.value = withSpring(1);
}}
onBlur={() => {
inputFocusAnimation1.value = withSpring(0);
}}
/>
</AnimatedView>
<AnimatedView style={inputStyle2}>
<TextInput
label="Пароль"
value={password}
onChangeText={setPassword}
mode="outlined"
style={styles.input}
secureTextEntry={!showPassword}
disabled={loading}
theme={{
colors: {
primary: theme.colors.primary,
placeholder: theme.colors.secondary,
text: theme.colors.onSurface,
background: theme.colors.surface,
outline: theme.colors.outline,
}
}}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
color="#a855f7"
/>
}
onFocus={() => {
inputFocusAnimation2.value = withSpring(1);
}}
onBlur={() => {
inputFocusAnimation2.value = withSpring(0);
}}
/>
</AnimatedView>
{error && (
<HelperText type="error" visible={true} style={styles.errorText}>
{error.message}
</HelperText>
)}
<AnimatedView style={buttonAnimatedStyle}>
<Button
mode="contained"
onPress={handleLogin}
onPressIn={handleButtonPressIn}
onPressOut={handleButtonPressOut}
loading={loading}
disabled={loading || !username || !password}
style={styles.button}
contentStyle={styles.buttonContent}
labelStyle={styles.buttonLabel}
theme={{
colors: {
primary: '#9333ea',
}
}}
>
Войти
</Button>
</AnimatedView>
<Button
mode="text"
onPress={() => navigation.navigate('Register')}
disabled={loading}
style={styles.linkButton}
labelStyle={styles.linkButtonLabel}
theme={{
colors: {
primary: '#a855f7',
}
}}
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
>
<Animated.View style={[styles.loginCard, glowStyle]}>
<LinearGradient
@ -242,68 +122,68 @@ export const LoginScreen = ({ navigation }: any) => {
style={styles.headerContainer}
>
<View style={styles.logoContainer}>
<View style={styles.logoPlaceholder}>
<Text style={styles.logoText}>P</Text>
<View style={[styles.logoPlaceholder, { backgroundColor: theme.colors.surfaceVariant }]}>
<Text style={[styles.logoText, { color: theme.colors.onSurface }]}>P</Text>
</View>
</View>
<Headline style={styles.title}>PRISM</Headline>
<Headline style={[styles.title, { color: theme.colors.onSurface }]}>PRISM</Headline>
</Animated.View>
<Animated.View
entering={FadeIn.delay(400).duration(600)}
style={styles.formContainer}
>
<TextInput
label="Имя пользователя"
value={username}
onChangeText={setUsername}
mode="flat"
style={styles.input}
autoCapitalize="none"
disabled={loading}
underlineColor="transparent"
activeUnderlineColor="#ffffff"
theme={{
colors: {
primary: '#ffffff',
placeholder: '#808080',
text: '#ffffff',
background: 'rgba(255,255,255,0.05)',
outline: '#666666',
}
}}
/>
<TextInput
label="Пароль"
value={password}
onChangeText={setPassword}
mode="flat"
style={styles.input}
secureTextEntry={!showPassword}
disabled={loading}
underlineColor="transparent"
activeUnderlineColor="#ffffff"
theme={{
colors: {
primary: '#ffffff',
placeholder: '#808080',
text: '#ffffff',
background: 'rgba(255,255,255,0.05)',
outline: '#666666',
}
}}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
color="#808080"
/>
<TextInput
label="Имя пользователя"
value={username}
onChangeText={setUsername}
mode="flat"
style={[styles.input, { backgroundColor: theme.colors.surfaceVariant }]}
autoCapitalize="none"
disabled={loading}
underlineColor="transparent"
activeUnderlineColor={theme.colors.primary}
theme={{
colors: {
primary: theme.colors.primary,
placeholder: theme.colors.onSurfaceVariant,
text: theme.colors.onSurface,
background: 'transparent',
outline: theme.colors.outline,
}
/>
}}
/>
<TextInput
label="Пароль"
value={password}
onChangeText={setPassword}
mode="flat"
style={[styles.input, { backgroundColor: theme.colors.surfaceVariant }]}
secureTextEntry={!showPassword}
disabled={loading}
underlineColor="transparent"
activeUnderlineColor={theme.colors.primary}
theme={{
colors: {
primary: theme.colors.primary,
placeholder: theme.colors.onSurfaceVariant,
text: theme.colors.onSurface,
background: 'transparent',
outline: theme.colors.outline,
}
}}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
color={theme.colors.onSurfaceVariant}
/>
}
/>
{error && (
<HelperText type="error" visible={true} style={styles.errorText}>
<HelperText type="error" visible={true} style={[styles.errorText, { color: theme.colors.error }]}>
{error.message}
</HelperText>
)}
@ -317,7 +197,7 @@ export const LoginScreen = ({ navigation }: any) => {
activeOpacity={0.8}
>
<LinearGradient
colors={['#ffffff', '#e6e6e6']}
colors={[theme.colors.primary, theme.colors.primaryContainer]}
style={[
styles.gradientButton,
(loading || !username || !password) && styles.disabledButton
@ -325,7 +205,7 @@ export const LoginScreen = ({ navigation }: any) => {
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={styles.buttonText}>
<Text style={[styles.buttonText, { color: theme.colors.onPrimary }]}>
{loading ? 'ВХОД...' : 'ВОЙТИ'}
</Text>
</LinearGradient>
@ -333,9 +213,9 @@ export const LoginScreen = ({ navigation }: any) => {
</Animated.View>
<View style={styles.dividerContainer}>
<View style={styles.divider} />
<Text style={styles.dividerText}>ИЛИ</Text>
<View style={styles.divider} />
<View style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />
<Text style={[styles.dividerText, { color: theme.colors.onSurfaceVariant }]}>ИЛИ</Text>
<View style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />
</View>
<TouchableOpacity
@ -344,10 +224,10 @@ export const LoginScreen = ({ navigation }: any) => {
style={styles.linkButton}
activeOpacity={0.7}
>
<Text style={styles.linkButtonText}>
<Text style={[styles.linkButtonText, { color: theme.colors.onSurfaceVariant }]}>
Нет аккаунта?
</Text>
<Text style={styles.linkButtonTextBold}>
<Text style={[styles.linkButtonTextBold, { color: theme.colors.primary }]}>
{' Создать'}
</Text>
</TouchableOpacity>
@ -381,7 +261,6 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(26, 26, 26, 0.8)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
// backdropFilter: 'blur(10px)', // не поддерживается в React Native
// iOS тени
shadowColor: '#ffffff',
shadowOffset: {
@ -412,7 +291,6 @@ const styles = StyleSheet.create({
width: 80,
height: 80,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
@ -421,23 +299,18 @@ const styles = StyleSheet.create({
logoText: {
fontSize: 40,
fontWeight: 'bold',
color: '#ffffff',
},
title: {
fontSize: 36,
fontWeight: '300',
color: '#ffffff',
letterSpacing: 4,
marginBottom: 8,
},
formContainer: {
width: '100%',
},
input: {
marginBottom: 20,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
fontSize: 16,
borderRadius: 12,
paddingHorizontal: 16,
@ -445,7 +318,6 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255, 255, 255, 0.1)',
},
errorText: {
color: '#ff6b6b',
textAlign: 'center',
marginBottom: 16,
fontSize: 14,
@ -457,7 +329,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
marginTop: 8,
// iOS тени
shadowColor: '#ffffff',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
@ -471,7 +343,6 @@ const styles = StyleSheet.create({
opacity: 0.5,
},
buttonText: {
color: '#000000',
fontSize: 16,
fontWeight: '700',
letterSpacing: 2,
@ -484,10 +355,8 @@ const styles = StyleSheet.create({
divider: {
flex: 1,
height: 1,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
dividerText: {
color: '#666666',
paddingHorizontal: 16,
fontSize: 12,
letterSpacing: 1,
@ -499,11 +368,9 @@ const styles = StyleSheet.create({
},
linkButtonText: {
fontSize: 14,
color: '#808080',
},
linkButtonTextBold: {
fontSize: 14,
color: '#ffffff',
fontWeight: '600',
},
});

View File

@ -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 (
<Animated.View
entering={FadeInDown.delay(index * 50).springify()}
>
<TouchableOpacity
activeOpacity={0.8}
onPress={() => handleStartChat(item.id)}
>
<View style={styles.userCard}>
<LinearGradient
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']}
style={styles.userGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.userContent}>
<View style={styles.avatarContainer}>
<Avatar.Text
size={56}
label={(item.username || item.email || item.id).charAt(0).toUpperCase()}
style={[styles.avatar, { backgroundColor: getAvatarColor(item.username || item.email || item.id) }]}
labelStyle={[styles.avatarLabel, { color: '#ffffff' }]}
/>
{item.isOnline === true && (
<View style={styles.onlineIndicator}>
<View style={styles.onlineBadge} />
</View>
)}
</View>
<View style={styles.userInfo}>
<Text variant="titleMedium" style={[styles.username, { color: theme.colors.onSurface }]}>
@{item.username || item.email?.split('@')[0] || item.id.slice(0, 8)}
</Text>
<Text variant="bodyMedium" style={[styles.userBio, { color: theme.colors.onSurfaceVariant }]} numberOfLines={1}>
{item.bio || item.email || 'Пользователь'}
</Text>
{item.isOnline === true && (
<View style={styles.onlineChip}>
<Text style={[styles.onlineChipText, { color: '#4CAF50' }]}>В сети</Text>
</View>
)}
</View>
<MaterialCommunityIcons
name="chevron-right"
size={24}
color={theme.colors.onSurfaceVariant}
/>
</View>
</View>
</TouchableOpacity>
</Animated.View>
);
};
const getAvatarColor = (identifier: string) => {
const colors = ['#2196F3', '#4CAF50', '#FF5722', '#9C27B0', '#FF9800', '#00BCD4'];
const index = identifier.charCodeAt(0) % colors.length;
return colors[index];
};
return (
<BackgroundDesign variant="chat">
{/* Заголовок */}
<Animated.View
style={[styles.headerContainer, headerAnimatedStyle]}
entering={FadeInUp.delay(200).duration(600)}
>
<View style={styles.headerCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.topRow}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="arrow-left" size={24} color={theme.colors.onSurface} />
</TouchableOpacity>
<Headline style={[styles.title, { color: theme.colors.onSurface }]}>НОВОЕ СООБЩЕНИЕ</Headline>
<View style={styles.placeholder} />
</View>
{/* Поисковая строка */}
<Searchbar
placeholder="Поиск по @username..."
onChangeText={setSearchQuery}
value={searchQuery}
style={[styles.searchbar, { backgroundColor: theme.colors.surfaceVariant }]}
inputStyle={{ color: theme.colors.onSurface }}
placeholderTextColor={theme.colors.onSurfaceVariant}
iconColor={theme.colors.onSurfaceVariant}
theme={{
colors: {
primary: theme.colors.primary,
text: theme.colors.onSurface,
placeholder: theme.colors.onSurfaceVariant,
}
}}
/>
</View>
</Animated.View>
{/* Опции создания */}
<Animated.View style={[styles.optionsContainer, listAnimatedStyle]}>
<View style={styles.optionsCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.optionsGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<TouchableOpacity style={styles.option} onPress={handleCreateGroup} activeOpacity={0.8}>
<View style={styles.optionIcon}>
<MaterialCommunityIcons name="account-group" size={24} color={theme.colors.primary} />
</View>
<View style={styles.optionContent}>
<Text style={[styles.optionTitle, { color: theme.colors.onSurface }]}>Новый групповой чат</Text>
<Text style={[styles.optionSubtitle, { color: theme.colors.onSurfaceVariant }]}>Создать группу до 256 участников</Text>
</View>
</TouchableOpacity>
<Divider style={[styles.optionDivider, { backgroundColor: theme.colors.outlineVariant }]} />
<TouchableOpacity style={styles.option} onPress={handleCreateChannel} activeOpacity={0.8}>
<View style={styles.optionIcon}>
<MaterialCommunityIcons name="bullhorn" size={24} color={theme.colors.primary} />
</View>
<View style={styles.optionContent}>
<Text style={[styles.optionTitle, { color: theme.colors.onSurface }]}>Новый канал</Text>
<Text style={[styles.optionSubtitle, { color: theme.colors.onSurfaceVariant }]}>Для публичных объявлений</Text>
</View>
</TouchableOpacity>
<Divider style={[styles.optionDivider, { backgroundColor: theme.colors.outlineVariant }]} />
<TouchableOpacity style={styles.option} onPress={handleInviteFriends} activeOpacity={0.8}>
<View style={styles.optionIcon}>
<MaterialCommunityIcons name="account-plus" size={24} color={theme.colors.primary} />
</View>
<View style={styles.optionContent}>
<Text style={[styles.optionTitle, { color: theme.colors.onSurface }]}>Пригласить в мессенджер</Text>
<Text style={[styles.optionSubtitle, { color: theme.colors.onSurfaceVariant }]}>Поделиться приглашением</Text>
</View>
</TouchableOpacity>
</View>
</Animated.View>
{/* Список пользователей */}
<Animated.View style={[styles.listContainer, listAnimatedStyle]}>
<FlatList
data={filteredUsers}
renderItem={renderUser}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<Animated.View
style={styles.emptyContainer}
entering={FadeInDown.duration(600)}
>
<View style={styles.emptyIconContainer}>
<LinearGradient
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']}
style={styles.emptyIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<MaterialCommunityIcons
name={searchQuery ? 'account-search' : 'account-group-outline'}
size={48}
color={theme.colors.onSurfaceVariant}
/>
</View>
<Text variant="headlineSmall" style={[styles.emptyText, { color: theme.colors.onSurface }]}>
{searchQuery
? `@${searchQuery} не найден`
: 'Загружаем пользователей...'}
</Text>
{searchQuery && (
<Text variant="bodyLarge" style={[styles.emptySubtext, { color: theme.colors.onSurfaceVariant }]}>
Проверьте правильность никнейма
</Text>
)}
</Animated.View>
}
/>
</Animated.View>
</BackgroundDesign>
);
};
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,
},
});

View File

@ -114,15 +114,15 @@ export const ProfileScreen = ({ navigation }: any) => {
<View style={styles.profileContent}>
<Avatar.Text
size={80}
label={user?.username.charAt(0).toUpperCase() || 'U'}
label={(user?.username || user?.email || user?.id)?.charAt(0).toUpperCase() || 'U'}
style={{ backgroundColor: theme.colors.primary }}
/>
<View style={styles.profileInfo}>
<Text variant="headlineSmall" style={{ color: theme.colors.onSurface }}>
@{user?.username}
@{user?.username || user?.email?.split('@')[0] || user?.id?.slice(0, 8)}
</Text>
<Text variant="bodyMedium" style={{ color: theme.colors.onSurfaceVariant }}>
{user?.email}
{user?.email || 'Не указан'}
</Text>
{user?.bio && (
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant, marginTop: 4 }}>
@ -133,10 +133,10 @@ export const ProfileScreen = ({ navigation }: any) => {
<MaterialCommunityIcons
name="circle"
size={10}
color={user?.isOnline ? '#10b981' : '#6b7280'}
color={user?.isOnline === true ? '#10b981' : '#6b7280'}
/>
<Text variant="bodySmall" style={{ color: user?.isOnline ? '#10b981' : '#6b7280', marginLeft: 4 }}>
{user?.isOnline ? 'В сети' : 'Не в сети'}
<Text variant="bodySmall" style={{ color: user?.isOnline === true ? '#10b981' : '#6b7280', marginLeft: 4 }}>
{user?.isOnline === true ? 'В сети' : 'Не в сети'}
</Text>
</View>
</View>

View File

@ -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 (
<BackgroundDesign variant="chat">
{/* Заголовок */}
<Animated.View
style={styles.headerContainer}
entering={FadeInUp.duration(600)}
>
<View style={styles.headerCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.headerContent}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="arrow-left"
size={24}
color={theme.colors.onSurface}
/>
</TouchableOpacity>
<Text
variant="titleLarge"
style={[styles.headerTitle, { color: theme.colors.onSurface }]}
>
Информация
</Text>
</View>
</View>
</Animated.View>
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
{/* Профиль пользователя */}
<Animated.View
style={styles.profileSection}
entering={FadeInDown.delay(200).duration(600)}
>
<View style={styles.profileCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.profileGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.profileContent}>
<View style={styles.avatarSection}>
<Avatar.Text
size={80}
label={(user?.username || user?.email || user?.id || 'U').charAt(0).toUpperCase()}
style={[styles.avatar, { backgroundColor: theme.colors.primaryContainer }]}
labelStyle={[styles.avatarLabel, { color: theme.colors.onPrimaryContainer }]}
/>
{user?.isOnline === true && (
<View style={styles.onlineIndicator}>
<View style={styles.onlineDot} />
</View>
)}
</View>
<View style={styles.userDetails}>
<Text
variant="headlineSmall"
style={[styles.userName, { color: theme.colors.onSurface }]}
>
@{user?.username || user?.email?.split('@')[0] || user?.id?.slice(0, 8)}
</Text>
{user?.bio && (
<Text
variant="bodyLarge"
style={[styles.userBio, { color: theme.colors.onSurfaceVariant }]}
>
{user.bio}
</Text>
)}
<View style={styles.statusRow}>
<MaterialCommunityIcons
name="circle"
size={12}
color={user?.isOnline === true ? '#4CAF50' : '#9E9E9E'}
/>
<Text
variant="bodyMedium"
style={[
styles.statusText,
{
color: user?.isOnline === true ? '#4CAF50' : theme.colors.onSurfaceVariant
}
]}
>
{user?.isOnline === true ? 'В сети' : 'Не в сети'}
</Text>
</View>
</View>
</View>
</View>
</Animated.View>
{/* Дополнительная информация */}
<Animated.View
style={styles.infoSection}
entering={FadeInDown.delay(400).duration(600)}
>
<View style={styles.infoCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.infoGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.infoContent}>
<Text
variant="titleMedium"
style={[styles.sectionTitle, { color: theme.colors.onSurface }]}
>
Детали
</Text>
{user?.email && (
<>
<View style={styles.infoRow}>
<MaterialCommunityIcons
name="email"
size={20}
color={theme.colors.onSurfaceVariant}
/>
<View style={styles.infoTextContainer}>
<Text
variant="bodySmall"
style={[styles.infoLabel, { color: theme.colors.onSurfaceVariant }]}
>
Email
</Text>
<Text
variant="bodyMedium"
style={[styles.infoValue, { color: theme.colors.onSurface }]}
>
{user.email}
</Text>
</View>
</View>
<Divider style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />
</>
)}
{user?.createdAt && (
<>
<View style={styles.infoRow}>
<MaterialCommunityIcons
name="calendar"
size={20}
color={theme.colors.onSurfaceVariant}
/>
<View style={styles.infoTextContainer}>
<Text
variant="bodySmall"
style={[styles.infoLabel, { color: theme.colors.onSurfaceVariant }]}
>
Присоединился
</Text>
<Text
variant="bodyMedium"
style={[styles.infoValue, { color: theme.colors.onSurface }]}
>
{formatJoinDate(user.createdAt)}
</Text>
</View>
</View>
<Divider style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />
</>
)}
{user?.lastSeen && user?.isOnline !== true && (
<View style={styles.infoRow}>
<MaterialCommunityIcons
name="clock"
size={20}
color={theme.colors.onSurfaceVariant}
/>
<View style={styles.infoTextContainer}>
<Text
variant="bodySmall"
style={[styles.infoLabel, { color: theme.colors.onSurfaceVariant }]}
>
Последний раз в сети
</Text>
<Text
variant="bodyMedium"
style={[styles.infoValue, { color: theme.colors.onSurface }]}
>
{format(new Date(user.lastSeen), 'dd MMMM, HH:mm', { locale: ru })}
</Text>
</View>
</View>
)}
</View>
</View>
</Animated.View>
</ScrollView>
</BackgroundDesign>
);
};
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,
},
});

View File

@ -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(),

View File

@ -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;