big update: added reactions, groups, media share
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 21:59:48 +03:00
parent ae24bdd0e0
commit 9d03ea4c51
41 changed files with 2548 additions and 172 deletions
+83 -4
View File
@@ -1,4 +1,4 @@
import type { Contact, Conversation, Message, SendMessageResult, User } from './types'
import type { Contact, Conversation, Media, Member, Message, SendMessageResult, User } from './types'
const API_BASE = '/api/v1'
@@ -98,6 +98,29 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
return readJson<T>(res)
}
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)
}
export interface ApiClient {
register(username: string, displayName: string, password: string): Promise<User>
login(username: string, password: string): Promise<User>
@@ -110,8 +133,20 @@ export interface ApiClient {
removeContact(userId: number): Promise<void>
listConversations(): Promise<Conversation[]>
createDM(userId: number): Promise<Conversation>
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>
listMessages(conversationId: number, before?: number, limit?: number): Promise<Message[]>
sendMessage(conversationId: number, body: string, tempId: string): Promise<SendMessageResult>
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>
}
export const api: ApiClient = {
@@ -167,17 +202,61 @@ export const api: ApiClient = {
return request<Conversation>('/conversations', { method: 'POST', body: { user_id: userId } })
},
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' })
},
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) {
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<Message>(`/conversations/${conversationId}/messages`, {
method: 'POST',
body: { body, temp_id: tempId },
body: payload,
})
return { temp_id: tempId, message }
},
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)
},
}
+9
View File
@@ -76,6 +76,15 @@ export function minutesBetween(a: string, b: string): number {
return Math.abs(first.getTime() - second.getTime()) / 60000
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 Б'
const units = ['Б', 'КБ', 'МБ', 'ГБ'] as const
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / 1024 ** index
const rounded = index === 0 || value >= 10 ? Math.round(value) : Number(value.toFixed(1))
return `${rounded} ${units[index]}`
}
export function peerForConversation(
conversation: Conversation,
peers: Record<number, number>,
+90 -1
View File
@@ -11,11 +11,27 @@ export interface Contact {
added_at: string
}
export interface Reaction {
emoji: string
user_id: number
}
export interface Media {
id: number
filename: string
content_type: string
size: number
url: string
thumb_url?: string | null
}
export interface Message {
id: number
conversation_id: number
sender_id: number
body: string
reactions: Reaction[]
media?: Media | null
created_at: string
edited_at?: string | null
deleted_at?: string | null
@@ -32,11 +48,21 @@ export interface ClientMessage extends Message {
export interface Conversation {
id: number
type: string
name: string
avatar: string | null
created_at: string
last_message?: Message | null
unread_count?: number
}
export type MemberRole = 'owner' | 'member'
export interface Member {
user: User
role: MemberRole
joined_at: string
}
export interface SendMessageResult {
temp_id: string
message: Message
@@ -51,6 +77,9 @@ export interface WsMessageCreated {
sender_id: number
body: string
created_at: string
reactions?: Reaction[]
media_id?: number
media?: Media | null
}
}
@@ -80,4 +109,64 @@ export interface WsRead {
}
}
export type WsEvent = WsMessageCreated | WsTyping | WsPresence | WsRead
export interface WsConversationAdded {
type: 'conversation.added'
data: {
conversation_id: number
}
}
export interface WsConversationRemoved {
type: 'conversation.removed'
data: {
conversation_id: number
}
}
export interface WsMemberAdded {
type: 'member.added'
data: {
conversation_id: number
user_id: number
}
}
export interface WsMemberRemoved {
type: 'member.removed'
data: {
conversation_id: number
user_id: number
}
}
export interface WsReactionAdded {
type: 'reaction.added'
data: {
conversation_id: number
message_id: number
user_id: number
emoji: string
}
}
export interface WsReactionRemoved {
type: 'reaction.removed'
data: {
conversation_id: number
message_id: number
user_id: number
emoji: string
}
}
export type WsEvent =
| WsMessageCreated
| WsTyping
| WsPresence
| WsRead
| WsConversationAdded
| WsConversationRemoved
| WsMemberAdded
| WsMemberRemoved
| WsReactionAdded
| WsReactionRemoved
+12 -1
View File
@@ -14,7 +14,18 @@ interface OutgoingRead {
data: { conversation_id: number; message_id: number }
}
const EVENT_TYPES: readonly WsEventType[] = ['message.created', 'typing', 'presence', 'read']
const EVENT_TYPES: readonly WsEventType[] = [
'message.created',
'typing',
'presence',
'read',
'conversation.added',
'conversation.removed',
'member.added',
'member.removed',
'reaction.added',
'reaction.removed',
]
function isWsEvent(value: unknown): value is WsEvent {
if (typeof value !== 'object' || value === null) return false