2026-09-27 21:59:48 +03:00
|
|
|
import type { Contact, Conversation, Media, Member, Message, SendMessageResult, User } from './types'
|
2026-09-27 19:56:06 +03:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 21:59:48 +03:00
|
|
|
async function requestForm<T>(path: string, form: FormData, authRetry = true): Promise<T> {
|
|
|
|
|
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<T>(path, form, false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
throw new ApiError(await readError(res), res.status)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return readJson<T>(res)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 19:56:06 +03:00
|
|
|
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>
|
2026-09-27 21:59:48 +03:00
|
|
|
createGroup(name: string, memberIds: number[]): Promise<Conversation>
|
|
|
|
|
listMembers(conversationId: number): Promise<Member[]>
|
|
|
|
|
addMember(conversationId: number, userId: number): Promise<void>
|
|
|
|
|
removeMember(conversationId: number, userId: number): Promise<void>
|
2026-09-27 19:56:06 +03:00
|
|
|
listMessages(conversationId: number, before?: number, limit?: number): Promise<Message[]>
|
2026-09-27 21:59:48 +03:00
|
|
|
sendMessage(
|
|
|
|
|
conversationId: number,
|
|
|
|
|
body: string,
|
|
|
|
|
tempId: string,
|
|
|
|
|
mediaId?: number,
|
|
|
|
|
): Promise<SendMessageResult>
|
|
|
|
|
addReaction(conversationId: number, messageId: number, emoji: string): Promise<Message>
|
|
|
|
|
removeReaction(conversationId: number, messageId: number, emoji: string): Promise<Message>
|
|
|
|
|
uploadMedia(file: File): Promise<Media>
|
2026-09-27 19:56:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 } })
|
|
|
|
|
},
|
|
|
|
|
|
2026-09-27 21:59:48 +03:00
|
|
|
createGroup(name, memberIds) {
|
|
|
|
|
return request<Conversation>('/conversations', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: { type: 'group', name, member_ids: memberIds },
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
listMembers(conversationId) {
|
|
|
|
|
return request<Member[]>(`/conversations/${conversationId}/members`)
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
addMember(conversationId, userId) {
|
|
|
|
|
return request<void>(`/conversations/${conversationId}/members`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: { user_id: userId },
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
removeMember(conversationId, userId) {
|
|
|
|
|
return request<void>(`/conversations/${conversationId}/members/${userId}`, { method: 'DELETE' })
|
|
|
|
|
},
|
|
|
|
|
|
2026-09-27 19:56:06 +03:00
|
|
|
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()}`)
|
|
|
|
|
},
|
|
|
|
|
|
2026-09-27 21:59:48 +03:00
|
|
|
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
|
2026-09-27 19:56:06 +03:00
|
|
|
const message = await request<Message>(`/conversations/${conversationId}/messages`, {
|
|
|
|
|
method: 'POST',
|
2026-09-27 21:59:48 +03:00
|
|
|
body: payload,
|
2026-09-27 19:56:06 +03:00
|
|
|
})
|
|
|
|
|
return { temp_id: tempId, message }
|
|
|
|
|
},
|
2026-09-27 21:59:48 +03:00
|
|
|
|
|
|
|
|
addReaction(conversationId, messageId, emoji) {
|
|
|
|
|
return request<Message>(`/conversations/${conversationId}/messages/${messageId}/reactions`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
body: { emoji },
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
removeReaction(conversationId, messageId, emoji) {
|
|
|
|
|
return request<Message>(
|
|
|
|
|
`/conversations/${conversationId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`,
|
|
|
|
|
{ method: 'DELETE' },
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
uploadMedia(file) {
|
|
|
|
|
const form = new FormData()
|
|
|
|
|
form.append('file', file)
|
|
|
|
|
return requestForm<Media>('/media', form)
|
|
|
|
|
},
|
2026-09-27 19:56:06 +03:00
|
|
|
}
|