/** * Chat transport: every call the 聊天室 pages make, wrapped in the shared * envelope contract so the UI only ever sees unwrapped rows or an `ApiFailure`. * * Two schema quirks are handled here and nowhere else: * - `list_threads` is typed against the generic `Page` schema (items untyped), * so the thread row is asserted once, in {@link listRoomThreads}. * - `POST /chat/dms` carries the *list* query parameters in its generated * `pathParams`, because the spec shares one path with `GET /chat/dms`; they * are never substituted into the URL, so {@link openDm} passes nulls. * * Empty strings are dropped (`nz`), not sent: Alova omits `undefined` query * values but forwards `''`, and the backend's `Option` deserialization * rejects an empty `thread_id=` with a 400. */ import { getLocale, translate } from '@app/i18n' import { type ApiEnvelope, toMemberUserRow, unwrapApi } from '@app/lib' import type { MemberSummaryPage } from '@app/services/api' import Apis from '@app/services/api' import type { Attachment, ChatMessage, DmBrief, Draft, EmojiPage, MessageEdit, MessagePage, Presence, ReactionAgg, ReactionUser, ReadState, RoomBrief, RoomMember, RoomSetting, SendMessageInput, SpaceReadStates, ThreadBrief, UploadIntent, } from './chat' const nz = (value: T | '' | null | undefined): T | undefined => (value ? value : undefined) const listOf = (method: PromiseLike>): Promise => unwrapApi(method as PromiseLike>).then((rows) => rows ?? []) const nothing = (method: PromiseLike>) => unwrapApi(method as PromiseLike>) /* ------------------------------------------------------------------ rooms */ export const getRoom = (roomId: string) => unwrapApi(Apis.chat.get_room({ pathParams: { room_id: roomId } })) /** `undefined` leaves a field untouched. The backend takes single `Option`s * here (only `parent_id` is three-state), so clearing goes through the empty * value instead of `null`: `topic: ''` and `rate_limit_per_user: 0`. */ export const updateRoom = ( roomId: string, cmd: { name?: string topic?: string is_private?: boolean nsfw?: boolean rate_limit_per_user?: number position?: number }, ) => unwrapApi(Apis.chat.update_room({ pathParams: { room_id: roomId }, data: cmd })) export const deleteRoom = (roomId: string) => nothing(Apis.chat.delete_room({ pathParams: { room_id: roomId } })) /** A room's roster is the enclosing space's membership: the chat service has no * member endpoint of its own, and DMs (`@me`) have no roster to show. */ export const listRoomMembers = async (spaceName: string, _roomId: string): Promise => { if (!spaceName || spaceName === '@me') return [] const page = await unwrapApi( Apis.space.list_space_members({ pathParams: { space_name: spaceName }, params: { limit: 100 } }), ) return (page?.items ?? []).map((row) => toMemberUserRow(row) as unknown as RoomMember) } /** Per-viewer channel preferences (mute / favorite / hidden / notification level). */ export const getRoomSetting = (roomId: string) => unwrapApi(Apis.chat.get_room_setting({ pathParams: { room_id: roomId } })) export const updateRoomSetting = ( roomId: string, cmd: { muted?: boolean favorite?: boolean hidden?: boolean /** `0` lifts a mute; `null` would read as "unchanged". */ mute_minutes?: number notification_level?: 'Default' | 'All' | 'Mentions' | 'Nothing' }, ) => unwrapApi(Apis.chat.update_room_setting({ pathParams: { room_id: roomId }, data: cmd })) /* -------------------------------------------------------------------- dms */ export const openDm = (peerUserId: number) => unwrapApi(Apis.chat.open_dm({ data: { peer_user_id: peerUserId } })) export const closeDm = (roomId: string) => nothing(Apis.chat.close_dm({ pathParams: { room_id: roomId } })) /* -------------------------------------------------------------- transcript */ export interface MessageQuery { beforeSeq?: number afterSeq?: number limit?: number threadId?: string authorId?: number } /** Ascending by `seq`; `has_more` means *older* rows exist (before_seq path). */ export const listMessages = (roomId: string, query: MessageQuery = {}): Promise => unwrapApi( Apis.chat.list_messages({ pathParams: { room_id: roomId }, params: { before_seq: query.beforeSeq, after_seq: query.afterSeq, limit: query.limit, thread_id: nz(query.threadId), author_id: nz(query.authorId), }, }), ).then( (page) => page ?? { items: [], has_more: false, oldest_seq: null, newest_seq: null, limit: 0, offset: 0, total: 0 }, ) export const getMessage = (roomId: string, messageId: string) => unwrapApi(Apis.chat.get_message({ pathParams: { room_id: roomId, message_id: messageId } })) export const sendMessage = (roomId: string, input: SendMessageInput) => unwrapApi( Apis.chat.send_message({ pathParams: { room_id: roomId }, data: { content: input.content, nonce: nz(input.nonce), reply_to_id: input.reply_to_id ?? null, thread_id: input.thread_id ?? null, thread_broadcast: input.thread_broadcast ?? false, attachments: input.attachments ?? [], urgent: input.urgent ?? false, }, }), ) export const editMessage = (roomId: string, messageId: string, content: string) => unwrapApi( Apis.chat.update_message({ pathParams: { room_id: roomId, message_id: messageId }, data: { content }, }), ) export const deleteMessage = (roomId: string, messageId: string) => nothing(Apis.chat.delete_message({ pathParams: { room_id: roomId, message_id: messageId } })) export const bulkDeleteMessages = (roomId: string, messageIds: string[]) => nothing(Apis.chat.bulk_delete_messages({ pathParams: { room_id: roomId }, data: { message_ids: messageIds } })) export const listMessageEdits = (roomId: string, messageId: string) => listOf(Apis.chat.list_message_history({ pathParams: { room_id: roomId, message_id: messageId } })) /** Forward to another room: the backend copies the payload, no re-upload. */ export const forwardMessage = (roomId: string, messageId: string, targetRoomId: string, comment?: string) => unwrapApi( Apis.chat.forward_message({ pathParams: { room_id: roomId, message_id: messageId }, data: { target_room_id: targetRoomId, comment: nz(comment) ?? null }, }), ) /* --------------------------------------------------------------- reactions */ export const addReaction = (roomId: string, messageId: string, emoji: string) => listOf( Apis.chat.add_reaction({ pathParams: { room_id: roomId, message_id: messageId }, data: { emoji }, }), ) export const removeReaction = (roomId: string, messageId: string, emoji: string) => listOf( Apis.chat.remove_reaction({ pathParams: { room_id: roomId, message_id: messageId }, data: { emoji }, }), ) /** Who reacted, per emoji — the popover's detail list. */ export const listReactionUsers = (roomId: string, messageId: string) => listOf(Apis.chat.list_reactions({ pathParams: { room_id: roomId, message_id: messageId } })) /** Add or withdraw the viewer's reaction; returns the authoritative aggregate. */ export const setReaction = (roomId: string, messageId: string, emoji: string, on: boolean) => on ? addReaction(roomId, messageId, emoji) : removeReaction(roomId, messageId, emoji) /* -------------------------------------------------------------------- pins */ export const setMessagePin = (roomId: string, messageId: string, pinned: boolean) => unwrapApi( Apis.chat.set_message_pin({ pathParams: { room_id: roomId, message_id: messageId }, data: { pinned }, }), ) /** The handler answers with a bare array (its own utoipa body annotation still * claims a Page, which is why the generated type disagrees with the wire). */ export const listPins = (roomId: string) => listOf(Apis.chat.list_pins({ pathParams: { room_id: roomId } })) /* -------------------------------------------------------------- read state */ export const getReadState = (roomId: string) => unwrapApi(Apis.chat.get_read_state({ pathParams: { room_id: roomId } })) /** No cursor at all means "read everything" — the backend clamps to the tip. */ export const markRead = (roomId: string, cursor?: { messageId?: string; seq?: number }) => unwrapApi( Apis.chat.mark_read({ pathParams: { room_id: roomId }, data: { message_id: nz(cursor?.messageId) ?? null, seq: cursor?.seq ?? null }, }), ) /** Unread rows for every room of a space — one call for the whole sidebar. */ export const listSpaceReadStates = (spaceName: string) => unwrapApi(Apis.chat.list_space_read_states({ pathParams: { space_name: spaceName } })).then( (state) => state?.items ?? [], ) /* ------------------------------------------------------------------- drafts */ export const getDraft = (roomId: string, threadId?: string) => unwrapApi( Apis.chat.get_draft({ pathParams: { room_id: roomId }, params: { thread_id: nz(threadId) }, }), ) /** Empty content clears the draft server-side, so `''` is a meaningful write. */ export const saveDraft = (roomId: string, content: string, threadId?: string) => unwrapApi( Apis.chat.upsert_draft({ pathParams: { room_id: roomId }, data: { content, thread_id: nz(threadId) ?? null }, }), ) export const clearDraft = (roomId: string, threadId?: string) => nothing( Apis.chat.delete_draft({ pathParams: { room_id: roomId }, params: { thread_id: nz(threadId) }, }), ) /* ------------------------------------------------------------------ threads */ /** Idempotent: a root that already has a thread returns the existing one. */ export const createThread = (roomId: string, rootMessageId: string, name?: string) => unwrapApi( Apis.chat.create_thread({ pathParams: { room_id: roomId, message_id: rootMessageId }, data: { name: nz(name) ?? null }, }), ) export const listRoomThreads = (roomId: string) => unwrapApi<{ items?: ThreadBrief[] } | null>( Apis.chat.list_threads({ pathParams: { room_id: roomId }, params: { limit: 100 }, }) as unknown as PromiseLike>, ).then((page) => page?.items ?? []) export const listMyThreads = () => unwrapApi<{ items?: ThreadBrief[] } | null>(Apis.chat.list_my_threads({ params: { limit: 50 } })).then( (page) => page?.items ?? [], ) export const getThread = (threadId: string) => unwrapApi(Apis.chat.get_thread({ pathParams: { thread_id: threadId } })) export const updateThread = ( threadId: string, /** An empty `name` clears it server-side; `null` means "unchanged". */ cmd: { name?: string; archived?: boolean; locked?: boolean }, ) => unwrapApi(Apis.chat.update_thread({ pathParams: { thread_id: threadId }, data: cmd })) // `notify_level` is a three-state field, so both follow calls carry a body. export const followThread = (threadId: string, notifyLevel: 'All' | 'None' | null = null) => nothing( Apis.chat.follow_thread({ pathParams: { thread_id: threadId }, data: { notify_level: notifyLevel }, }), ) export const unfollowThread = (threadId: string) => nothing(Apis.chat.unfollow_thread({ pathParams: { thread_id: threadId } })) export const markThreadRead = (threadId: string, messageId?: string) => nothing( Apis.chat.mark_thread_read({ pathParams: { thread_id: threadId }, data: { message_id: nz(messageId) ?? null }, }), ) /* ------------------------------------------------------------ space people */ /** * A space member row, as the generic `Page` schema leaves it. `MemberDto` nests * the identity under `user` and ships no `user_id`/`role`/`group_ids`, so the id * is flattened in `listSpaceMembers` below — every caller reads `user_id`. */ export interface SpaceMemberRow { user?: import('@app/services/api').UserDto | null alias_name?: string | null status?: string | null } export interface SpaceMemberOption extends SpaceMemberRow { user_id: number } /** * People who can be pulled into a room. Only space members may join one, so this * is the source for the add-member picker (the same list the 问题集 uses for * assignees — both are "who is in this space", read from the space endpoint). */ export const listSpaceMembers = (spaceName: string) => unwrapApi( Apis.space.list_space_members({ pathParams: { space_name: spaceName }, params: { limit: 100 } }), ).then((page) => (page?.items ?? []).map((row) => ({ ...toMemberUserRow(row), user_id: row.user_id }))) /* ---------------------------------------------------------------- presence */ export const listSpacePresences = (spaceName: string) => listOf(Apis.chat.list_space_presences({ pathParams: { space_name: spaceName } })) export const getUserPresence = (userId: number) => unwrapApi(Apis.chat.get_presence({ pathParams: { user_id: userId } })) /* ------------------------------------------------------------------ emojis */ /** Paged: the handler returns `Page`, so read `items` (a bare-array * read handed the picker the page object and crashed its `.filter`). */ export const listSpaceEmojis = (spaceName: string) => unwrapApi( Apis.chat.list_emojis({ pathParams: { space_name: spaceName }, params: { limit: 100 } }), ).then((page) => page?.items ?? []) /* -------------------------------------------------------------- attachments */ /** * Three-step upload: reserve a row + presigned PUT, stream the bytes, then * `finalize` (which `head`s the object and refuses an empty/oversized blob). * Only a `Ready`/`Uploaded` attachment the caller owns may be referenced by * `send_message`, so a message never sends half-uploaded ids. */ export const createAttachment = (cmd: { filename: string; content_type: string; size: number }) => unwrapApi(Apis.chat.create_attachment({ data: cmd })) export const finalizeAttachment = ( attachmentId: string, meta: { width?: number | null; height?: number | null; duration_secs?: number | null } = {}, ) => unwrapApi( Apis.chat.finalize_attachment({ pathParams: { attachment_id: attachmentId }, data: { width: meta.width ?? null, height: meta.height ?? null, duration_secs: meta.duration_secs ?? null, }, }), ) /** Stream endpoint used by every `src`/download link: cookie-authed, no expiry. */ export { attachmentUrl } from './meta' /** * PUT the raw bytes to the presigned URL. The object store allows `*` origins, * so the browser talks to it directly; `trackFetch` deliberately does not wrap * this call — a dead bucket must not look like a dead API to the outage gate. */ export async function putUpload(uploadUrl: string, file: File): Promise { const response = await fetch(uploadUrl, { method: 'PUT', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file, }) if (!response.ok) { throw new Error(translate(getLocale(), 'chat.upload.failedStatus', { status: response.status })) } } /** Probe an image blob's intrinsic size for `finalize` (best effort). */ export function readImageSize(file: File): Promise<{ width: number; height: number } | null> { return new Promise((resolve) => { if (!file.type.startsWith('image/')) return resolve(null) const url = URL.createObjectURL(file) const image = new Image() const done = (value: { width: number; height: number } | null) => { URL.revokeObjectURL(url) resolve(value) } image.onload = () => done( image.naturalWidth > 0 && image.naturalHeight > 0 ? { width: image.naturalWidth, height: image.naturalHeight } : null, ) image.onerror = () => done(null) image.src = url }) }