big update: added reactions, groups, media share
This commit is contained in:
+83
-4
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user