From 1f3a0e43511d840170153b91e827c6502e500f2f Mon Sep 17 00:00:00 2001 From: vantablackTea Date: Sun, 27 Sep 2026 19:56:06 +0300 Subject: [PATCH] frontend: messages, should work --- web/src/auth/AuthContext.tsx | 78 +++++++++++++ web/src/hooks/useConversations.ts | 57 ++++++++++ web/src/hooks/useMessages.ts | 155 +++++++++++++++++++++++++ web/src/hooks/useRealtime.ts | 23 ++++ web/src/lib/api.ts | 183 ++++++++++++++++++++++++++++++ web/src/lib/format.ts | 91 +++++++++++++++ web/src/lib/types.ts | 83 ++++++++++++++ web/src/lib/ws.ts | 155 +++++++++++++++++++++++++ 8 files changed, 825 insertions(+) create mode 100644 web/src/auth/AuthContext.tsx create mode 100644 web/src/hooks/useConversations.ts create mode 100644 web/src/hooks/useMessages.ts create mode 100644 web/src/hooks/useRealtime.ts create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/format.ts create mode 100644 web/src/lib/types.ts create mode 100644 web/src/lib/ws.ts diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx new file mode 100644 index 0000000..e52d781 --- /dev/null +++ b/web/src/auth/AuthContext.tsx @@ -0,0 +1,78 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { api } from '../lib/api' +import type { User } from '../lib/types' + +export interface AuthContextValue { + user: User | null + loading: boolean + login: (username: string, password: string) => Promise + register: (username: string, displayName: string, password: string) => Promise + logout: () => Promise + refreshMe: () => Promise +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + api + .me() + .then((me) => { + if (!cancelled) setUser(me) + }) + .catch(() => { + if (!cancelled) setUser(null) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + const login = useCallback(async (username: string, password: string) => { + const me = await api.login(username, password) + setUser(me) + }, []) + + const register = useCallback(async (username: string, displayName: string, password: string) => { + const me = await api.register(username, displayName, password) + setUser(me) + }, []) + + const logout = useCallback(async () => { + try { + await api.logout() + } finally { + setUser(null) + } + }, []) + + const refreshMe = useCallback(async () => { + try { + const me = await api.me() + setUser(me) + } catch { + setUser(null) + } + }, []) + + const value = useMemo( + () => ({ user, loading, login, register, logout, refreshMe }), + [user, loading, login, register, logout, refreshMe], + ) + + return {children} +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext) + if (!context) throw new Error('useAuth должен использоваться внутри AuthProvider') + return context +} diff --git a/web/src/hooks/useConversations.ts b/web/src/hooks/useConversations.ts new file mode 100644 index 0000000..8333a08 --- /dev/null +++ b/web/src/hooks/useConversations.ts @@ -0,0 +1,57 @@ +import { useCallback, useEffect, useState } from 'react' +import { api } from '../lib/api' +import type { Conversation } from '../lib/types' + +function activityTime(conversation: Conversation): number { + const value = conversation.last_message?.created_at ?? conversation.created_at + const time = Date.parse(value) + return Number.isNaN(time) ? 0 : time +} + +function sortConversations(list: Conversation[]): Conversation[] { + return [...list].sort((a, b) => activityTime(b) - activityTime(a)) +} + +export interface UseConversationsResult { + conversations: Conversation[] + loading: boolean + error: string | null + reload: () => Promise + add: (conversation: Conversation) => void + patch: (id: number, updater: (conversation: Conversation) => Conversation) => void +} + +export function useConversations(enabled = true): UseConversationsResult { + const [conversations, setConversations] = useState([]) + const [loading, setLoading] = useState(enabled) + const [error, setError] = useState(null) + + const reload = useCallback(async () => { + setLoading(true) + setError(null) + try { + const list = await api.listConversations() + setConversations(sortConversations(list)) + } catch (err) { + setError(err instanceof Error ? err.message : 'Не удалось загрузить чаты') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (enabled) void reload() + }, [enabled, reload]) + + const add = useCallback((conversation: Conversation) => { + setConversations((prev) => + prev.some((item) => item.id === conversation.id) ? prev : sortConversations([conversation, ...prev]), + ) + }, []) + + const patch = useCallback((id: number, updater: (conversation: Conversation) => Conversation) => { + setConversations((prev) => sortConversations(prev.map((item) => (item.id === id ? updater(item) : item)))) + }, []) + + return { conversations, loading, error, reload, add, patch } +} diff --git a/web/src/hooks/useMessages.ts b/web/src/hooks/useMessages.ts new file mode 100644 index 0000000..335acf8 --- /dev/null +++ b/web/src/hooks/useMessages.ts @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { api } from '../lib/api' +import type { ClientMessage, Message } from '../lib/types' + +const PAGE_SIZE = 50 + +export interface UseMessagesResult { + messages: ClientMessage[] + loading: boolean + loadingOlder: boolean + hasMore: boolean + error: string | null + loadOlder: () => Promise + upsert: (message: Message, tempId?: string) => void + addOptimistic: (message: ClientMessage) => void + markSending: (tempId: string) => void + failOptimistic: (tempId: string) => void + markReadUpTo: (messageId: number) => void +} + +export function useMessages(conversationId: number | null, currentUserId: number): UseMessagesResult { + const [messages, setMessages] = useState([]) + const [loading, setLoading] = useState(false) + const [loadingOlder, setLoadingOlder] = useState(false) + const [hasMore, setHasMore] = useState(false) + const [error, setError] = useState(null) + + const messagesRef = useRef(messages) + const hasMoreRef = useRef(hasMore) + const loadingOlderRef = useRef(false) + + useEffect(() => { + messagesRef.current = messages + }, [messages]) + + useEffect(() => { + hasMoreRef.current = hasMore + }, [hasMore]) + + useEffect(() => { + if (conversationId === null) { + setMessages([]) + setHasMore(false) + setError(null) + setLoading(false) + return + } + + let cancelled = false + setLoading(true) + setError(null) + setMessages([]) + + api + .listMessages(conversationId, undefined, PAGE_SIZE) + .then((list) => { + if (cancelled) return + setMessages([...list].reverse()) + setHasMore(list.length >= PAGE_SIZE) + }) + .catch((err: unknown) => { + if (!cancelled) setError(err instanceof Error ? err.message : 'Не удалось загрузить сообщения') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, [conversationId]) + + const loadOlder = useCallback(async () => { + if (conversationId === null || loadingOlderRef.current || !hasMoreRef.current) return + const oldest = messagesRef.current.find((message) => message.id > 0) + if (!oldest) return + + loadingOlderRef.current = true + setLoadingOlder(true) + try { + const list = await api.listMessages(conversationId, oldest.id, PAGE_SIZE) + if (list.length < PAGE_SIZE) setHasMore(false) + const older = [...list].reverse() + setMessages((prev) => { + const known = new Set(prev.map((message) => message.id)) + return [...older.filter((message) => !known.has(message.id)), ...prev] + }) + } catch { + loadingOlderRef.current = false + } finally { + loadingOlderRef.current = false + setLoadingOlder(false) + } + }, [conversationId]) + + const upsert = useCallback((message: Message, tempId?: string) => { + setMessages((prev) => { + if (message.id > 0) { + const byId = prev.findIndex((item) => item.id === message.id) + if (byId !== -1) { + return prev.map((item, index) => (index === byId ? { ...message } : item)) + } + } + if (tempId) { + const byTemp = prev.findIndex((item) => item.temp_id === tempId) + if (byTemp !== -1) { + return prev.map((item, index) => (index === byTemp ? { ...message } : item)) + } + } + return [...prev, { ...message }] + }) + }, []) + + const addOptimistic = useCallback((message: ClientMessage) => { + setMessages((prev) => { + if (message.temp_id && prev.some((item) => item.temp_id === message.temp_id)) return prev + return [...prev, message] + }) + }, []) + + const markSending = useCallback((tempId: string) => { + setMessages((prev) => prev.map((item) => (item.temp_id === tempId ? { ...item, status: 'sending' } : item))) + }, []) + + const failOptimistic = useCallback((tempId: string) => { + setMessages((prev) => prev.map((item) => (item.temp_id === tempId ? { ...item, status: 'error' } : item))) + }, []) + + const markReadUpTo = useCallback( + (messageId: number) => { + setMessages((prev) => + prev.map((item) => + item.sender_id === currentUserId && item.id > 0 && item.id <= messageId && !item.read_by_peer + ? { ...item, read_by_peer: true } + : item, + ), + ) + }, + [currentUserId], + ) + + return { + messages, + loading, + loadingOlder, + hasMore, + error, + loadOlder, + upsert, + addOptimistic, + markSending, + failOptimistic, + markReadUpTo, + } +} diff --git a/web/src/hooks/useRealtime.ts b/web/src/hooks/useRealtime.ts new file mode 100644 index 0000000..0738f48 --- /dev/null +++ b/web/src/hooks/useRealtime.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef, useState } from 'react' +import { RealtimeClient } from '../lib/ws' +import type { WsEvent } from '../lib/types' + +export function useRealtime(handler: (event: WsEvent) => void): RealtimeClient { + const [client] = useState(() => new RealtimeClient()) + const handlerRef = useRef(handler) + + useEffect(() => { + handlerRef.current = handler + }) + + useEffect(() => { + const unsubscribe = client.onEvent((event) => handlerRef.current(event)) + client.connect() + return () => { + unsubscribe() + client.close() + } + }, [client]) + + return client +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..7e03fae --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,183 @@ +import type { Contact, Conversation, Message, SendMessageResult, User } from './types' + +const API_BASE = '/api/v1' + +export class ApiError extends Error { + readonly status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +export class UnauthorizedError extends ApiError { + constructor(message = 'Требуется вход') { + super(message, 401) + this.name = 'UnauthorizedError' + } +} + +interface RequestOptions { + method?: string + body?: unknown + authRetry?: boolean +} + +let refreshInFlight: Promise | null = null + +async function refreshSession(): Promise { + const pending = refreshInFlight + if (pending) return pending + const promise = fetch(`${API_BASE}/auth/refresh`, { + method: 'POST', + credentials: 'include', + }) + .then((res) => { + if (!res.ok) throw new UnauthorizedError() + }) + .finally(() => { + refreshInFlight = null + }) + refreshInFlight = promise + return promise +} + +async function readError(res: Response): Promise { + const fallback = `Ошибка запроса (${res.status})` + const text = await res.text().catch(() => '') + if (!text) return fallback + try { + const parsed: unknown = JSON.parse(text) + if (parsed && typeof parsed === 'object' && 'error' in parsed) { + const value = (parsed as { error: unknown }).error + if (typeof value === 'string' && value.trim()) return value + } + } catch { + return fallback + } + return fallback +} + +async function readJson(res: Response): Promise { + const text = await res.text() + if (!text) return undefined as T + try { + return JSON.parse(text) as T + } catch { + return undefined as T + } +} + +async function request(path: string, options: RequestOptions = {}): Promise { + const { method = 'GET', body, authRetry = true } = options + const headers: Record = {} + if (body !== undefined) headers['Content-Type'] = 'application/json' + + const res = await fetch(`${API_BASE}${path}`, { + method, + credentials: 'include', + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }) + + if (res.status === 401 && authRetry) { + try { + await refreshSession() + } catch { + throw new UnauthorizedError() + } + return request(path, { ...options, authRetry: false }) + } + + if (!res.ok) { + throw new ApiError(await readError(res), res.status) + } + + return readJson(res) +} + +export interface ApiClient { + register(username: string, displayName: string, password: string): Promise + login(username: string, password: string): Promise + logout(): Promise + refresh(): Promise + me(): Promise + searchUsers(query: string): Promise + listContacts(): Promise + addContact(userId: number): Promise + removeContact(userId: number): Promise + listConversations(): Promise + createDM(userId: number): Promise + listMessages(conversationId: number, before?: number, limit?: number): Promise + sendMessage(conversationId: number, body: string, tempId: string): Promise +} + +export const api: ApiClient = { + register(username, displayName, password) { + return request('/auth/register', { + method: 'POST', + body: { username, display_name: displayName, password }, + authRetry: false, + }) + }, + + login(username, password) { + return request('/auth/login', { + method: 'POST', + body: { username, password }, + authRetry: false, + }) + }, + + logout() { + return request('/auth/logout', { method: 'POST', authRetry: false }) + }, + + refresh() { + return request('/auth/refresh', { method: 'POST', authRetry: false }) + }, + + me() { + return request('/users/me') + }, + + searchUsers(query) { + return request(`/users?q=${encodeURIComponent(query)}`) + }, + + listContacts() { + return request('/contacts') + }, + + addContact(userId) { + return request('/contacts', { method: 'POST', body: { user_id: userId } }) + }, + + removeContact(userId) { + return request(`/contacts/${userId}`, { method: 'DELETE' }) + }, + + listConversations() { + return request('/conversations') + }, + + createDM(userId) { + return request('/conversations', { method: 'POST', body: { user_id: userId } }) + }, + + listMessages(conversationId, before, limit = 50) { + const params = new URLSearchParams({ limit: String(limit) }) + if (before !== undefined) params.set('before', String(before)) + return request(`/conversations/${conversationId}/messages?${params.toString()}`) + }, + + async sendMessage(conversationId, body, tempId) { + const message = await request(`/conversations/${conversationId}/messages`, { + method: 'POST', + body: { body, temp_id: tempId }, + }) + return { temp_id: tempId, message } + }, +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..c616268 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,91 @@ +import type { Conversation, User } from './types' + +const AVATAR_COLORS = [ + '#5b8cff', + '#34d399', + '#f59e0b', + '#f472b6', + '#a78bfa', + '#22d3ee', + '#fb7185', + '#4ade80', +] as const + +export function avatarColor(seed: number | string): string { + const numeric = typeof seed === 'number' ? seed : Array.from(seed).reduce((sum, ch) => sum + ch.charCodeAt(0), 0) + return AVATAR_COLORS[Math.abs(numeric) % AVATAR_COLORS.length] +} + +export function initials(user: Pick): string { + const name = (user.display_name || user.username || '?').trim() + const parts = name.split(/\s+/).filter(Boolean) + const first = parts[0] ?? '' + if (parts.length <= 1) return first.slice(0, 2).toUpperCase() + const last = parts[parts.length - 1] ?? '' + return (first.charAt(0) + last.charAt(0)).toUpperCase() +} + +export function userTitle(user: Pick): string { + return user.display_name || user.username +} + +function toDate(value: string): Date | null { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +export function formatTime(value: string): string { + const date = toDate(value) + if (!date) return '' + return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) +} + +export function formatListTime(value: string): string { + const date = toDate(value) + if (!date) return '' + const now = new Date() + if (date.toDateString() === now.toDateString()) return formatTime(value) + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + if (date.toDateString() === yesterday.toDateString()) return 'вчера' + return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) +} + +export function formatDayDivider(value: string): string { + const date = toDate(value) + if (!date) return '' + const now = new Date() + if (date.toDateString() === now.toDateString()) return 'Сегодня' + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + if (date.toDateString() === yesterday.toDateString()) return 'Вчера' + return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }) +} + +export function isSameDay(a: string, b: string): boolean { + const first = toDate(a) + const second = toDate(b) + if (!first || !second) return false + return first.toDateString() === second.toDateString() +} + +export function minutesBetween(a: string, b: string): number { + const first = toDate(a) + const second = toDate(b) + if (!first || !second) return 0 + return Math.abs(first.getTime() - second.getTime()) / 60000 +} + +export function peerForConversation( + conversation: Conversation, + peers: Record, + directory: Map, + currentUserId: number, +): User | null { + let peerId = peers[conversation.id] + if (peerId === undefined && conversation.last_message && conversation.last_message.sender_id !== currentUserId) { + peerId = conversation.last_message.sender_id + } + if (peerId === undefined) return null + return directory.get(peerId) ?? null +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..81b664e --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,83 @@ +export interface User { + id: number + username: string + display_name: string + avatar: string | null + created_at: string +} + +export interface Contact { + user: User + added_at: string +} + +export interface Message { + id: number + conversation_id: number + sender_id: number + body: string + created_at: string + edited_at?: string | null + deleted_at?: string | null +} + +export type MessageStatus = 'sending' | 'error' + +export interface ClientMessage extends Message { + temp_id?: string + status?: MessageStatus + read_by_peer?: boolean +} + +export interface Conversation { + id: number + type: string + created_at: string + last_message?: Message | null + unread_count?: number +} + +export interface SendMessageResult { + temp_id: string + message: Message +} + +export interface WsMessageCreated { + type: 'message.created' + data: { + temp_id?: string + conversation_id: number + message_id: number + sender_id: number + body: string + created_at: string + } +} + +export interface WsTyping { + type: 'typing' + data: { + conversation_id: number + user_id: number + is_typing: boolean + } +} + +export interface WsPresence { + type: 'presence' + data: { + user_id: number + status: 'online' | 'offline' + } +} + +export interface WsRead { + type: 'read' + data: { + conversation_id: number + user_id: number + message_id: number + } +} + +export type WsEvent = WsMessageCreated | WsTyping | WsPresence | WsRead diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts new file mode 100644 index 0000000..7ea896c --- /dev/null +++ b/web/src/lib/ws.ts @@ -0,0 +1,155 @@ +import type { WsEvent } from './types' + +export type WsEventType = WsEvent['type'] + +type Handler = (event: Extract) => void + +interface OutgoingTyping { + type: 'typing' + data: { conversation_id: number; is_typing: boolean } +} + +interface OutgoingRead { + type: 'read' + data: { conversation_id: number; message_id: number } +} + +const EVENT_TYPES: readonly WsEventType[] = ['message.created', 'typing', 'presence', 'read'] + +function isWsEvent(value: unknown): value is WsEvent { + if (typeof value !== 'object' || value === null) return false + const type = (value as { type?: unknown }).type + return typeof type === 'string' && (EVENT_TYPES as readonly string[]).includes(type) +} + +function buildWsUrl(): string { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${window.location.host}/ws` +} + +export class RealtimeClient { + private readonly url: string + private readonly handlers = new Map void>>() + private readonly listeners = new Set<(event: WsEvent) => void>() + private socket: WebSocket | null = null + private reconnectTimer: number | null = null + private reconnectAttempts = 0 + private shouldReconnect = false + + constructor(url?: string) { + this.url = url ?? buildWsUrl() + } + + connect(): void { + if (typeof WebSocket === 'undefined') return + if ( + this.socket && + (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) + ) { + return + } + + this.shouldReconnect = true + const socket = new WebSocket(this.url) + this.socket = socket + + socket.onopen = () => { + this.reconnectAttempts = 0 + } + + socket.onmessage = (event: MessageEvent) => { + if (typeof event.data === 'string') this.dispatch(event.data) + } + + socket.onerror = () => { + socket.close() + } + + socket.onclose = () => { + if (this.socket === socket) this.socket = null + if (this.shouldReconnect) this.scheduleReconnect() + } + } + + on(type: T, handler: Handler): () => void { + let group = this.handlers.get(type) + if (!group) { + group = new Set() + this.handlers.set(type, group) + } + const fn = handler as (event: WsEvent) => void + group.add(fn) + return () => { + group?.delete(fn) + } + } + + onEvent(listener: (event: WsEvent) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + sendTyping(conversationId: number, isTyping: boolean): void { + const payload: OutgoingTyping = { + type: 'typing', + data: { conversation_id: conversationId, is_typing: isTyping }, + } + this.send(payload) + } + + sendRead(conversationId: number, messageId: number): void { + const payload: OutgoingRead = { + type: 'read', + data: { conversation_id: conversationId, message_id: messageId }, + } + this.send(payload) + } + + close(): void { + this.shouldReconnect = false + if (this.reconnectTimer !== null) { + window.clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + const socket = this.socket + this.socket = null + if (socket) { + socket.onopen = null + socket.onmessage = null + socket.onerror = null + socket.onclose = null + socket.close() + } + } + + private send(payload: OutgoingTyping | OutgoingRead): void { + const socket = this.socket + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(payload)) + } + } + + private dispatch(raw: string): void { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return + } + if (!isWsEvent(parsed)) return + this.listeners.forEach((listener) => listener(parsed)) + this.handlers.get(parsed.type)?.forEach((handler) => handler(parsed)) + } + + private scheduleReconnect(): void { + if (this.reconnectTimer !== null) return + const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000) + this.reconnectAttempts += 1 + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null + this.connect() + }, delay) + } +}