import type { Contact, Conversation, Media, Member, 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) } async function requestForm(path: string, form: FormData, authRetry = true): Promise { const res = await fetch(`${API_BASE}${path}`, { method: 'POST', credentials: 'include', body: form, }) if (res.status === 401 && authRetry) { try { await refreshSession() } catch { throw new UnauthorizedError() } return requestForm(path, form, 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 createGroup(name: string, memberIds: number[]): Promise listMembers(conversationId: number): Promise addMember(conversationId: number, userId: number): Promise removeMember(conversationId: number, userId: number): Promise listMessages(conversationId: number, before?: number, limit?: number): Promise sendMessage( conversationId: number, body: string, tempId: string, mediaId?: number, ): Promise addReaction(conversationId: number, messageId: number, emoji: string): Promise removeReaction(conversationId: number, messageId: number, emoji: string): Promise uploadMedia(file: File): 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 } }) }, createGroup(name, memberIds) { return request('/conversations', { method: 'POST', body: { type: 'group', name, member_ids: memberIds }, }) }, listMembers(conversationId) { return request(`/conversations/${conversationId}/members`) }, addMember(conversationId, userId) { return request(`/conversations/${conversationId}/members`, { method: 'POST', body: { user_id: userId }, }) }, removeMember(conversationId, userId) { return request(`/conversations/${conversationId}/members/${userId}`, { method: 'DELETE' }) }, 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, mediaId) { const payload: { body: string; temp_id: string; media_id?: number } = { body, temp_id: tempId } if (mediaId !== undefined) payload.media_id = mediaId const message = await request(`/conversations/${conversationId}/messages`, { method: 'POST', body: payload, }) return { temp_id: tempId, message } }, addReaction(conversationId, messageId, emoji) { return request(`/conversations/${conversationId}/messages/${messageId}/reactions`, { method: 'PUT', body: { emoji }, }) }, removeReaction(conversationId, messageId, emoji) { return request( `/conversations/${conversationId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`, { method: 'DELETE' }, ) }, uploadMedia(file) { const form = new FormData() form.append('file', file) return requestForm('/media', form) }, }