Compare commits
5 Commits
6a141d25b7
...
main
Author | SHA1 | Date | |
---|---|---|---|
bd731f1f57 | |||
5a3fdc01f8 | |||
1eea449a30 | |||
1441440247 | |||
3f2e8e96f5 |
92
CLAUDE.md
Normal file
92
CLAUDE.md
Normal 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
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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 })
|
||||
|
@ -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 } });
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
33
frontend/package-lock.json
generated
33
frontend/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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();
|
@ -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>
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
@ -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,
|
||||
},
|
||||
});
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -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',
|
||||
},
|
||||
});
|
570
frontend/src/screens/NewMessageScreen.tsx
Normal file
570
frontend/src/screens/NewMessageScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
@ -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>
|
||||
|
424
frontend/src/screens/UserInfoScreen.tsx
Normal file
424
frontend/src/screens/UserInfoScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
@ -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(),
|
||||
|
@ -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;
|
||||
|
Reference in New Issue
Block a user