Compare commits

..

7 Commits

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 21:42:36 +03:00
5a3fdc01f8 Fix Git merge conflict in LoginScreen.tsx - combined beautiful design with theme support 2025-08-06 11:31:15 +03:00
1eea449a30 Fix Git merge conflict in ConversationsScreen.tsx - combined both versions 2025-08-06 11:29:25 +03:00
1441440247 Add expo-linear-gradient dependency 2025-08-06 11:25:40 +03:00
3f2e8e96f5 Resolve merge conflict in App.tsx - use imported theme 2025-08-06 11:22:16 +03:00
6a141d25b7 Обновил дизайн на черно-серый 2025-08-06 06:14:55 +03:00
020682854d 1212 2025-08-06 06:04:23 +03:00
21 changed files with 4183 additions and 988 deletions

92
CLAUDE.md Normal file
View File

@ -0,0 +1,92 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Prism is a real-time messenger built with Expo (React Native) frontend and NestJS backend using GraphQL and PostgreSQL. The project is structured as a monorepo with separate frontend and backend directories.
## Development Commands
### Backend (NestJS)
```bash
cd backend
npm run start:dev # Development server with hot reload
npm run build # Build for production
npm run test # Run unit tests
npm run test:e2e # Run end-to-end tests
npm run lint # ESLint with auto-fix
npm run format # Prettier formatting
```
### Frontend (Expo/React Native)
```bash
cd frontend
npx expo start # Start Expo development server
npx expo start --ios # Start with iOS simulator
npx expo start --android # Start with Android emulator
```
## Architecture
### Backend Architecture
- **Framework**: NestJS with TypeScript
- **API**: GraphQL using code-first approach with auto-generated schema
- **Database**: PostgreSQL with TypeORM (synchronize enabled in development)
- **Authentication**: JWT with Passport strategy
- **Real-time**: GraphQL Subscriptions for live updates
- **Module Structure**:
- `auth/` - JWT authentication and guards
- `users/` - User management and profiles
- `conversations/` - Chat/conversation management
- `messages/` - Message handling and real-time updates
### Frontend Architecture
- **Framework**: Expo with React Native and TypeScript
- **State Management**: Apollo Client for GraphQL state management
- **UI Framework**: React Native Paper with custom theming
- **Navigation**: React Navigation with stack and tab navigators
- **Storage**: AsyncStorage for token persistence
- **Key Contexts**: AuthContext for authentication state
### Data Flow
- Frontend communicates with backend exclusively through GraphQL
- Real-time updates via GraphQL subscriptions
- JWT tokens stored in AsyncStorage and included in GraphQL requests via authLink
- Apollo Client handles caching with cache-and-network fetch policy
## Database Configuration
The project supports both remote and local PostgreSQL:
- Remote database is the default (configured in backend .env)
- Local database can be enabled by uncommenting postgres service in docker-compose.yml
- TypeORM synchronize is enabled in development (automatic schema updates)
## Environment Configuration
### Backend Environment Variables
Required in `backend/.env`:
```
DATABASE_HOST=
DATABASE_PORT=5432
DATABASE_USERNAME=
DATABASE_PASSWORD=
DATABASE_NAME=prism_messenger
JWT_SECRET=
JWT_EXPIRATION=7d
```
### Frontend API Configuration
Update `frontend/src/config/api.ts` based on development environment:
- iOS simulator: http://localhost:3000/graphql
- Android emulator: http://10.0.2.2:3000/graphql
- Physical device: http://YOUR_IP:3000/graphql
## Key Implementation Details
- GraphQL schema is auto-generated at `backend/src/schema.gql`
- All GraphQL queries, mutations, and subscriptions are centralized in `frontend/src/graphql/`
- Authentication uses JWT with bearer token authorization
- Real-time features implemented via GraphQL subscriptions with graphql-ws
- Frontend theme system supports both light and dark modes
- Message features include send, edit, delete with real-time updates

View File

@ -12,7 +12,9 @@ async function bootstrap() {
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
});
await app.listen(process.env.PORT ?? 3000);
console.log(`Application is running on: http://localhost:${process.env.PORT ?? 3000}/graphql`);
const port = process.env.PORT ?? 3000;
await app.listen(port, '0.0.0.0');
console.log(`Application is running on: http://0.0.0.0:${port}/graphql`);
console.log(`For mobile devices use: http://192.168.31.210:${port}/graphql`);
}
bootstrap();

View File

@ -25,7 +25,7 @@ export class AuthService {
throw new UnauthorizedException('Неверный логин или пароль');
}
const payload = { username: user.username, sub: user.id };
const payload = { username: user.username || user.email, sub: user.id };
return {
access_token: this.jwtService.sign(payload),
user,
@ -34,7 +34,7 @@ export class AuthService {
async register(username: string, email: string, password: string) {
const user = await this.usersService.create(username, email, password);
const payload = { username: user.username, sub: user.id };
const payload = { username: user.username || user.email, sub: user.id };
return {
access_token: this.jwtService.sign(payload),
user,

View File

@ -9,13 +9,13 @@ export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column({ unique: true })
username: string;
@Field(() => String, { nullable: true })
@Column({ unique: true, nullable: true })
username?: string;
@Field()
@Column({ unique: true })
email: string;
@Field(() => String, { nullable: true })
@Column({ unique: true, nullable: true })
email?: string;
@HideField()
@Column()
@ -29,9 +29,9 @@ export class User {
@Column({ nullable: true })
bio?: string;
@Field(() => Boolean)
@Column({ default: false })
isOnline: boolean;
@Field(() => Boolean, { nullable: true })
@Column({ default: false, nullable: true })
isOnline?: boolean;
@Field(() => Date, { nullable: true })
@Column({ nullable: true })

View File

@ -12,9 +12,13 @@ export class UsersService {
) {}
async create(username: string, email: string, password: string): Promise<User> {
const existingUser = await this.usersRepository.findOne({
where: [{ username }, { email }],
});
const whereConditions: Array<{ username?: string; email?: string }> = [];
if (username) whereConditions.push({ username });
if (email) whereConditions.push({ email });
const existingUser = whereConditions.length > 0 ? await this.usersRepository.findOne({
where: whereConditions,
}) : null;
if (existingUser) {
throw new ConflictException('Пользователь с таким username или email уже существует');
@ -43,10 +47,12 @@ export class UsersService {
}
async findByUsername(username: string): Promise<User | null> {
if (!username) return null;
return this.usersRepository.findOne({ where: { username } });
}
async findByEmail(email: string): Promise<User | null> {
if (!email) return null;
return this.usersRepository.findOne({ where: { email } });
}

View File

@ -16,8 +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-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",
@ -27,6 +29,7 @@
"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"
},
@ -3425,6 +3428,12 @@
"node": ">=0.6"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@ -3997,6 +4006,56 @@
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -4110,6 +4169,61 @@
"node": ">=0.10"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@ -4170,6 +4284,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-editor": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@ -4371,6 +4497,17 @@
"react": "*"
}
},
"node_modules/expo-linear-gradient": {
"version": "14.1.5",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-14.1.5.tgz",
"integrity": "sha512-BSN3MkSGLZoHMduEnAgfhoj3xqcDWaoICgIr4cIYEx1GcHfKMhzA/O4mpZJ/WC27BP1rnAqoKfbclk1eA70ndQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "2.1.14",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.1.14.tgz",
@ -4700,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",
@ -5733,6 +5900,12 @@
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
"license": "Apache-2.0"
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@ -6343,6 +6516,18 @@
"node": ">=10"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@ -7221,6 +7406,21 @@
"react-native": "*"
}
},
"node_modules/react-native-svg": {
"version": "15.11.2",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.11.2.tgz",
"integrity": "sha512-+YfF72IbWQUKzCIydlijV1fLuBsQNGMT6Da2kFlo1sh+LE3BIm/2Q7AR1zAAR6L0BFLi1WaQPLfFUC9bNZpOmw==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
"warn-once": "0.1.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-vector-icons": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz",

View File

@ -10,15 +10,19 @@
},
"dependencies": {
"@apollo/client": "^3.13.9",
"@expo/metro-runtime": "~5.0.4",
"@react-native-async-storage/async-storage": "2.1.2",
"@react-navigation/bottom-tabs": "^7.4.5",
"@react-navigation/native": "^7.1.17",
"@react-navigation/native-stack": "^7.3.24",
"date-fns": "^4.1.0",
"expo": "~53.0.20",
"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",
"react-native-gesture-handler": "~2.24.0",
"react-native-keyboard-aware-scroll-view": "^0.9.5",
@ -26,10 +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-dom": "19.0.0",
"react-native-web": "^0.20.0",
"@expo/metro-runtime": "~5.0.4"
"react-native-web": "^0.20.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",

View File

@ -0,0 +1,252 @@
import React from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
withSequence,
interpolate,
Easing,
} from 'react-native-reanimated';
const { width, height } = Dimensions.get('window');
interface BackgroundDesignProps {
variant?: 'default' | 'login' | 'chat';
children?: React.ReactNode;
}
export const BackgroundDesign: React.FC<BackgroundDesignProps> = ({
variant = 'default',
children
}) => {
const floatAnimation = useSharedValue(0);
const rotateAnimation = useSharedValue(0);
const pulseAnimation = useSharedValue(0);
React.useEffect(() => {
// Плавное движение вверх-вниз
floatAnimation.value = withRepeat(
withSequence(
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.ease) }),
withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.ease) })
),
-1,
false
);
// Вращение элементов
rotateAnimation.value = withRepeat(
withTiming(360, { duration: 30000, easing: Easing.linear }),
-1,
false
);
// Пульсация
pulseAnimation.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.ease) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.ease) })
),
-1,
false
);
}, []);
const floatingStyle = useAnimatedStyle(() => {
const translateY = interpolate(floatAnimation.value, [0, 1], [0, -30]);
return {
transform: [{ translateY }],
};
});
const rotatingStyle = useAnimatedStyle(() => {
return {
transform: [{ rotate: `${rotateAnimation.value}deg` }],
};
});
const pulsingStyle = useAnimatedStyle(() => {
const scale = interpolate(pulseAnimation.value, [0, 1], [1, 1.1]);
const opacity = interpolate(pulseAnimation.value, [0, 1], [0.3, 0.6]);
return {
transform: [{ scale }],
opacity,
};
});
return (
<View style={styles.container}>
{/* Основной градиентный фон */}
<LinearGradient
colors={['#0a0a0a', '#1a1a1a', '#0f0f0f']}
style={StyleSheet.absoluteFillObject}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Декоративные элементы */}
<View style={styles.decorativeElements}>
{/* Большой круг с градиентом слева вверху */}
<Animated.View style={[styles.circle1, pulsingStyle]}>
<LinearGradient
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.01)']}
style={styles.circleGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
{/* Плавающий элемент справа */}
<Animated.View style={[styles.floatingElement1, floatingStyle]}>
<LinearGradient
colors={['rgba(255,255,255,0.05)', 'rgba(255,255,255,0.02)']}
style={styles.elementGradient}
/>
</Animated.View>
{/* Вращающийся квадрат */}
<Animated.View style={[styles.rotatingSquare, rotatingStyle]}>
<View style={styles.squareInner} />
</Animated.View>
{/* Сетка точек для login варианта */}
{variant === 'login' && (
<View style={styles.dotsGrid}>
{Array.from({ length: 10 }).map((_, i) =>
Array.from({ length: 15 }).map((_, j) => (
<View
key={`${i}-${j}`}
style={[
styles.dot,
{
left: i * (width / 9),
top: j * (height / 14),
opacity: 0.05 + Math.random() * 0.05,
}
]}
/>
))
)}
</View>
)}
{/* Градиентные блики */}
<View style={styles.glowContainer}>
<LinearGradient
colors={['rgba(255,255,255,0.1)', 'transparent']}
style={[styles.glow1]}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<LinearGradient
colors={['rgba(255,255,255,0.08)', 'transparent']}
style={[styles.glow2]}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
/>
</View>
</View>
{/* Контент поверх фона */}
<View style={styles.contentContainer}>
{children}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0a0a0a',
},
decorativeElements: {
...StyleSheet.absoluteFillObject,
overflow: 'hidden',
},
contentContainer: {
flex: 1,
zIndex: 1,
},
circle1: {
position: 'absolute',
top: -width * 0.2,
left: -width * 0.2,
width: width * 0.6,
height: width * 0.6,
borderRadius: width * 0.3,
overflow: 'hidden',
},
circleGradient: {
flex: 1,
},
floatingElement1: {
position: 'absolute',
top: height * 0.2,
right: width * 0.1,
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(255,255,255,0.02)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
overflow: 'hidden',
},
elementGradient: {
flex: 1,
},
rotatingSquare: {
position: 'absolute',
bottom: height * 0.15,
left: width * 0.15,
width: 60,
height: 60,
backgroundColor: 'transparent',
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.1)',
transform: [{ rotate: '45deg' }],
},
squareInner: {
flex: 1,
margin: 10,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
},
dotsGrid: {
position: 'absolute',
top: 0,
left: 0,
width: width,
height: height,
},
dot: {
position: 'absolute',
width: 2,
height: 2,
borderRadius: 1,
backgroundColor: '#666666',
},
glowContainer: {
...StyleSheet.absoluteFillObject,
},
glow1: {
position: 'absolute',
top: height * 0.1,
right: -width * 0.2,
width: width * 0.8,
height: width * 0.8,
borderRadius: width * 0.4,
},
glow2: {
position: 'absolute',
bottom: -height * 0.1,
left: -width * 0.1,
width: width * 0.7,
height: width * 0.7,
borderRadius: width * 0.35,
},
});

View File

@ -17,4 +17,15 @@ const getApiUrl = () => {
}
};
export const API_URL = getApiUrl();
// Для физических устройств в сети используйте IP адрес компьютера
export const DEV_SERVER_IP = '192.168.31.210';
export const getDeviceApiUrl = () => {
if (__DEV__) {
return `http://${DEV_SERVER_IP}:3000/graphql`;
}
return getApiUrl();
};
// Используйте getDeviceApiUrl() для физических устройств, getApiUrl() для эмуляторов
export const API_URL = getDeviceApiUrl();

View File

@ -7,6 +7,8 @@ import { useTheme } from 'react-native-paper';
// Импортируем существующие экраны
import { ConversationsScreen } from '../screens/ConversationsScreen';
import { ChatScreen } from '../screens/ChatScreen';
import { NewMessageScreen } from '../screens/NewMessageScreen';
import { UserInfoScreen } from '../screens/UserInfoScreen';
// Заглушки для новых экранов (создадим позже)
import { ContactsScreen } from '../screens/ContactsScreen';
@ -25,16 +27,29 @@ function ChatsStackNavigator() {
name="ConversationsList"
component={ConversationsScreen}
options={{
title: 'Чаты',
headerLargeTitle: true,
headerShown: false,
}}
/>
<ChatsStack.Screen
name="Chat"
component={ChatScreen}
options={({ route }) => ({
title: route.params?.title || 'Чат',
})}
options={{
headerShown: false,
}}
/>
<ChatsStack.Screen
name="NewMessage"
component={NewMessageScreen}
options={{
headerShown: false,
}}
/>
<ChatsStack.Screen
name="UserInfo"
component={UserInfoScreen}
options={{
headerShown: false,
}}
/>
</ChatsStack.Navigator>
);
@ -48,8 +63,7 @@ function ContactsStackNavigator() {
name="ContactsList"
component={ContactsScreen}
options={{
title: 'Контакты',
headerLargeTitle: true,
headerShown: false,
}}
/>
</ContactsStack.Navigator>

View File

@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { View, StyleSheet, FlatList, KeyboardAvoidingView, Platform } from 'react-native';
import { TextInput, IconButton, Text, Avatar, Surface, Menu } from 'react-native-paper';
import { View, StyleSheet, FlatList, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
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';
@ -9,17 +10,32 @@ import { Message } from '../types';
import { useAuth } from '../contexts/AuthContext';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
FadeInDown,
FadeOutDown,
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, {
@ -57,15 +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;
@ -78,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);
}
@ -94,35 +149,108 @@ export const ChatScreen = ({ route }: any) => {
}
};
const renderMessage = ({ item }: { item: Message }) => {
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
const isOwnMessage = item.sender.id === user?.id;
const messageTime = format(new Date(item.createdAt), 'HH:mm', { locale: ru });
return (
<View style={[styles.messageContainer, isOwnMessage && styles.ownMessageContainer]}>
<Animated.View
entering={isOwnMessage ? SlideInRight.delay(index * 30) : SlideInLeft.delay(index * 30)}
exiting={FadeOutDown}
style={[styles.messageContainer, isOwnMessage && styles.ownMessageContainer]}
>
{/* Аватар для входящих сообщений */}
{!isOwnMessage && (
<View style={styles.messageAvatarContainer}>
<Avatar.Text
size={36}
label={item.sender.username.charAt(0).toUpperCase()}
style={styles.avatar}
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>
)}
<Surface
style={[styles.messageBubble, isOwnMessage && styles.ownMessageBubble]}
elevation={1}
{/* Контейнер сообщения */}
<View style={[styles.messageBubbleContainer, isOwnMessage && styles.ownMessageBubbleContainer]}>
<TouchableOpacity
activeOpacity={0.9}
onLongPress={() => {
if (isOwnMessage) {
setSelectedMessage(item.id);
setMenuVisible(true);
}
}}
>
<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}>
{item.sender.username}
<Text
variant="labelSmall"
style={[styles.senderName, { color: theme.colors.primary }]}
>
{item.sender.username || item.sender.email?.split('@')[0] || 'Пользователь'}
</Text>
)}
<Text style={[styles.messageText, isOwnMessage && styles.ownMessageText]}>
{/* Текст сообщения */}
<Text
style={[
styles.messageText,
{
color: isOwnMessage
? theme.colors.onPrimary
: theme.colors.onSurface
}
]}
>
{item.content}
</Text>
<Text variant="bodySmall" style={[styles.messageTime, isOwnMessage && styles.ownMessageTime]}>
{/* Время и статус */}
<View style={styles.messageFooter}>
<Text
variant="bodySmall"
style={[
styles.messageTime,
{
color: isOwnMessage
? 'rgba(255,255,255,0.7)'
: theme.colors.onSurfaceVariant
}
]}
>
{messageTime}
{item.isEdited && ' • изменено'}
{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}
@ -130,39 +258,172 @@ export const ChatScreen = ({ route }: any) => {
setMenuVisible(false);
setSelectedMessage(null);
}}
anchor={
<IconButton
icon="dots-vertical"
size={16}
onPress={() => {
setSelectedMessage(item.id);
setMenuVisible(true);
}}
style={styles.menuButton}
/>
}
anchor={<View />}
contentStyle={[styles.messageMenuContent, { backgroundColor: theme.colors.surface }]}
>
<Menu.Item onPress={() => handleDeleteMessage(item.id)} title="Удалить" />
<Menu.Item
onPress={() => handleDeleteMessage(item.id)}
title="Удалить"
leadingIcon="delete"
titleStyle={{ color: theme.colors.error }}
/>
<Menu.Item
onPress={() => {
setMenuVisible(false);
setSelectedMessage(null);
}}
title="Редактировать"
leadingIcon="pencil"
titleStyle={{ color: theme.colors.onSurface }}
/>
</Menu>
)}
</Surface>
</View>
</Animated.View>
);
};
if (loading && !data) {
return (
<View style={styles.centerContainer}>
<Text>Загрузка сообщений...</Text>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Загрузка сообщений...</Text>
</View>
);
}
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>
{/* Информация о пользователе */}
<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.container}
style={styles.keyboardAvoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={90}
keyboardVerticalOffset={100}
>
<FlatList
ref={flatListRef}
@ -170,94 +431,435 @@ 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}>
{/* Поле ввода */}
<Animated.View
style={styles.inputContainer}
entering={FadeInUp.delay(400).duration(600)}
>
{/* Основной контейнер ввода */}
<View style={styles.inputWrapper}>
<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="Введите сообщение..."
mode="outlined"
style={styles.input}
placeholder="Сообщение..."
placeholderTextColor="rgba(255,255,255,0.5)"
style={styles.messageInput}
multiline
maxLength={1000}
right={
<TextInput.Icon
icon="send"
onPress={handleSend}
disabled={!message.trim()}
/>
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>
</Animated.View>
</KeyboardAvoidingView>
</BackgroundDesign>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
backgroundColor: '#0a0a0a',
},
centerContainer: {
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',
alignItems: 'center',
backgroundColor: '#0a0a0a',
},
loadingText: {
color: '#666666',
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: {
messageAvatarContainer: {
marginRight: 8,
alignSelf: 'flex-end',
},
messageAvatar: {
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
messageAvatarLabel: {
fontSize: 14,
fontWeight: '600',
},
messageBubbleContainer: {
maxWidth: '75%',
position: 'relative',
},
ownMessageBubbleContainer: {
alignItems: 'flex-end',
},
messageBubble: {
maxWidth: '75%',
padding: 12,
borderRadius: 16,
backgroundColor: '#fff',
borderRadius: 18,
padding: 16,
minWidth: 80,
position: 'relative',
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
ownMessageBubble: {
backgroundColor: '#2196F3',
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: '#666',
fontSize: 12,
fontWeight: '600',
marginBottom: 4,
letterSpacing: 0.3,
},
messageText: {
color: '#000',
fontSize: 16,
lineHeight: 22,
marginBottom: 6,
},
ownMessageText: {
color: '#fff',
messageFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
},
messageTime: {
color: '#666',
marginTop: 4,
fontSize: 11,
fontWeight: '500',
opacity: 0.8,
},
ownMessageTime: {
color: 'rgba(255, 255, 255, 0.7)',
readStatus: {
marginLeft: 4,
},
menuButton: {
position: 'absolute',
top: -8,
right: -8,
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,
},
// Поле ввода
inputContainer: {
padding: 8,
backgroundColor: '#fff',
backgroundColor: 'transparent',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
borderTopColor: 'rgba(255, 255, 255, 0.08)',
},
input: {
maxHeight: 100,
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,
},
leftButtons: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 8,
},
rightButtons: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 8,
},
actionButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
justifyContent: 'center',
alignItems: 'center',
marginHorizontal: 4,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
},
textInputWrapper: {
flex: 1,
backgroundColor: 'rgba(255, 255, 255, 0.06)',
borderRadius: 24,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
paddingHorizontal: 16,
paddingVertical: 4,
minHeight: 40,
maxHeight: 120,
},
messageInput: {
fontSize: 16,
color: '#ffffff',
lineHeight: 20,
textAlignVertical: 'center',
paddingVertical: 8,
minHeight: 32,
},
sendButtonContainer: {
width: 40,
height: 40,
borderRadius: 20,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
sendButtonGradient: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 20,
},
});

View File

@ -1,19 +1,41 @@
import React, { useState, useEffect } from 'react';
import { View, StyleSheet, FlatList, ScrollView, Animated, TouchableOpacity } from 'react-native';
import { Searchbar, List, Avatar, Text, Chip, FAB, Divider, IconButton, SegmentedButtons, Button, ActivityIndicator, Badge } from 'react-native-paper';
import { View, StyleSheet, FlatList, TouchableOpacity, Dimensions } from 'react-native';
import { Searchbar, Avatar, Text, Chip, IconButton, SegmentedButtons, ActivityIndicator, useTheme, Headline } from 'react-native-paper';
import { useQuery, useMutation } from '@apollo/client';
import { GET_USERS } from '../graphql/queries';
import { CREATE_PRIVATE_CONVERSATION } from '../graphql/mutations';
import { User } from '../types';
import { useAuth } from '../contexts/AuthContext';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
FadeInDown,
FadeInRight,
FadeInUp,
useSharedValue,
useAnimatedStyle,
withTiming,
withSpring,
withSequence,
withRepeat,
withDelay,
interpolate,
Easing,
} from 'react-native-reanimated';
import { BackgroundDesign } from '../components/BackgroundDesign';
export const ContactsScreen = ({ navigation }: any) => {
const [searchQuery, setSearchQuery] = useState('');
const [selectedTab, setSelectedTab] = useState('all');
const [creatingChatWithId, setCreatingChatWithId] = useState<string | null>(null);
const { user: currentUser } = useAuth();
const fadeAnim = useState(new Animated.Value(0))[0];
const theme = useTheme();
// Анимации
const headerScale = useSharedValue(0.95);
const headerOpacity = useSharedValue(0);
const listAnimation = useSharedValue(0);
const statsAnimation = useSharedValue(0);
const { data, loading, refetch } = useQuery(GET_USERS);
@ -34,16 +56,20 @@ export const ContactsScreen = ({ navigation }: any) => {
});
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 500,
useNativeDriver: true,
}).start();
// Анимация появления заголовка
headerScale.value = withSpring(1, { damping: 15, stiffness: 100 });
headerOpacity.value = withTiming(1, { duration: 800 });
// Анимация списка
listAnimation.value = withTiming(1, { duration: 600, easing: Easing.out(Easing.ease) });
// Анимация статистики
statsAnimation.value = withDelay(200, withTiming(1, { duration: 500 }));
}, []);
const getOtherParticipantName = (conversation: any) => {
const otherParticipant = conversation.participants?.find((p: User) => p.id !== currentUser?.id);
return otherParticipant?.username || 'Чат';
return otherParticipant?.username || otherParticipant?.email || 'Чат';
};
const handleStartChat = async (userId: string) => {
@ -57,12 +83,36 @@ export const ContactsScreen = ({ navigation }: any) => {
}
};
// Анимированные стили
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ scale: headerScale.value }],
opacity: headerOpacity.value,
};
});
const listAnimatedStyle = useAnimatedStyle(() => {
const translateY = interpolate(listAnimation.value, [0, 1], [30, 0]);
return {
transform: [{ translateY }],
opacity: listAnimation.value,
};
});
const statsAnimatedStyle = useAnimatedStyle(() => {
const scale = interpolate(statsAnimation.value, [0, 1], [0.8, 1]);
return {
transform: [{ scale }],
opacity: statsAnimation.value,
};
});
const filteredUsers = data?.users?.filter((user: User) => {
if (user.id === currentUser?.id) return false;
if (searchQuery) {
return user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email.toLowerCase().includes(searchQuery.toLowerCase());
return user.username?.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email?.toLowerCase().includes(searchQuery.toLowerCase());
}
return true;
@ -73,59 +123,58 @@ export const ContactsScreen = ({ navigation }: any) => {
return (
<Animated.View
style={{
opacity: fadeAnim,
transform: [{
translateY: fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: [50, 0],
}),
}],
}}
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,44 +236,79 @@ 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}>
<Animated.View style={[styles.statsContainer, statsAnimatedStyle]}>
<View style={styles.statItem}>
<Text variant="headlineSmall" style={styles.statNumber}>{totalUsersCount}</Text>
<Text variant="bodySmall" style={styles.statLabel}>пользователей</Text>
<Text variant="headlineSmall" style={[styles.statNumber, { color: theme.colors.onSurface }]}>
{totalUsersCount}
</Text>
<Text variant="bodySmall" style={[styles.statLabel, { color: theme.colors.onSurfaceVariant }]}>
пользователей
</Text>
</View>
<View style={styles.statDivider} />
<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}>в сети</Text>
<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}
@ -232,13 +329,22 @@ export const ContactsScreen = ({ navigation }: any) => {
icon: 'account-star',
},
]}
style={styles.tabs}
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;
// В будущем добавим логику для "Мои контакты"
if (selectedTab === 'online') return user.isOnline === true;
return true;
})}
renderItem={renderUser}
@ -246,60 +352,108 @@ export const ContactsScreen = ({ navigation }: any) => {
onRefresh={refetch}
refreshing={loading}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<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={64}
color="#ccc"
size={48}
color={theme.colors.onSurfaceVariant}
/>
<Text variant="titleMedium" style={styles.emptyText}>
</View>
<Text variant="headlineSmall" style={[styles.emptyText, { color: theme.colors.onSurface }]}>
{searchQuery
? `Пользователь @${searchQuery} не найден`
? `@${searchQuery} не найден`
: selectedTab === 'online'
? 'Нет пользователей онлайн'
: 'Нет доступных пользователей'}
</Text>
{searchQuery && (
<Text variant="bodyMedium" style={[styles.emptyText, { marginTop: 8 }]}>
<Text variant="bodyLarge" style={[styles.emptySubtext, { color: theme.colors.onSurfaceVariant }]}>
Проверьте правильность никнейма
</Text>
)}
</View>
</Animated.View>
}
/>
</View>
</Animated.View>
</BackgroundDesign>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
// Заголовок
headerContainer: {
paddingTop: 60,
paddingHorizontal: 20,
paddingBottom: 20,
},
header: {
backgroundColor: '#ffffff',
paddingBottom: 16,
elevation: 2,
headerCard: {
borderRadius: 24,
padding: 24,
backgroundColor: 'rgba(26, 26, 26, 0.8)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
shadowColor: '#ffffff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 30,
elevation: 20,
},
searchContainer: {
padding: 16,
paddingBottom: 8,
headerGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 24,
},
headerContent: {
alignItems: 'center',
marginBottom: 20,
},
logoContainer: {
marginBottom: 12,
},
logoPlaceholder: {
width: 60,
height: 60,
borderRadius: 15,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
title: {
fontSize: 28,
fontWeight: '300',
letterSpacing: 3,
marginBottom: 8,
},
searchbar: {
backgroundColor: '#f8f9fa',
borderRadius: 12,
paddingHorizontal: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
elevation: 0,
marginBottom: 20,
},
searchHint: {
color: '#666',
marginTop: 8,
textAlign: 'center',
},
// Статистика
statsContainer: {
flexDirection: 'row',
justifyContent: 'center',
paddingHorizontal: 16,
paddingTop: 8,
alignItems: 'center',
},
statItem: {
alignItems: 'center',
@ -308,120 +462,210 @@ const styles = StyleSheet.create({
statDivider: {
width: 1,
height: 40,
backgroundColor: '#e0e0e0',
marginHorizontal: 16,
},
statNumber: {
fontWeight: 'bold',
fontWeight: '600',
fontSize: 24,
},
statLabel: {
color: '#666',
marginTop: 2,
marginTop: 4,
fontSize: 12,
letterSpacing: 0.5,
},
onlineStatContainer: {
flexDirection: 'row',
alignItems: 'center',
},
tabs: {
marginHorizontal: 16,
marginVertical: 16,
},
listContent: {
paddingHorizontal: 16,
// Табы
tabsContainer: {
paddingHorizontal: 20,
paddingBottom: 16,
},
tabs: {
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
// Список
listContainer: {
flex: 1,
paddingHorizontal: 20,
},
listContent: {
flexGrow: 1,
paddingBottom: 100,
},
// Карточка пользователя
userCard: {
backgroundColor: '#ffffff',
borderRadius: 16,
marginBottom: 12,
marginVertical: 6,
borderRadius: 20,
backgroundColor: 'rgba(26, 26, 26, 0.6)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
shadowColor: '#ffffff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.05,
shadowRadius: 15,
elevation: 5,
position: 'relative',
overflow: 'hidden',
},
userCardShadow: {
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
headerShadow: {
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 4,
userGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 20,
},
userContent: {
flexDirection: 'row',
padding: 16,
padding: 20,
alignItems: 'center',
},
avatarContainer: {
position: 'relative',
marginRight: 16,
},
avatarGradient: {
position: 'absolute',
width: 60,
height: 60,
borderRadius: 30,
},
avatar: {
elevation: 2,
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
avatarLabel: {
fontSize: 24,
fontWeight: '600',
},
onlineIndicator: {
position: 'absolute',
bottom: 2,
right: 2,
backgroundColor: '#ffffff',
borderRadius: 7,
padding: 1,
bottom: -2,
right: -2,
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 3,
borderColor: 'rgba(26, 26, 26, 0.8)',
justifyContent: 'center',
alignItems: 'center',
},
onlineBadge: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#4CAF50',
},
userInfo: {
flex: 1,
marginRight: 8,
marginRight: 12,
},
userHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
marginBottom: 6,
},
username: {
fontWeight: '600',
color: '#1a1a1a',
fontSize: 17,
},
onlineChip: {
marginLeft: 8,
backgroundColor: '#e8f5e9',
height: 22,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
backgroundColor: 'rgba(76, 175, 80, 0.15)',
borderWidth: 1,
borderColor: 'rgba(76, 175, 80, 0.3)',
},
onlineChipText: {
fontSize: 11,
color: '#4CAF50',
fontWeight: '500',
},
userBio: {
color: '#666',
lineHeight: 20,
fontSize: 15,
},
lastSeen: {
color: '#999',
marginTop: 4,
fontSize: 12,
},
actionContainer: {
justifyContent: 'center',
minWidth: 48,
alignItems: 'center',
},
loadingContainer: {
width: 44,
height: 44,
justifyContent: 'center',
alignItems: 'center',
},
messageButton: {
margin: 0,
borderRadius: 12,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 4,
},
messageButtonGradient: {
width: 44,
height: 44,
justifyContent: 'center',
alignItems: 'center',
},
// Empty состояние
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: 100,
paddingHorizontal: 40,
paddingTop: 80,
},
emptyIconContainer: {
width: 120,
height: 120,
borderRadius: 60,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 32,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(26, 26, 26, 0.6)',
shadowColor: '#ffffff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 20,
elevation: 10,
position: 'relative',
overflow: 'hidden',
},
emptyIconGradient: {
position: 'absolute',
width: 120,
height: 120,
borderRadius: 60,
},
emptyText: {
color: '#666',
fontSize: 16,
textAlign: 'center',
marginBottom: 12,
fontWeight: '300',
letterSpacing: 0.5,
fontSize: 24,
},
emptySubtext: {
textAlign: 'center',
maxWidth: 280,
fontSize: 16,
lineHeight: 24,
},
});

View File

@ -1,107 +1,457 @@
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';
import { useQuery } from '@apollo/client';
import { GET_CONVERSATIONS } from '../graphql/queries';
import { Conversation } from '../types';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import { useAuth } from '../contexts/AuthContext';
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,
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";
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, // Обновляем каждые 5 секунд
pollInterval: 5000,
});
const formatMessageTime = (date: string) => {
const messageDate = new Date(date);
if (isToday(messageDate)) {
return format(messageDate, "HH:mm", { locale: ru });
} else if (isYesterday(messageDate)) {
return "Вчера";
} else {
return format(messageDate, "dd.MM", { locale: ru });
}
};
// Фильтрация чатов по поисковому запросу
const filteredConversations = data?.conversations?.filter((conv: Conversation) => {
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;
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());
return (
displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
conv.lastMessage?.content
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}) || [];
const renderConversation = ({ item }: { item: Conversation }) => {
const otherParticipant = item.participants.find(p => p.id !== user?.id);
const displayName = item.isGroup ? item.name : otherParticipant?.username;
// Функции статуса
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
? format(new Date(item.lastMessage.createdAt), 'HH:mm', { locale: ru })
: '';
? formatMessageTime(item.lastMessage.createdAt)
: "";
// Подсчет непрочитанных сообщений (в будущем добавить в GraphQL)
const unreadCount = 0;
return (
<Animated.View entering={FadeInDown.delay(index * 50).springify()}>
<TouchableOpacity
onPress={() => navigation.navigate('Chat', { conversationId: item.id, title: displayName })}
onPress={() =>
navigation.navigate("Chat", {
conversationId: item.id,
title: displayName,
})
}
activeOpacity={0.8}
>
<List.Item
title={displayName || 'Без имени'}
description={item.lastMessage?.content || 'Нет сообщений'}
left={() => (
<View>
<Avatar.Text
size={50}
label={displayName?.charAt(0).toUpperCase() || '?'}
<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 }}
/>
{otherParticipant?.isOnline && (
<Badge style={styles.onlineBadge} size={12} />
<View style={styles.avatarContainer}>
<LinearGradient
colors={["rgba(255,255,255,0.1)", "rgba(255,255,255,0.05)"]}
style={styles.avatarGradient}
/>
<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 === true && (
<Badge style={styles.onlineBadge} size={16} />
)}
</View>
)}
right={() => (
<View style={styles.rightContent}>
<Text variant="bodySmall" style={styles.time}>
<View style={styles.contentContainer}>
<View style={styles.headerRow}>
<Text
variant="titleMedium"
style={[
styles.conversationTitle,
{ color: theme.colors.onSurface },
]}
numberOfLines={1}
>
{displayName || "Без имени"}
</Text>
<Text
variant="bodySmall"
style={[
styles.time,
{ color: theme.colors.onSurfaceVariant },
]}
>
{lastMessageTime}
</Text>
</View>
<View style={styles.messageRow}>
<Text
variant="bodyMedium"
style={[
styles.lastMessage,
{ color: theme.colors.onSurfaceVariant },
]}
numberOfLines={2}
>
{item.lastMessage?.content || "Нет сообщений"}
</Text>
{unreadCount > 0 && (
<Badge style={styles.unreadBadge}>{unreadCount}</Badge>
)}
</View>
)}
style={styles.listItem}
/>
<Divider />
</View>
</View>
</TouchableOpacity>
</Animated.View>
);
};
if (loading && !data) {
return (
<View style={styles.centerContainer}>
<Text>Загрузка...</Text>
<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.centerContainer}>
<Text>Ошибка загрузки чатов</Text>
<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 (
<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}
@ -109,92 +459,437 @@ export const ConversationsScreen = ({ navigation }: any) => {
onRefresh={refetch}
refreshing={loading}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text variant="bodyLarge" style={styles.emptyText}>
У вас пока нет чатов
<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="bodyMedium" style={styles.emptySubtext}>
<Text
variant="bodyLarge"
style={[
styles.emptySubtext,
{ color: theme.colors.onSurfaceVariant },
]}
>
Начните новый чат, нажав на кнопку внизу
</Text>
</View>
</Animated.View>
}
/>
<FAB
icon="plus"
style={styles.fab}
onPress={() => navigation.navigate('Contacts')}
</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>
<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 }}
>
<MaterialCommunityIcons
name="pencil"
size={28}
color={theme.colors.onPrimary}
/>
</LinearGradient>
</TouchableOpacity>
</Animated.View>
</BackgroundDesign>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
searchContainer: {
padding: 16,
// Loading состояние
loadingContainer: {
flex: 1,
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: {
fontSize: 18,
fontWeight: "300",
letterSpacing: 1,
},
// Error состояние
errorContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
},
errorCard: {
borderRadius: 24,
padding: 40,
backgroundColor: "rgba(26, 26, 26, 0.8)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
alignItems: "center",
shadowColor: "#ffffff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 30,
elevation: 20,
},
errorGradient: {
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 24,
},
errorText: {
fontSize: 18,
marginBottom: 24,
textAlign: "center",
fontWeight: "300",
letterSpacing: 1,
},
retryButton: {
borderRadius: 12,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
},
retryGradient: {
paddingVertical: 16,
paddingHorizontal: 32,
alignItems: "center",
},
retryText: {
fontSize: 16,
fontWeight: "700",
letterSpacing: 2,
},
// Заголовок
headerContainer: {
paddingTop: 60,
paddingHorizontal: 20,
paddingBottom: 8,
backgroundColor: '#ffffff',
elevation: 2,
},
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: "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,
backgroundColor: '#f5f5f5',
},
centerContainer: {
// Список
listContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20,
},
listContent: {
flexGrow: 1,
paddingBottom: 8,
},
listItem: {
paddingVertical: 8,
conversationItem: {
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.1)",
shadowColor: "#ffffff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.05,
shadowRadius: 15,
elevation: 5,
position: "relative",
overflow: "hidden",
},
rightContent: {
alignItems: 'flex-end',
justifyContent: 'center',
conversationGradient: {
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 20,
},
time: {
color: '#666',
fontSize: 12,
marginBottom: 4,
avatarContainer: {
position: "relative",
marginRight: 16,
},
unreadBadge: {
backgroundColor: '#2196F3',
color: '#ffffff',
fontSize: 12,
avatarGradient: {
position: "absolute",
width: 56,
height: 56,
borderRadius: 28,
},
avatar: {
borderWidth: 2,
borderColor: "rgba(255, 255, 255, 0.2)",
},
avatarLabel: {
fontSize: 22,
fontWeight: "600",
},
onlineBadge: {
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: '#4CAF50',
position: "absolute",
bottom: -2,
right: -2,
backgroundColor: "#4CAF50",
borderWidth: 3,
borderColor: "rgba(26, 26, 26, 0.8)",
},
contentContainer: {
flex: 1,
justifyContent: "center",
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
},
conversationTitle: {
fontWeight: "600",
flex: 1,
marginRight: 12,
fontSize: 17,
},
time: {
fontSize: 13,
fontWeight: "500",
},
messageRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
lastMessage: {
flex: 1,
fontSize: 15,
},
unreadBadge: {
backgroundColor: "#2196F3",
color: "#ffffff",
fontSize: 12,
marginLeft: 8,
},
// Разделитель
dividerContainer: {
paddingHorizontal: 20,
marginTop: 4,
marginBottom: 4,
},
divider: {
height: 1,
borderRadius: 0.5,
},
// FAB
fabContainer: {
position: "absolute",
right: 20,
bottom: 100,
borderRadius: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
},
fab: {
position: 'absolute',
margin: 16,
right: 0,
bottom: 0,
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: 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: 8,
color: '#666',
textAlign: "center",
marginBottom: 12,
fontWeight: "300",
letterSpacing: 0.5,
fontSize: 24,
},
emptySubtext: {
textAlign: 'center',
color: '#999',
textAlign: "center",
maxWidth: 280,
fontSize: 16,
lineHeight: 24,
},
});

View File

@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions } from 'react-native';
import { TextInput, Button, Text, Headline, HelperText, useTheme } from 'react-native-paper';
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableOpacity } from 'react-native';
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';
import { BackgroundDesign } from '../components/BackgroundDesign';
import Animated, {
useSharedValue,
useAnimatedStyle,
@ -14,10 +15,13 @@ import Animated, {
withRepeat,
interpolate,
Easing,
FadeIn,
FadeInDown,
FadeInUp,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
const { width: screenWidth } = Dimensions.get('window');
const AnimatedView = Animated.View;
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
export const LoginScreen = ({ navigation }: any) => {
const [username, setUsername] = useState('');
@ -27,25 +31,21 @@ export const LoginScreen = ({ navigation }: any) => {
const theme = useTheme();
// Анимации
const translateY = useSharedValue(50);
const opacity = useSharedValue(0);
const scale = useSharedValue(0.9);
const cardScale = useSharedValue(0.95);
const cardOpacity = useSharedValue(0);
const glowAnimation = useSharedValue(0);
const buttonScale = useSharedValue(1);
const inputFocusAnimation1 = useSharedValue(0);
const inputFocusAnimation2 = useSharedValue(0);
useEffect(() => {
// Анимация появления
translateY.value = withSpring(0, { damping: 15, stiffness: 100 });
opacity.value = withTiming(1, { duration: 800 });
scale.value = withSpring(1, { damping: 15, stiffness: 100 });
// Анимация появления карточки
cardScale.value = withSpring(1, { damping: 15, stiffness: 100 });
cardOpacity.value = withTiming(1, { duration: 800 });
// Пульсирующее свечение
// Мягкое свечение
glowAnimation.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.ease) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.ease) })
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.ease) }),
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.ease) })
),
-1,
false
@ -67,24 +67,20 @@ export const LoginScreen = ({ navigation }: any) => {
};
// Анимированные стили
const containerAnimatedStyle = useAnimatedStyle(() => {
const cardAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{ translateY: translateY.value },
{ scale: scale.value }
],
opacity: opacity.value,
transform: [{ scale: cardScale.value }],
opacity: cardOpacity.value,
};
});
const glowContainerStyle = useAnimatedStyle(() => {
const glowOpacity = interpolate(glowAnimation.value, [0, 1], [0.3, 0.8]);
const shadowRadius = interpolate(glowAnimation.value, [0, 1], [10, 30]);
const glowStyle = useAnimatedStyle(() => {
const shadowOpacity = interpolate(glowAnimation.value, [0, 1], [0.1, 0.3]);
const shadowRadius = interpolate(glowAnimation.value, [0, 1], [20, 40]);
return {
shadowOpacity: glowOpacity,
shadowOpacity: shadowOpacity,
shadowRadius: shadowRadius,
elevation: interpolate(glowAnimation.value, [0, 1], [5, 15]),
};
});
@ -94,22 +90,6 @@ export const LoginScreen = ({ navigation }: any) => {
};
});
const createInputAnimatedStyle = (focusAnimation: any) => {
return useAnimatedStyle(() => {
const borderWidth = interpolate(focusAnimation.value, [0, 1], [1, 2]);
const shadowOpacity = interpolate(focusAnimation.value, [0, 1], [0, 0.6]);
return {
borderWidth: borderWidth,
shadowOpacity: shadowOpacity,
elevation: interpolate(focusAnimation.value, [0, 1], [2, 8]),
};
});
};
const inputStyle1 = createInputAnimatedStyle(inputFocusAnimation1);
const inputStyle2 = createInputAnimatedStyle(inputFocusAnimation2);
const handleButtonPressIn = () => {
buttonScale.value = withSpring(0.95);
};
@ -119,59 +99,77 @@ export const LoginScreen = ({ navigation }: any) => {
};
return (
<BackgroundDesign variant="login">
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: theme.colors.background }]}
style={styles.container}
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
style={[styles.content, cardAnimatedStyle]}
entering={FadeInDown.duration(800).springify()}
>
<Animated.View style={[styles.loginCard, glowStyle]}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Animated.View
entering={FadeInUp.delay(200).duration(600)}
style={styles.headerContainer}
>
<View style={styles.logoContainer}>
<View style={[styles.logoPlaceholder, { backgroundColor: theme.colors.surfaceVariant }]}>
<Text style={[styles.logoText, { color: theme.colors.onSurface }]}>P</Text>
</View>
</View>
<Headline style={[styles.title, { color: theme.colors.onSurface }]}>PRISM</Headline>
</Animated.View>
<AnimatedView style={inputStyle1}>
<Animated.View
entering={FadeIn.delay(400).duration(600)}
style={styles.formContainer}
>
<TextInput
label="Имя пользователя"
value={username}
onChangeText={setUsername}
mode="outlined"
style={styles.input}
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.secondary,
placeholder: theme.colors.onSurfaceVariant,
text: theme.colors.onSurface,
background: theme.colors.surface,
background: 'transparent',
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}
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.secondary,
placeholder: theme.colors.onSurfaceVariant,
text: theme.colors.onSurface,
background: theme.colors.surface,
background: 'transparent',
outline: theme.colors.outline,
}
}}
@ -179,69 +177,72 @@ export const LoginScreen = ({ navigation }: any) => {
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
color="#a855f7"
color={theme.colors.onSurfaceVariant}
/>
}
onFocus={() => {
inputFocusAnimation2.value = withSpring(1);
}}
onBlur={() => {
inputFocusAnimation2.value = withSpring(0);
}}
/>
</AnimatedView>
{error && (
<HelperText type="error" visible={true} style={styles.errorText}>
<HelperText type="error" visible={true} style={[styles.errorText, { color: theme.colors.error }]}>
{error.message}
</HelperText>
)}
<AnimatedView style={buttonAnimatedStyle}>
<Button
mode="contained"
<Animated.View style={buttonAnimatedStyle}>
<TouchableOpacity
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',
}
}}
activeOpacity={0.8}
>
Войти
</Button>
</AnimatedView>
<LinearGradient
colors={[theme.colors.primary, theme.colors.primaryContainer]}
style={[
styles.gradientButton,
(loading || !username || !password) && styles.disabledButton
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={[styles.buttonText, { color: theme.colors.onPrimary }]}>
{loading ? 'ВХОД...' : 'ВОЙТИ'}
</Text>
</LinearGradient>
</TouchableOpacity>
</Animated.View>
<Button
mode="text"
<View style={styles.dividerContainer}>
<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
onPress={() => navigation.navigate('Register')}
disabled={loading}
style={styles.linkButton}
labelStyle={styles.linkButtonLabel}
theme={{
colors: {
primary: '#a855f7',
}
}}
activeOpacity={0.7}
>
Нет аккаунта? Зарегистрироваться
</Button>
<Text style={[styles.linkButtonText, { color: theme.colors.onSurfaceVariant }]}>
Нет аккаунта?
</Text>
<Text style={[styles.linkButtonTextBold, { color: theme.colors.primary }]}>
{' Создать'}
</Text>
</TouchableOpacity>
</Animated.View>
</Animated.View>
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
</BackgroundDesign>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0a0a0f',
},
scrollContent: {
flexGrow: 1,
@ -254,90 +255,122 @@ const styles = StyleSheet.create({
width: '100%',
alignSelf: 'center',
},
glowContainer: {
marginBottom: 40,
padding: 20,
borderRadius: 20,
backgroundColor: '#1a1a2e',
loginCard: {
borderRadius: 24,
padding: 32,
backgroundColor: 'rgba(26, 26, 26, 0.8)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
// iOS тени
shadowColor: '#9333ea',
shadowColor: '#ffffff',
shadowOffset: {
width: 0,
height: 0,
},
shadowOpacity: 0.5,
shadowRadius: 20,
shadowOpacity: 0.1,
shadowRadius: 30,
// Android тень
elevation: 10,
elevation: 20,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 24,
},
headerContainer: {
alignItems: 'center',
marginBottom: 40,
},
logoContainer: {
marginBottom: 20,
},
logoPlaceholder: {
width: 80,
height: 80,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
logoText: {
fontSize: 40,
fontWeight: 'bold',
},
title: {
textAlign: 'center',
marginBottom: 10,
fontSize: 32,
fontWeight: 'bold',
color: '#ffffff',
textShadowColor: '#9333ea',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
fontSize: 36,
fontWeight: '300',
letterSpacing: 4,
marginBottom: 8,
},
subtitle: {
textAlign: 'center',
fontSize: 16,
color: '#a855f7',
fontStyle: 'italic',
formContainer: {
width: '100%',
},
input: {
marginBottom: 20,
backgroundColor: '#1a1a2e',
fontSize: 16,
borderRadius: 12,
paddingHorizontal: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
errorText: {
textAlign: 'center',
marginBottom: 16,
fontSize: 14,
},
gradientButton: {
paddingVertical: 16,
paddingHorizontal: 32,
borderRadius: 12,
alignItems: 'center',
marginTop: 8,
// iOS тени
shadowColor: '#7c3aed',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 6,
shadowRadius: 12,
// Android тень
elevation: 5,
elevation: 8,
},
button: {
marginTop: 20,
marginBottom: 10,
borderRadius: 12,
backgroundColor: '#9333ea',
// iOS тени для кнопки
shadowColor: '#9333ea',
shadowOffset: {
width: 0,
height: 8,
disabledButton: {
opacity: 0.5,
},
shadowOpacity: 0.6,
shadowRadius: 15,
// Android тень
elevation: 10,
buttonText: {
fontSize: 16,
fontWeight: '700',
letterSpacing: 2,
},
buttonContent: {
paddingVertical: 8,
dividerContainer: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 24,
},
buttonLabel: {
fontSize: 18,
fontWeight: 'bold',
divider: {
flex: 1,
height: 1,
},
dividerText: {
paddingHorizontal: 16,
fontSize: 12,
letterSpacing: 1,
},
linkButton: {
marginTop: 10,
flexDirection: 'row',
justifyContent: 'center',
paddingVertical: 12,
},
linkButtonLabel: {
fontSize: 16,
color: '#a855f7',
linkButtonText: {
fontSize: 14,
},
errorText: {
color: '#ef4444',
textAlign: 'center',
marginBottom: 10,
textShadowColor: '#ef4444',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 5,
linkButtonTextBold: {
fontSize: 14,
fontWeight: '600',
},
});

View File

@ -0,0 +1,570 @@
import React, { useState, useEffect } from 'react';
import { View, StyleSheet, FlatList, TouchableOpacity, Share, Alert } from 'react-native';
import { Searchbar, Avatar, Text, useTheme, Headline, IconButton, Divider } from 'react-native-paper';
import { useQuery, useMutation } from '@apollo/client';
import { GET_USERS } from '../graphql/queries';
import { CREATE_PRIVATE_CONVERSATION } from '../graphql/mutations';
import { User } from '../types';
import { useAuth } from '../contexts/AuthContext';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
FadeInDown,
FadeInUp,
useSharedValue,
useAnimatedStyle,
withTiming,
withSpring,
interpolate,
Easing,
} from 'react-native-reanimated';
import { BackgroundDesign } from '../components/BackgroundDesign';
export const NewMessageScreen = ({ navigation }: any) => {
const [searchQuery, setSearchQuery] = useState('');
const { user: currentUser } = useAuth();
const theme = useTheme();
// Анимации
const headerScale = useSharedValue(0.95);
const headerOpacity = useSharedValue(0);
const listAnimation = useSharedValue(0);
useEffect(() => {
// Анимация появления
headerScale.value = withSpring(1, { damping: 15, stiffness: 100 });
headerOpacity.value = withTiming(1, { duration: 800 });
listAnimation.value = withTiming(1, { duration: 600, easing: Easing.out(Easing.ease) });
}, []);
const { data, loading } = useQuery(GET_USERS);
const [createConversation] = useMutation(CREATE_PRIVATE_CONVERSATION, {
onCompleted: (data) => {
navigation.navigate('Chat', {
conversationId: data.createPrivateConversation.id,
title: getOtherParticipantName(data.createPrivateConversation),
});
},
});
const getOtherParticipantName = (conversation: any) => {
const otherParticipant = conversation.participants?.find((p: User) => p.id !== currentUser?.id);
return otherParticipant?.username || otherParticipant?.email || 'Чат';
};
const handleStartChat = async (userId: string) => {
try {
await createConversation({
variables: { recipientId: userId },
});
} catch (error) {
console.error('Error creating conversation:', error);
}
};
const handleCreateGroup = () => {
Alert.alert('Новый групповой чат', 'Функция в разработке');
};
const handleCreateChannel = () => {
Alert.alert('Новый канал', 'Функция в разработке');
};
const handleInviteFriends = async () => {
try {
await Share.share({
message: 'Присоединяйся ко мне в Prism Messenger! Скачай приложение и давай общаться.',
title: 'Приглашение в Prism',
});
} catch (error) {
console.error('Error sharing:', error);
}
};
const filteredUsers = data?.users?.filter((user: User) => {
if (user.id === currentUser?.id) return false;
if (searchQuery) {
return user.username?.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email?.toLowerCase().includes(searchQuery.toLowerCase());
}
return true;
}) || [];
// Анимированные стили
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ scale: headerScale.value }],
opacity: headerOpacity.value,
};
});
const listAnimatedStyle = useAnimatedStyle(() => {
const translateY = interpolate(listAnimation.value, [0, 1], [30, 0]);
return {
transform: [{ translateY }],
opacity: listAnimation.value,
};
});
const renderUser = ({ item, index }: { item: User; index: number }) => {
return (
<Animated.View
entering={FadeInDown.delay(index * 50).springify()}
>
<TouchableOpacity
activeOpacity={0.8}
onPress={() => handleStartChat(item.id)}
>
<View style={styles.userCard}>
<LinearGradient
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']}
style={styles.userGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.userContent}>
<View style={styles.avatarContainer}>
<Avatar.Text
size={56}
label={(item.username || item.email || item.id).charAt(0).toUpperCase()}
style={[styles.avatar, { backgroundColor: getAvatarColor(item.username || item.email || item.id) }]}
labelStyle={[styles.avatarLabel, { color: '#ffffff' }]}
/>
{item.isOnline === true && (
<View style={styles.onlineIndicator}>
<View style={styles.onlineBadge} />
</View>
)}
</View>
<View style={styles.userInfo}>
<Text variant="titleMedium" style={[styles.username, { color: theme.colors.onSurface }]}>
@{item.username || item.email?.split('@')[0] || item.id.slice(0, 8)}
</Text>
<Text variant="bodyMedium" style={[styles.userBio, { color: theme.colors.onSurfaceVariant }]} numberOfLines={1}>
{item.bio || item.email || 'Пользователь'}
</Text>
{item.isOnline === true && (
<View style={styles.onlineChip}>
<Text style={[styles.onlineChipText, { color: '#4CAF50' }]}>В сети</Text>
</View>
)}
</View>
<MaterialCommunityIcons
name="chevron-right"
size={24}
color={theme.colors.onSurfaceVariant}
/>
</View>
</View>
</TouchableOpacity>
</Animated.View>
);
};
const getAvatarColor = (identifier: string) => {
const colors = ['#2196F3', '#4CAF50', '#FF5722', '#9C27B0', '#FF9800', '#00BCD4'];
const index = identifier.charCodeAt(0) % colors.length;
return colors[index];
};
return (
<BackgroundDesign variant="chat">
{/* Заголовок */}
<Animated.View
style={[styles.headerContainer, headerAnimatedStyle]}
entering={FadeInUp.delay(200).duration(600)}
>
<View style={styles.headerCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.topRow}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="arrow-left" size={24} color={theme.colors.onSurface} />
</TouchableOpacity>
<Headline style={[styles.title, { color: theme.colors.onSurface }]}>НОВОЕ СООБЩЕНИЕ</Headline>
<View style={styles.placeholder} />
</View>
{/* Поисковая строка */}
<Searchbar
placeholder="Поиск по @username..."
onChangeText={setSearchQuery}
value={searchQuery}
style={[styles.searchbar, { backgroundColor: theme.colors.surfaceVariant }]}
inputStyle={{ color: theme.colors.onSurface }}
placeholderTextColor={theme.colors.onSurfaceVariant}
iconColor={theme.colors.onSurfaceVariant}
theme={{
colors: {
primary: theme.colors.primary,
text: theme.colors.onSurface,
placeholder: theme.colors.onSurfaceVariant,
}
}}
/>
</View>
</Animated.View>
{/* Опции создания */}
<Animated.View style={[styles.optionsContainer, listAnimatedStyle]}>
<View style={styles.optionsCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.optionsGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<TouchableOpacity style={styles.option} onPress={handleCreateGroup} activeOpacity={0.8}>
<View style={styles.optionIcon}>
<MaterialCommunityIcons name="account-group" size={24} color={theme.colors.primary} />
</View>
<View style={styles.optionContent}>
<Text style={[styles.optionTitle, { color: theme.colors.onSurface }]}>Новый групповой чат</Text>
<Text style={[styles.optionSubtitle, { color: theme.colors.onSurfaceVariant }]}>Создать группу до 256 участников</Text>
</View>
</TouchableOpacity>
<Divider style={[styles.optionDivider, { backgroundColor: theme.colors.outlineVariant }]} />
<TouchableOpacity style={styles.option} onPress={handleCreateChannel} activeOpacity={0.8}>
<View style={styles.optionIcon}>
<MaterialCommunityIcons name="bullhorn" size={24} color={theme.colors.primary} />
</View>
<View style={styles.optionContent}>
<Text style={[styles.optionTitle, { color: theme.colors.onSurface }]}>Новый канал</Text>
<Text style={[styles.optionSubtitle, { color: theme.colors.onSurfaceVariant }]}>Для публичных объявлений</Text>
</View>
</TouchableOpacity>
<Divider style={[styles.optionDivider, { backgroundColor: theme.colors.outlineVariant }]} />
<TouchableOpacity style={styles.option} onPress={handleInviteFriends} activeOpacity={0.8}>
<View style={styles.optionIcon}>
<MaterialCommunityIcons name="account-plus" size={24} color={theme.colors.primary} />
</View>
<View style={styles.optionContent}>
<Text style={[styles.optionTitle, { color: theme.colors.onSurface }]}>Пригласить в мессенджер</Text>
<Text style={[styles.optionSubtitle, { color: theme.colors.onSurfaceVariant }]}>Поделиться приглашением</Text>
</View>
</TouchableOpacity>
</View>
</Animated.View>
{/* Список пользователей */}
<Animated.View style={[styles.listContainer, listAnimatedStyle]}>
<FlatList
data={filteredUsers}
renderItem={renderUser}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<Animated.View
style={styles.emptyContainer}
entering={FadeInDown.duration(600)}
>
<View style={styles.emptyIconContainer}>
<LinearGradient
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']}
style={styles.emptyIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<MaterialCommunityIcons
name={searchQuery ? 'account-search' : 'account-group-outline'}
size={48}
color={theme.colors.onSurfaceVariant}
/>
</View>
<Text variant="headlineSmall" style={[styles.emptyText, { color: theme.colors.onSurface }]}>
{searchQuery
? `@${searchQuery} не найден`
: 'Загружаем пользователей...'}
</Text>
{searchQuery && (
<Text variant="bodyLarge" style={[styles.emptySubtext, { color: theme.colors.onSurfaceVariant }]}>
Проверьте правильность никнейма
</Text>
)}
</Animated.View>
}
/>
</Animated.View>
</BackgroundDesign>
);
};
const styles = StyleSheet.create({
// Заголовок
headerContainer: {
paddingTop: 70,
paddingHorizontal: 20,
paddingBottom: 16,
},
headerCard: {
borderRadius: 24,
padding: 16,
backgroundColor: 'rgba(26, 26, 26, 0.8)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
shadowColor: '#ffffff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 30,
elevation: 20,
},
headerGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 24,
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
backButton: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
title: {
fontSize: 20,
fontWeight: '300',
letterSpacing: 1,
flex: 1,
textAlign: 'center',
},
placeholder: {
width: 44,
},
searchbar: {
borderRadius: 12,
paddingHorizontal: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
elevation: 0,
},
// Опции
optionsContainer: {
paddingHorizontal: 20,
paddingBottom: 16,
},
optionsCard: {
borderRadius: 20,
backgroundColor: 'rgba(26, 26, 26, 0.6)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
shadowColor: '#ffffff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.05,
shadowRadius: 15,
elevation: 5,
position: 'relative',
overflow: 'hidden',
},
optionsGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 20,
},
option: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
},
optionIcon: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: 'rgba(33, 150, 243, 0.1)',
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
optionContent: {
flex: 1,
},
optionTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 2,
},
optionSubtitle: {
fontSize: 14,
},
optionDivider: {
marginHorizontal: 20,
height: 1,
},
// Список
listContainer: {
flex: 1,
paddingHorizontal: 20,
},
listContent: {
flexGrow: 1,
paddingBottom: 100,
},
// Карточка пользователя
userCard: {
marginVertical: 6,
borderRadius: 20,
backgroundColor: 'rgba(26, 26, 26, 0.6)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
shadowColor: '#ffffff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.05,
shadowRadius: 15,
elevation: 5,
position: 'relative',
overflow: 'hidden',
},
userGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 20,
},
userContent: {
flexDirection: 'row',
padding: 16,
alignItems: 'center',
},
avatarContainer: {
position: 'relative',
marginRight: 16,
},
avatar: {
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
avatarLabel: {
fontSize: 20,
fontWeight: '600',
},
onlineIndicator: {
position: 'absolute',
bottom: -2,
right: -2,
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 3,
borderColor: 'rgba(26, 26, 26, 0.8)',
justifyContent: 'center',
alignItems: 'center',
},
onlineBadge: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#4CAF50',
},
userInfo: {
flex: 1,
},
username: {
fontWeight: '600',
fontSize: 16,
marginBottom: 4,
},
userBio: {
fontSize: 14,
marginBottom: 4,
},
onlineChip: {
alignSelf: 'flex-start',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
backgroundColor: 'rgba(76, 175, 80, 0.15)',
borderWidth: 1,
borderColor: 'rgba(76, 175, 80, 0.3)',
},
onlineChipText: {
fontSize: 11,
fontWeight: '500',
},
// Empty состояние
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 40,
paddingTop: 80,
},
emptyIconContainer: {
width: 120,
height: 120,
borderRadius: 60,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 32,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(26, 26, 26, 0.6)',
shadowColor: '#ffffff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 20,
elevation: 10,
position: 'relative',
overflow: 'hidden',
},
emptyIconGradient: {
position: 'absolute',
width: 120,
height: 120,
borderRadius: 60,
},
emptyText: {
textAlign: 'center',
marginBottom: 12,
fontWeight: '300',
letterSpacing: 0.5,
fontSize: 20,
},
emptySubtext: {
textAlign: 'center',
maxWidth: 280,
fontSize: 16,
lineHeight: 24,
},
});

View File

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

View File

@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions } from 'react-native';
import { TextInput, Button, Text, Headline, HelperText } from 'react-native-paper';
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableOpacity } from 'react-native';
import { TextInput, Button, Text, Headline, HelperText, Surface } from 'react-native-paper';
import { useMutation } from '@apollo/client';
import { REGISTER } from '../graphql/mutations';
import { useAuth } from '../contexts/AuthContext';
import { BackgroundDesign } from '../components/BackgroundDesign';
import Animated, {
useSharedValue,
useAnimatedStyle,
@ -14,10 +15,13 @@ import Animated, {
withRepeat,
interpolate,
Easing,
FadeIn,
FadeInDown,
FadeInUp,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
const { width: screenWidth } = Dimensions.get('window');
const AnimatedView = Animated.View;
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
export const RegisterScreen = ({ navigation }: any) => {
const [username, setUsername] = useState('');
@ -28,27 +32,21 @@ export const RegisterScreen = ({ navigation }: any) => {
const { login } = useAuth();
// Анимации
const translateY = useSharedValue(50);
const opacity = useSharedValue(0);
const scale = useSharedValue(0.9);
const cardScale = useSharedValue(0.95);
const cardOpacity = useSharedValue(0);
const glowAnimation = useSharedValue(0);
const buttonScale = useSharedValue(1);
const inputFocusAnimation1 = useSharedValue(0);
const inputFocusAnimation2 = useSharedValue(0);
const inputFocusAnimation3 = useSharedValue(0);
const inputFocusAnimation4 = useSharedValue(0);
useEffect(() => {
// Анимация появления
translateY.value = withSpring(0, { damping: 15, stiffness: 100 });
opacity.value = withTiming(1, { duration: 800 });
scale.value = withSpring(1, { damping: 15, stiffness: 100 });
// Анимация появления карточки
cardScale.value = withSpring(1, { damping: 15, stiffness: 100 });
cardOpacity.value = withTiming(1, { duration: 800 });
// Пульсирующее свечение
// Мягкое свечение
glowAnimation.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.ease) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.ease) })
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.ease) }),
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.ease) })
),
-1,
false
@ -72,24 +70,20 @@ export const RegisterScreen = ({ navigation }: any) => {
const passwordsMatch = password === confirmPassword || confirmPassword === '';
// Анимированные стили
const containerAnimatedStyle = useAnimatedStyle(() => {
const cardAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{ translateY: translateY.value },
{ scale: scale.value }
],
opacity: opacity.value,
transform: [{ scale: cardScale.value }],
opacity: cardOpacity.value,
};
});
const glowContainerStyle = useAnimatedStyle(() => {
const glowOpacity = interpolate(glowAnimation.value, [0, 1], [0.3, 0.8]);
const shadowRadius = interpolate(glowAnimation.value, [0, 1], [10, 30]);
const glowStyle = useAnimatedStyle(() => {
const shadowOpacity = interpolate(glowAnimation.value, [0, 1], [0.1, 0.3]);
const shadowRadius = interpolate(glowAnimation.value, [0, 1], [20, 40]);
return {
shadowOpacity: glowOpacity,
shadowOpacity: shadowOpacity,
shadowRadius: shadowRadius,
elevation: interpolate(glowAnimation.value, [0, 1], [5, 15]),
};
});
@ -99,23 +93,7 @@ export const RegisterScreen = ({ navigation }: any) => {
};
});
const createInputAnimatedStyle = (focusAnimation: any) => {
return useAnimatedStyle(() => {
const borderWidth = interpolate(focusAnimation.value, [0, 1], [1, 2]);
const shadowOpacity = interpolate(focusAnimation.value, [0, 1], [0, 0.6]);
return {
borderWidth: borderWidth,
shadowOpacity: shadowOpacity,
elevation: interpolate(focusAnimation.value, [0, 1], [2, 8]),
};
});
};
const inputStyle1 = createInputAnimatedStyle(inputFocusAnimation1);
const inputStyle2 = createInputAnimatedStyle(inputFocusAnimation2);
const inputStyle3 = createInputAnimatedStyle(inputFocusAnimation3);
const inputStyle4 = createInputAnimatedStyle(inputFocusAnimation4);
const handleButtonPressIn = () => {
buttonScale.value = withSpring(0.95);
@ -126,136 +104,130 @@ export const RegisterScreen = ({ navigation }: any) => {
};
return (
<BackgroundDesign variant="login">
<KeyboardAvoidingView
style={styles.container}
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
style={[styles.content, cardAnimatedStyle]}
entering={FadeInDown.duration(800).springify()}
>
<Animated.View style={[styles.registerCard, glowStyle]}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Animated.View
entering={FadeInUp.delay(200).duration(600)}
style={styles.headerContainer}
>
<Headline style={styles.title}>РЕГИСТРАЦИЯ</Headline>
</Animated.View>
<AnimatedView style={inputStyle1}>
<Animated.View
entering={FadeIn.delay(400).duration(600)}
style={styles.formContainer}
>
<TextInput
label="Имя пользователя"
value={username}
onChangeText={setUsername}
mode="outlined"
mode="flat"
style={styles.input}
autoCapitalize="none"
disabled={loading}
theme={{
colors: {
primary: '#9333ea',
placeholder: '#a855f7',
primary: '#ffffff',
placeholder: '#808080',
text: '#ffffff',
background: '#1a1a2e',
outline: '#7c3aed',
background: 'rgba(255,255,255,0.05)',
outline: '#666666',
}
}}
onFocus={() => {
inputFocusAnimation1.value = withSpring(1);
}}
onBlur={() => {
inputFocusAnimation1.value = withSpring(0);
}}
underlineColor="transparent"
activeUnderlineColor="#ffffff"
/>
</AnimatedView>
<AnimatedView style={inputStyle2}>
<TextInput
label="Email"
label="Электронная почта"
value={email}
onChangeText={setEmail}
mode="outlined"
mode="flat"
style={styles.input}
keyboardType="email-address"
autoCapitalize="none"
disabled={loading}
theme={{
colors: {
primary: '#9333ea',
placeholder: '#a855f7',
primary: '#ffffff',
placeholder: '#808080',
text: '#ffffff',
background: '#1a1a2e',
outline: '#7c3aed',
background: 'rgba(255,255,255,0.05)',
outline: '#666666',
}
}}
onFocus={() => {
inputFocusAnimation2.value = withSpring(1);
}}
onBlur={() => {
inputFocusAnimation2.value = withSpring(0);
}}
underlineColor="transparent"
activeUnderlineColor="#ffffff"
/>
</AnimatedView>
<AnimatedView style={inputStyle3}>
<TextInput
label="Пароль"
value={password}
onChangeText={setPassword}
mode="outlined"
mode="flat"
style={styles.input}
secureTextEntry={!showPassword}
disabled={loading}
theme={{
colors: {
primary: '#9333ea',
placeholder: '#a855f7',
primary: '#ffffff',
placeholder: '#808080',
text: '#ffffff',
background: '#1a1a2e',
outline: '#7c3aed',
background: 'rgba(255,255,255,0.05)',
outline: '#666666',
}
}}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
color="#a855f7"
color="#808080"
/>
}
onFocus={() => {
inputFocusAnimation3.value = withSpring(1);
}}
onBlur={() => {
inputFocusAnimation3.value = withSpring(0);
}}
underlineColor="transparent"
activeUnderlineColor="#ffffff"
/>
</AnimatedView>
<AnimatedView style={inputStyle4}>
<TextInput
label="Подтвердите пароль"
value={confirmPassword}
onChangeText={setConfirmPassword}
mode="outlined"
mode="flat"
style={styles.input}
secureTextEntry={!showPassword}
disabled={loading}
error={!passwordsMatch}
theme={{
colors: {
primary: '#9333ea',
placeholder: '#a855f7',
primary: '#ffffff',
placeholder: '#808080',
text: '#ffffff',
background: '#1a1a2e',
outline: '#7c3aed',
error: '#ef4444',
background: 'rgba(255,255,255,0.05)',
outline: passwordsMatch ? '#666666' : '#ff6b6b',
error: '#ff6b6b',
}
}}
onFocus={() => {
inputFocusAnimation4.value = withSpring(1);
}}
onBlur={() => {
inputFocusAnimation4.value = withSpring(0);
}}
underlineColor="transparent"
activeUnderlineColor="#ffffff"
/>
</AnimatedView>
{!passwordsMatch && (
{!passwordsMatch && confirmPassword !== '' && (
<HelperText type="error" visible={true} style={styles.errorText}>
Пароли не совпадают
</HelperText>
@ -267,51 +239,61 @@ export const RegisterScreen = ({ navigation }: any) => {
</HelperText>
)}
<AnimatedView style={buttonAnimatedStyle}>
<Button
mode="contained"
<Animated.View style={buttonAnimatedStyle}>
<TouchableOpacity
onPress={handleRegister}
onPressIn={handleButtonPressIn}
onPressOut={handleButtonPressOut}
loading={loading}
disabled={loading || !username || !email || !password || !passwordsMatch}
style={styles.button}
contentStyle={styles.buttonContent}
labelStyle={styles.buttonLabel}
theme={{
colors: {
primary: '#9333ea',
}
}}
activeOpacity={0.8}
>
Зарегистрироваться
</Button>
</AnimatedView>
<LinearGradient
colors={['#ffffff', '#e6e6e6']}
style={[
styles.gradientButton,
(loading || !username || !email || !password || !passwordsMatch) && styles.disabledButton
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={styles.buttonText}>
{loading ? 'СОЗДАНИЕ...' : 'СОЗДАТЬ АККАУНТ'}
</Text>
</LinearGradient>
</TouchableOpacity>
</Animated.View>
<Button
mode="text"
<View style={styles.dividerContainer}>
<View style={styles.divider} />
<Text style={styles.dividerText}>ИЛИ</Text>
<View style={styles.divider} />
</View>
<TouchableOpacity
onPress={() => navigation.navigate('Login')}
disabled={loading}
style={styles.linkButton}
labelStyle={styles.linkButtonLabel}
theme={{
colors: {
primary: '#a855f7',
}
}}
activeOpacity={0.7}
>
Уже есть аккаунт? Войти
</Button>
<Text style={styles.linkButtonText}>
Уже есть аккаунт?
</Text>
<Text style={styles.linkButtonTextBold}>
{' Войти'}
</Text>
</TouchableOpacity>
</Animated.View>
</Animated.View>
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
</BackgroundDesign>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0a0a0f',
},
scrollContent: {
flexGrow: 1,
@ -324,90 +306,117 @@ const styles = StyleSheet.create({
width: '100%',
alignSelf: 'center',
},
glowContainer: {
marginBottom: 40,
padding: 20,
borderRadius: 20,
backgroundColor: '#1a1a2e',
registerCard: {
borderRadius: 24,
padding: 32,
backgroundColor: 'rgba(26, 26, 26, 0.8)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
// backdropFilter: 'blur(10px)', // не поддерживается в React Native
// iOS тени
shadowColor: '#9333ea',
shadowColor: '#ffffff',
shadowOffset: {
width: 0,
height: 0,
},
shadowOpacity: 0.5,
shadowRadius: 20,
shadowOpacity: 0.1,
shadowRadius: 30,
// Android тень
elevation: 10,
elevation: 20,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 24,
},
headerContainer: {
alignItems: 'center',
marginBottom: 40,
},
title: {
textAlign: 'center',
marginBottom: 10,
fontSize: 32,
fontWeight: 'bold',
fontSize: 28,
fontWeight: '300',
color: '#ffffff',
textShadowColor: '#9333ea',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
letterSpacing: 3,
marginBottom: 8,
},
subtitle: {
textAlign: 'center',
fontSize: 16,
color: '#a855f7',
fontStyle: 'italic',
formContainer: {
width: '100%',
},
input: {
marginBottom: 20,
backgroundColor: '#1a1a2e',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
fontSize: 16,
borderRadius: 12,
paddingHorizontal: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
errorText: {
color: '#ff6b6b',
textAlign: 'center',
marginBottom: 16,
fontSize: 14,
},
gradientButton: {
paddingVertical: 16,
paddingHorizontal: 32,
borderRadius: 12,
alignItems: 'center',
marginTop: 8,
// iOS тени
shadowColor: '#7c3aed',
shadowColor: '#ffffff',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 6,
shadowRadius: 12,
// Android тень
elevation: 5,
elevation: 8,
},
button: {
marginTop: 20,
marginBottom: 10,
borderRadius: 12,
backgroundColor: '#9333ea',
// iOS тени для кнопки
shadowColor: '#9333ea',
shadowOffset: {
width: 0,
height: 8,
disabledButton: {
opacity: 0.5,
},
shadowOpacity: 0.6,
shadowRadius: 15,
// Android тень
elevation: 10,
buttonText: {
color: '#000000',
fontSize: 16,
fontWeight: '700',
letterSpacing: 2,
},
buttonContent: {
paddingVertical: 8,
dividerContainer: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 24,
},
buttonLabel: {
fontSize: 18,
fontWeight: 'bold',
divider: {
flex: 1,
height: 1,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
dividerText: {
color: '#666666',
paddingHorizontal: 16,
fontSize: 12,
letterSpacing: 1,
},
linkButton: {
marginTop: 10,
flexDirection: 'row',
justifyContent: 'center',
paddingVertical: 12,
},
linkButtonLabel: {
fontSize: 16,
color: '#a855f7',
linkButtonText: {
fontSize: 14,
color: '#808080',
},
errorText: {
color: '#ef4444',
textAlign: 'center',
marginBottom: 10,
textShadowColor: '#ef4444',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 5,
linkButtonTextBold: {
fontSize: 14,
color: '#ffffff',
fontWeight: '600',
},
});

View File

@ -0,0 +1,424 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
import { Text, Avatar, Divider, useTheme } from 'react-native-paper';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInUp, FadeInDown } from 'react-native-reanimated';
import { BackgroundDesign } from '../components/BackgroundDesign';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
export const UserInfoScreen = ({ route, navigation }: any) => {
const { user } = route.params;
const theme = useTheme();
const formatJoinDate = (date: string | Date) => {
return format(new Date(date), 'MMMM yyyy', { locale: ru });
};
return (
<BackgroundDesign variant="chat">
{/* Заголовок */}
<Animated.View
style={styles.headerContainer}
entering={FadeInUp.duration(600)}
>
<View style={styles.headerCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.headerContent}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="arrow-left"
size={24}
color={theme.colors.onSurface}
/>
</TouchableOpacity>
<Text
variant="titleLarge"
style={[styles.headerTitle, { color: theme.colors.onSurface }]}
>
Информация
</Text>
</View>
</View>
</Animated.View>
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
{/* Профиль пользователя */}
<Animated.View
style={styles.profileSection}
entering={FadeInDown.delay(200).duration(600)}
>
<View style={styles.profileCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.profileGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.profileContent}>
<View style={styles.avatarSection}>
<Avatar.Text
size={80}
label={(user?.username || user?.email || user?.id || 'U').charAt(0).toUpperCase()}
style={[styles.avatar, { backgroundColor: theme.colors.primaryContainer }]}
labelStyle={[styles.avatarLabel, { color: theme.colors.onPrimaryContainer }]}
/>
{user?.isOnline === true && (
<View style={styles.onlineIndicator}>
<View style={styles.onlineDot} />
</View>
)}
</View>
<View style={styles.userDetails}>
<Text
variant="headlineSmall"
style={[styles.userName, { color: theme.colors.onSurface }]}
>
@{user?.username || user?.email?.split('@')[0] || user?.id?.slice(0, 8)}
</Text>
{user?.bio && (
<Text
variant="bodyLarge"
style={[styles.userBio, { color: theme.colors.onSurfaceVariant }]}
>
{user.bio}
</Text>
)}
<View style={styles.statusRow}>
<MaterialCommunityIcons
name="circle"
size={12}
color={user?.isOnline === true ? '#4CAF50' : '#9E9E9E'}
/>
<Text
variant="bodyMedium"
style={[
styles.statusText,
{
color: user?.isOnline === true ? '#4CAF50' : theme.colors.onSurfaceVariant
}
]}
>
{user?.isOnline === true ? 'В сети' : 'Не в сети'}
</Text>
</View>
</View>
</View>
</View>
</Animated.View>
{/* Дополнительная информация */}
<Animated.View
style={styles.infoSection}
entering={FadeInDown.delay(400).duration(600)}
>
<View style={styles.infoCard}>
<LinearGradient
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
style={styles.infoGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.infoContent}>
<Text
variant="titleMedium"
style={[styles.sectionTitle, { color: theme.colors.onSurface }]}
>
Детали
</Text>
{user?.email && (
<>
<View style={styles.infoRow}>
<MaterialCommunityIcons
name="email"
size={20}
color={theme.colors.onSurfaceVariant}
/>
<View style={styles.infoTextContainer}>
<Text
variant="bodySmall"
style={[styles.infoLabel, { color: theme.colors.onSurfaceVariant }]}
>
Email
</Text>
<Text
variant="bodyMedium"
style={[styles.infoValue, { color: theme.colors.onSurface }]}
>
{user.email}
</Text>
</View>
</View>
<Divider style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />
</>
)}
{user?.createdAt && (
<>
<View style={styles.infoRow}>
<MaterialCommunityIcons
name="calendar"
size={20}
color={theme.colors.onSurfaceVariant}
/>
<View style={styles.infoTextContainer}>
<Text
variant="bodySmall"
style={[styles.infoLabel, { color: theme.colors.onSurfaceVariant }]}
>
Присоединился
</Text>
<Text
variant="bodyMedium"
style={[styles.infoValue, { color: theme.colors.onSurface }]}
>
{formatJoinDate(user.createdAt)}
</Text>
</View>
</View>
<Divider style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />
</>
)}
{user?.lastSeen && user?.isOnline !== true && (
<View style={styles.infoRow}>
<MaterialCommunityIcons
name="clock"
size={20}
color={theme.colors.onSurfaceVariant}
/>
<View style={styles.infoTextContainer}>
<Text
variant="bodySmall"
style={[styles.infoLabel, { color: theme.colors.onSurfaceVariant }]}
>
Последний раз в сети
</Text>
<Text
variant="bodyMedium"
style={[styles.infoValue, { color: theme.colors.onSurface }]}
>
{format(new Date(user.lastSeen), 'dd MMMM, HH:mm', { locale: ru })}
</Text>
</View>
</View>
)}
</View>
</View>
</Animated.View>
</ScrollView>
</BackgroundDesign>
);
};
const styles = StyleSheet.create({
// Заголовок
headerContainer: {
paddingTop: 60,
paddingHorizontal: 20,
paddingBottom: 16,
},
headerCard: {
borderRadius: 20,
backgroundColor: 'rgba(26, 26, 26, 0.8)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
shadowColor: '#ffffff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 30,
elevation: 20,
overflow: 'hidden',
},
headerGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 20,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 16,
},
backButton: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
marginRight: 16,
},
headerTitle: {
fontWeight: '300',
letterSpacing: 1,
},
// Контент
scrollContainer: {
flex: 1,
paddingHorizontal: 20,
},
// Профиль
profileSection: {
marginBottom: 20,
},
profileCard: {
borderRadius: 24,
backgroundColor: 'rgba(26, 26, 26, 0.8)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
shadowColor: '#ffffff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 30,
elevation: 20,
overflow: 'hidden',
},
profileGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 24,
},
profileContent: {
padding: 24,
alignItems: 'center',
},
avatarSection: {
position: 'relative',
marginBottom: 20,
},
avatar: {
borderWidth: 3,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
avatarLabel: {
fontSize: 32,
fontWeight: '600',
},
onlineIndicator: {
position: 'absolute',
bottom: 2,
right: 2,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(26, 26, 26, 0.9)',
borderWidth: 3,
borderColor: 'rgba(26, 26, 26, 0.9)',
justifyContent: 'center',
alignItems: 'center',
},
onlineDot: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#4CAF50',
},
userDetails: {
alignItems: 'center',
},
userName: {
fontWeight: '600',
marginBottom: 8,
textAlign: 'center',
},
userBio: {
textAlign: 'center',
marginBottom: 12,
lineHeight: 22,
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
},
statusText: {
marginLeft: 6,
fontSize: 14,
fontWeight: '500',
},
// Информация
infoSection: {
marginBottom: 20,
},
infoCard: {
borderRadius: 20,
backgroundColor: 'rgba(26, 26, 26, 0.6)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
shadowColor: '#ffffff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.05,
shadowRadius: 15,
elevation: 5,
overflow: 'hidden',
},
infoGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 20,
},
infoContent: {
padding: 20,
},
sectionTitle: {
fontWeight: '600',
marginBottom: 16,
letterSpacing: 0.5,
},
infoRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
},
infoTextContainer: {
marginLeft: 16,
flex: 1,
},
infoLabel: {
fontSize: 12,
opacity: 0.8,
marginBottom: 2,
},
infoValue: {
fontSize: 15,
fontWeight: '500',
},
divider: {
height: 1,
marginVertical: 4,
opacity: 0.3,
},
});

View File

@ -1,7 +1,10 @@
import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink } from '@apollo/client';
import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink, split } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { getMainDefinition } from '@apollo/client/utilities';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { API_URL } from '../config/api';
import { API_URL, DEV_SERVER_IP } from '../config/api';
const httpLink = createHttpLink({
uri: API_URL,
@ -17,6 +20,41 @@ const authLink = setContext(async (_, { headers }) => {
};
});
// WebSocket линк для подписок
const wsLink = new GraphQLWsLink(createClient({
url: API_URL.replace('http://', 'ws://'),
connectionParams: () => {
return new Promise(async (resolve) => {
try {
const token = await AsyncStorage.getItem('token');
resolve({
authorization: token ? `Bearer ${token}` : '',
});
} catch (error) {
console.error('Error getting token for WebSocket:', error);
resolve({
authorization: '',
});
}
});
},
retryAttempts: 5,
retryWait: (attempt) => Math.min(1000 * Math.pow(2, attempt), 10000),
}));
// Временно используем только HTTP линк, пока не настроим WebSocket
// const splitLink = split(
// ({ query }) => {
// const definition = getMainDefinition(query);
// return (
// definition.kind === 'OperationDefinition' &&
// definition.operation === 'subscription'
// );
// },
// wsLink,
// ApolloLink.from([authLink, httpLink])
// );
export const apolloClient = new ApolloClient({
link: ApolloLink.from([authLink, httpLink]),
cache: new InMemoryCache(),

View File

@ -1,10 +1,10 @@
export interface User {
id: string;
username: string;
email: string;
username?: string;
email?: string;
avatar?: string;
bio?: string;
isOnline: boolean;
isOnline?: boolean;
lastSeen?: Date;
createdAt: Date;
updatedAt: Date;