Initial commit: Prism messenger with Expo + NestJS + GraphQL + PostgreSQL
This commit is contained in:
263
frontend/src/screens/ChatScreen.tsx
Normal file
263
frontend/src/screens/ChatScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
151
frontend/src/screens/ConversationsScreen.tsx
Normal file
151
frontend/src/screens/ConversationsScreen.tsx
Normal 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',
|
||||
},
|
||||
});
|
122
frontend/src/screens/LoginScreen.tsx
Normal file
122
frontend/src/screens/LoginScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
154
frontend/src/screens/RegisterScreen.tsx
Normal file
154
frontend/src/screens/RegisterScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
Reference in New Issue
Block a user