Перейти к содержанию

Chat Module (Frontend)

Real-time чат с WebSocket, оптимистичными обновлениями и поддержкой мультимедиа.

Структура

fara_chat/
├── components/
│   ├── ChatPage.tsx          # Главная страница: список + чат
│   ├── ChatList.tsx          # Sidebar со списком чатов
│   ├── MessageList.tsx       # Лента сообщений
│   ├── MessageInput.tsx      # Поле ввода + вложения
│   ├── MessageBubble.tsx     # Одно сообщение
│   ├── PinnedMessages.tsx    # Закреплённые сообщения
│   └── ReactionPicker.tsx    # Выбор реакции
├── hooks/
│   ├── useChat.ts            # Логика чата
│   └── useWebSocket.ts       # WS-подключение
├── context/
│   └── ChatContext.tsx        # Контекст текущего чата
└── locales/
    ├── ru.json
    └── en.json

WebSocket

fara_chat/hooks/useWebSocket.ts
import { useEffect, useRef } from 'react';

export function useWebSocket(token: string, onMessage: (data: any) => void) {
    const ws = useRef<WebSocket | null>(null);

    useEffect(() => {
        const url = `${WS_BASE_URL}/ws?token=${token}`;
        ws.current = new WebSocket(url);

        ws.current.onmessage = (event) => {
            const data = JSON.parse(event.data);
            onMessage(data);
        };

        ws.current.onclose = () => {
            // Reconnect logic
            setTimeout(() => {
                ws.current = new WebSocket(url);
            }, 3000);
        };

        return () => ws.current?.close();
    }, [token]);

    return ws;
}

Обработка WS-событий

fara_chat/components/ChatPage.tsx
function ChatPage() {
    const { token } = useAuth();
    const [messages, setMessages] = useState<Message[]>([]);

    const handleWsMessage = useCallback((data: WsEvent) => {
        switch (data.type) {
            case 'new_message':
                setMessages(prev => [...prev, data.message]);
                break;

            case 'message_edited':
                setMessages(prev =>
                    prev.map(m =>
                        m.id === data.message_id
                            ? { ...m, body: data.body, is_edited: true }
                            : m
                    )
                );
                break;

            case 'message_deleted':
                setMessages(prev =>
                    prev.filter(m => m.id !== data.message_id)
                );
                break;

            case 'message_pinned':
                setMessages(prev =>
                    prev.map(m =>
                        m.id === data.message_id
                            ? { ...m, pinned: data.pinned }
                            : m
                    )
                );
                break;
        }
    }, []);

    useWebSocket(token, handleWsMessage);

    return (
        <ChatLayout>
            <ChatList />
            <MessageList messages={messages} />
            <MessageInput chatId={currentChatId} />
        </ChatLayout>
    );
}

Компоненты

MessageInput

fara_chat/components/MessageInput.tsx
function MessageInput({ chatId }: { chatId: number }) {
    const [body, setBody] = useState('');
    const [sendMessage] = chatApi.useSendMessageMutation();

    const handleSend = async () => {
        if (!body.trim()) return;

        await sendMessage({
            chatId,
            body: body.trim(),
            attachments: [],
        });

        setBody('');
    };

    return (
        <Group>
            <Textarea
                value={body}
                onChange={(e) => setBody(e.target.value)}
                placeholder={t('chat.typeMessage')}
                onKeyDown={(e) => {
                    if (e.key === 'Enter' && !e.shiftKey) {
                        e.preventDefault();
                        handleSend();
                    }
                }}
            />
            <ActionIcon onClick={handleSend}>
                <IconSend />
            </ActionIcon>
        </Group>
    );
}

MessageBubble

fara_chat/components/MessageBubble.tsx
function MessageBubble({ message, isOwn }: Props) {
    return (
        <Paper
            p="sm"
            radius="md"
            bg={isOwn ? 'blue.0' : 'gray.0'}
            ml={isOwn ? 'auto' : 0}
            maw="70%"
        >
            {!isOwn && (
                <Text size="xs" fw={600} c="blue">
                    {message.author_name}
                </Text>
            )}

            <Text size="sm">{message.body}</Text>

            <Group gap={4} mt={4}>
                <Text size="xs" c="dimmed">
                    {formatTime(message.created_at)}
                </Text>
                {message.is_edited && (
                    <Text size="xs" c="dimmed">(ред.)</Text>
                )}
                {message.pinned && <IconPin size={12} />}
            </Group>
        </Paper>
    );
}