Skip to content
Clone
98e06cb
424 lines 16.1 KB
/**
* 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<Uuid>` 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 = <T>(value: T | '' | null | undefined): T | undefined => (value ? value : undefined)
const listOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T[]> =>
unwrapApi<T[] | null>(method as PromiseLike<ApiEnvelope<T[] | null>>).then((rows) => rows ?? [])
const nothing = (method: PromiseLike<ApiEnvelope<unknown>>) => unwrapApi<null>(method as PromiseLike<ApiEnvelope<null>>)
/* ------------------------------------------------------------------ rooms */
export const getRoom = (roomId: string) => unwrapApi<RoomBrief>(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<RoomBrief>(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<RoomMember[]> => {
if (!spaceName || spaceName === '@me') return []
const page = await unwrapApi<MemberSummaryPage | null>(
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<RoomSetting>(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<RoomSetting>(Apis.chat.update_room_setting({ pathParams: { room_id: roomId }, data: cmd }))
/* -------------------------------------------------------------------- dms */
export const openDm = (peerUserId: number) =>
unwrapApi<DmBrief>(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<MessagePage> =>
unwrapApi<MessagePage | null>(
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<ChatMessage>(Apis.chat.get_message({ pathParams: { room_id: roomId, message_id: messageId } }))
export const sendMessage = (roomId: string, input: SendMessageInput) =>
unwrapApi<ChatMessage>(
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<ChatMessage>(
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<MessageEdit>(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<ChatMessage>(
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<ReactionAgg>(
Apis.chat.add_reaction({
pathParams: { room_id: roomId, message_id: messageId },
data: { emoji },
}),
)
export const removeReaction = (roomId: string, messageId: string, emoji: string) =>
listOf<ReactionAgg>(
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<ReactionUser>(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<ChatMessage>(
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<ChatMessage>(Apis.chat.list_pins({ pathParams: { room_id: roomId } }))
/* -------------------------------------------------------------- read state */
export const getReadState = (roomId: string) =>
unwrapApi<ReadState>(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<ReadState>(
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<SpaceReadStates>(Apis.chat.list_space_read_states({ pathParams: { space_name: spaceName } })).then(
(state) => state?.items ?? [],
)
/* ------------------------------------------------------------------- drafts */
export const getDraft = (roomId: string, threadId?: string) =>
unwrapApi<Draft | null>(
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<Draft>(
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<ThreadBrief>(
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<ApiEnvelope<{ items?: ThreadBrief[] } | null>>,
).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<ThreadBrief>(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<ThreadBrief>(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<MemberSummaryPage | null>(
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<import('./chat').Presence>(Apis.chat.list_space_presences({ pathParams: { space_name: spaceName } }))
export const getUserPresence = (userId: number) =>
unwrapApi<Presence>(Apis.chat.get_presence({ pathParams: { user_id: userId } }))
/* ------------------------------------------------------------------ emojis */
/** Paged: the handler returns `Page<EmojiDto>`, so read `items` (a bare-array
* read handed the picker the page object and crashed its `.filter`). */
export const listSpaceEmojis = (spaceName: string) =>
unwrapApi<EmojiPage | null>(
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<UploadIntent>(Apis.chat.create_attachment({ data: cmd }))
export const finalizeAttachment = (
attachmentId: string,
meta: { width?: number | null; height?: number | null; duration_secs?: number | null } = {},
) =>
unwrapApi<Attachment>(
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<void> {
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
})
}