frontend: messages, should work
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 19:56:06 +03:00
parent 099ecaa7e4
commit 1f3a0e4351
8 changed files with 825 additions and 0 deletions
+183
View File
@@ -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<void> | null = null
async function refreshSession(): Promise<void> {
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<string> {
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<T>(res: Response): Promise<T> {
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<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, authRetry = true } = options
const headers: Record<string, string> = {}
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<T>(path, { ...options, authRetry: false })
}
if (!res.ok) {
throw new ApiError(await readError(res), res.status)
}
return readJson<T>(res)
}
export interface ApiClient {
register(username: string, displayName: string, password: string): Promise<User>
login(username: string, password: string): Promise<User>
logout(): Promise<void>
refresh(): Promise<void>
me(): Promise<User>
searchUsers(query: string): Promise<User[]>
listContacts(): Promise<Contact[]>
addContact(userId: number): Promise<void>
removeContact(userId: number): Promise<void>
listConversations(): Promise<Conversation[]>
createDM(userId: number): Promise<Conversation>
listMessages(conversationId: number, before?: number, limit?: number): Promise<Message[]>
sendMessage(conversationId: number, body: string, tempId: string): Promise<SendMessageResult>
}
export const api: ApiClient = {
register(username, displayName, password) {
return request<User>('/auth/register', {
method: 'POST',
body: { username, display_name: displayName, password },
authRetry: false,
})
},
login(username, password) {
return request<User>('/auth/login', {
method: 'POST',
body: { username, password },
authRetry: false,
})
},
logout() {
return request<void>('/auth/logout', { method: 'POST', authRetry: false })
},
refresh() {
return request<void>('/auth/refresh', { method: 'POST', authRetry: false })
},
me() {
return request<User>('/users/me')
},
searchUsers(query) {
return request<User[]>(`/users?q=${encodeURIComponent(query)}`)
},
listContacts() {
return request<Contact[]>('/contacts')
},
addContact(userId) {
return request<void>('/contacts', { method: 'POST', body: { user_id: userId } })
},
removeContact(userId) {
return request<void>(`/contacts/${userId}`, { method: 'DELETE' })
},
listConversations() {
return request<Conversation[]>('/conversations')
},
createDM(userId) {
return request<Conversation>('/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<Message[]>(`/conversations/${conversationId}/messages?${params.toString()}`)
},
async sendMessage(conversationId, body, tempId) {
const message = await request<Message>(`/conversations/${conversationId}/messages`, {
method: 'POST',
body: { body, temp_id: tempId },
})
return { temp_id: tempId, message }
},
}
+91
View File
@@ -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<User, 'display_name' | 'username'>): 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<User, 'display_name' | 'username'>): 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<number, number>,
directory: Map<number, User>,
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
}
+83
View File
@@ -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
+155
View File
@@ -0,0 +1,155 @@
import type { WsEvent } from './types'
export type WsEventType = WsEvent['type']
type Handler<T extends WsEventType> = (event: Extract<WsEvent, { type: T }>) => 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<WsEventType, Set<(event: WsEvent) => 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<T extends WsEventType>(type: T, handler: Handler<T>): () => 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)
}
}