Initial commit: Prism messenger with Expo + NestJS + GraphQL + PostgreSQL

This commit is contained in:
Bivekich
2025-08-06 02:19:37 +03:00
commit 6fb83334d6
56 changed files with 24295 additions and 0 deletions

View File

@ -0,0 +1,263 @@
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 { useQuery, useMutation, useSubscription } from '@apollo/client';
import { GET_MESSAGES } from '../graphql/queries';
import { SEND_MESSAGE, DELETE_MESSAGE } from '../graphql/mutations';
import { MESSAGE_ADDED } from '../graphql/subscriptions';
import { Message } from '../types';
import { useAuth } from '../contexts/AuthContext';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
export const ChatScreen = ({ route }: any) => {
const { conversationId, title } = route.params;
const { user } = useAuth();
const [message, setMessage] = useState('');
const [selectedMessage, setSelectedMessage] = useState<string | null>(null);
const [menuVisible, setMenuVisible] = useState(false);
const flatListRef = useRef<FlatList>(null);
const { data, loading, error } = useQuery(GET_MESSAGES, {
variables: { conversationId },
});
const [sendMessage] = useMutation(SEND_MESSAGE, {
update(cache, { data: { sendMessage } }) {
const existing = cache.readQuery({
query: GET_MESSAGES,
variables: { conversationId },
});
if (existing) {
cache.writeQuery({
query: GET_MESSAGES,
variables: { conversationId },
data: {
messages: [...existing.messages, sendMessage],
},
});
}
},
});
const [deleteMessage] = useMutation(DELETE_MESSAGE, {
update(cache, { data: { deleteMessage } }, { variables }) {
if (deleteMessage) {
cache.modify({
fields: {
messages(existingMessages, { readField }) {
return existingMessages.filter(
(messageRef: any) => variables?.messageId !== readField('id', messageRef)
);
},
},
});
}
},
});
useSubscription(MESSAGE_ADDED, {
variables: { conversationId },
onData: ({ data }) => {
if (data?.data?.messageAdded && data.data.messageAdded.sender.id !== user?.id) {
// Сообщение от другого пользователя
flatListRef.current?.scrollToEnd();
}
},
});
const handleSend = async () => {
if (!message.trim()) return;
try {
await sendMessage({
variables: {
conversationId,
content: message.trim(),
},
});
setMessage('');
setTimeout(() => flatListRef.current?.scrollToEnd(), 100);
} catch (error) {
console.error('Error sending message:', error);
}
};
const handleDeleteMessage = async (messageId: string) => {
try {
await deleteMessage({ variables: { messageId } });
setMenuVisible(false);
setSelectedMessage(null);
} catch (error) {
console.error('Error deleting message:', error);
}
};
const renderMessage = ({ item }: { item: Message }) => {
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]}>
{!isOwnMessage && (
<Avatar.Text
size={36}
label={item.sender.username.charAt(0).toUpperCase()}
style={styles.avatar}
/>
)}
<Surface
style={[styles.messageBubble, isOwnMessage && styles.ownMessageBubble]}
elevation={1}
>
{!isOwnMessage && (
<Text variant="labelSmall" style={styles.senderName}>
{item.sender.username}
</Text>
)}
<Text style={[styles.messageText, isOwnMessage && styles.ownMessageText]}>
{item.content}
</Text>
<Text variant="bodySmall" style={[styles.messageTime, isOwnMessage && styles.ownMessageTime]}>
{messageTime}
{item.isEdited && ' • изменено'}
</Text>
{isOwnMessage && (
<Menu
visible={menuVisible && selectedMessage === item.id}
onDismiss={() => {
setMenuVisible(false);
setSelectedMessage(null);
}}
anchor={
<IconButton
icon="dots-vertical"
size={16}
onPress={() => {
setSelectedMessage(item.id);
setMenuVisible(true);
}}
style={styles.menuButton}
/>
}
>
<Menu.Item onPress={() => handleDeleteMessage(item.id)} title="Удалить" />
</Menu>
)}
</Surface>
</View>
);
};
if (loading && !data) {
return (
<View style={styles.centerContainer}>
<Text>Загрузка сообщений...</Text>
</View>
);
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={90}
>
<FlatList
ref={flatListRef}
data={data?.messages || []}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.messagesList}
onContentSizeChange={() => flatListRef.current?.scrollToEnd()}
/>
<View style={styles.inputContainer}>
<TextInput
value={message}
onChangeText={setMessage}
placeholder="Введите сообщение..."
mode="outlined"
style={styles.input}
multiline
maxLength={1000}
right={
<TextInput.Icon
icon="send"
onPress={handleSend}
disabled={!message.trim()}
/>
}
/>
</View>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
messagesList: {
padding: 16,
paddingBottom: 8,
},
messageContainer: {
flexDirection: 'row',
marginBottom: 12,
alignItems: 'flex-end',
},
ownMessageContainer: {
justifyContent: 'flex-end',
},
avatar: {
marginRight: 8,
},
messageBubble: {
maxWidth: '75%',
padding: 12,
borderRadius: 16,
backgroundColor: '#fff',
},
ownMessageBubble: {
backgroundColor: '#2196F3',
},
senderName: {
color: '#666',
marginBottom: 4,
},
messageText: {
color: '#000',
},
ownMessageText: {
color: '#fff',
},
messageTime: {
color: '#666',
marginTop: 4,
fontSize: 11,
},
ownMessageTime: {
color: 'rgba(255, 255, 255, 0.7)',
},
menuButton: {
position: 'absolute',
top: -8,
right: -8,
},
inputContainer: {
padding: 8,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
input: {
maxHeight: 100,
},
});

View File

@ -0,0 +1,151 @@
import React from 'react';
import { View, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
import { List, Avatar, Text, FAB, Divider, Badge } 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';
export const ConversationsScreen = ({ navigation }: any) => {
const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS, {
pollInterval: 5000, // Обновляем каждые 5 секунд
});
const renderConversation = ({ item }: { item: Conversation }) => {
const otherParticipant = item.participants.find(p => p.id !== data?.me?.id);
const displayName = item.isGroup ? item.name : otherParticipant?.username;
const lastMessageTime = item.lastMessage
? format(new Date(item.lastMessage.createdAt), 'HH:mm', { locale: ru })
: '';
return (
<TouchableOpacity
onPress={() => navigation.navigate('Chat', { conversationId: item.id, title: displayName })}
>
<List.Item
title={displayName || 'Без имени'}
description={item.lastMessage?.content || 'Нет сообщений'}
left={() => (
<View>
<Avatar.Text
size={50}
label={displayName?.charAt(0).toUpperCase() || '?'}
/>
{otherParticipant?.isOnline && (
<Badge style={styles.onlineBadge} size={12} />
)}
</View>
)}
right={() => (
<View style={styles.rightContent}>
<Text variant="bodySmall" style={styles.time}>
{lastMessageTime}
</Text>
{/* Здесь можно добавить счетчик непрочитанных сообщений */}
</View>
)}
style={styles.listItem}
/>
<Divider />
</TouchableOpacity>
);
};
if (loading && !data) {
return (
<View style={styles.centerContainer}>
<Text>Загрузка...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.centerContainer}>
<Text>Ошибка загрузки чатов</Text>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={data?.conversations || []}
renderItem={renderConversation}
keyExtractor={(item) => item.id}
onRefresh={refetch}
refreshing={loading}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text variant="bodyLarge" style={styles.emptyText}>
У вас пока нет чатов
</Text>
<Text variant="bodyMedium" style={styles.emptySubtext}>
Начните новый чат, нажав на кнопку внизу
</Text>
</View>
}
/>
<FAB
icon="plus"
style={styles.fab}
onPress={() => navigation.navigate('NewChat')}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
listContent: {
flexGrow: 1,
},
listItem: {
paddingVertical: 8,
},
rightContent: {
alignItems: 'flex-end',
justifyContent: 'center',
},
time: {
color: '#666',
},
onlineBadge: {
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: '#4CAF50',
},
fab: {
position: 'absolute',
margin: 16,
right: 0,
bottom: 0,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 40,
paddingTop: 100,
},
emptyText: {
textAlign: 'center',
marginBottom: 8,
color: '#666',
},
emptySubtext: {
textAlign: 'center',
color: '#999',
},
});

View File

@ -0,0 +1,122 @@
import React, { useState } from 'react';
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
import { TextInput, Button, Text, Headline, HelperText } from 'react-native-paper';
import { useMutation } from '@apollo/client';
import { LOGIN } from '../graphql/mutations';
import { useAuth } from '../contexts/AuthContext';
export const LoginScreen = ({ navigation }: any) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { login } = useAuth();
const [loginMutation, { loading, error }] = useMutation(LOGIN, {
onCompleted: async (data) => {
await login(data.login.access_token, data.login.user);
},
onError: (error) => {
console.error('Login error:', error);
},
});
const handleLogin = () => {
if (!username || !password) return;
loginMutation({ variables: { username, password } });
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.content}>
<Headline style={styles.title}>Вход в Prism</Headline>
<TextInput
label="Имя пользователя"
value={username}
onChangeText={setUsername}
mode="outlined"
style={styles.input}
autoCapitalize="none"
disabled={loading}
/>
<TextInput
label="Пароль"
value={password}
onChangeText={setPassword}
mode="outlined"
style={styles.input}
secureTextEntry={!showPassword}
disabled={loading}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
/>
}
/>
{error && (
<HelperText type="error" visible={true}>
{error.message}
</HelperText>
)}
<Button
mode="contained"
onPress={handleLogin}
loading={loading}
disabled={loading || !username || !password}
style={styles.button}
>
Войти
</Button>
<Button
mode="text"
onPress={() => navigation.navigate('Register')}
disabled={loading}
style={styles.linkButton}
>
Нет аккаунта? Зарегистрироваться
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
},
content: {
padding: 20,
maxWidth: 400,
width: '100%',
alignSelf: 'center',
},
title: {
textAlign: 'center',
marginBottom: 30,
},
input: {
marginBottom: 15,
},
button: {
marginTop: 10,
marginBottom: 10,
},
linkButton: {
marginTop: 10,
},
});

View File

@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
import { TextInput, Button, Text, Headline, HelperText } from 'react-native-paper';
import { useMutation } from '@apollo/client';
import { REGISTER } from '../graphql/mutations';
import { useAuth } from '../contexts/AuthContext';
export const RegisterScreen = ({ navigation }: any) => {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { login } = useAuth();
const [registerMutation, { loading, error }] = useMutation(REGISTER, {
onCompleted: async (data) => {
await login(data.register.access_token, data.register.user);
},
onError: (error) => {
console.error('Register error:', error);
},
});
const handleRegister = () => {
if (!username || !email || !password || password !== confirmPassword) return;
registerMutation({ variables: { username, email, password } });
};
const passwordsMatch = password === confirmPassword || confirmPassword === '';
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.content}>
<Headline style={styles.title}>Регистрация в Prism</Headline>
<TextInput
label="Имя пользователя"
value={username}
onChangeText={setUsername}
mode="outlined"
style={styles.input}
autoCapitalize="none"
disabled={loading}
/>
<TextInput
label="Email"
value={email}
onChangeText={setEmail}
mode="outlined"
style={styles.input}
keyboardType="email-address"
autoCapitalize="none"
disabled={loading}
/>
<TextInput
label="Пароль"
value={password}
onChangeText={setPassword}
mode="outlined"
style={styles.input}
secureTextEntry={!showPassword}
disabled={loading}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
/>
}
/>
<TextInput
label="Подтвердите пароль"
value={confirmPassword}
onChangeText={setConfirmPassword}
mode="outlined"
style={styles.input}
secureTextEntry={!showPassword}
disabled={loading}
error={!passwordsMatch}
/>
{!passwordsMatch && (
<HelperText type="error" visible={true}>
Пароли не совпадают
</HelperText>
)}
{error && (
<HelperText type="error" visible={true}>
{error.message}
</HelperText>
)}
<Button
mode="contained"
onPress={handleRegister}
loading={loading}
disabled={loading || !username || !email || !password || !passwordsMatch}
style={styles.button}
>
Зарегистрироваться
</Button>
<Button
mode="text"
onPress={() => navigation.navigate('Login')}
disabled={loading}
style={styles.linkButton}
>
Уже есть аккаунт? Войти
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
},
content: {
padding: 20,
maxWidth: 400,
width: '100%',
alignSelf: 'center',
},
title: {
textAlign: 'center',
marginBottom: 30,
},
input: {
marginBottom: 15,
},
button: {
marginTop: 10,
marginBottom: 10,
},
linkButton: {
marginTop: 10,
},
});