Adds removeGroupMember (DELETE /groups/{topic}/members/{user_id}) --
confirmed live the backend now has this route (server/im_http.go's
serveIMGroupMember + canRemoveIMGroupMember), which an earlier pass
recorded as a genuine protocol gap; it isn't anymore. Wired into
GroupMembersModal with a per-row "移出" button gated by the same rule
the backend enforces (owner or admin removes a plain member, only the
owner removes an admin, nobody removes the owner or themselves), with
a confirm dialog before the real DELETE call. Verified live end-to-end
against a real owned group: member count 4 -> 3, list updates
correctly (the side panel's own separate "群成员 N" summary count
still trailed behind after this -- confirmed via a direct GET that the
server's own group payload hadn't recalculated member_count yet, a
backend read-consistency lag unrelated to this fix, not chased
further).
Also fixes the reported "已处理的群邀请消息,即使群解散了,接受邀请
按钮还是一直可以点" bug: the button's visibility used to be derived
purely from the notification's static action payload every render, so
neither a successful accept nor a definitively-failed one (expired,
already handled, group gone) ever stopped it from looking freshly
clickable again. Tracks per-invitation resolved state (accepted/
failed) once either outcome happens and swaps the button for a static
resolved label. Doesn't survive a page reload for invitations resolved
in an earlier visit (the notification record itself carries no
resolved flag to read back) -- a real, acknowledged limit, not
silently hidden.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
503 lines
24 KiB
TypeScript
503 lines
24 KiB
TypeScript
// Group REST endpoints (docs/IM-前后端对接文档.md ch.6). Path/field shapes ported from the old app's
|
|
// src/lib/im-api.js (lines 686-759), whose own header states they were verified directly against the
|
|
// deployed backend -- its inline comments flag three real doc/server mismatches this file follows
|
|
// instead of the doc where they conflict (see each function's own comment):
|
|
// - reject-join-request has no `reason` field (server 400s if sent, DisallowUnknownFields)
|
|
// - group-files search param is `q`, not `query`
|
|
// - addGroupFile needs more fields than the doc's minimal example, or it registers blank metadata
|
|
// Unlike P2P conversation settings, almost the entire group management surface is REST, not native
|
|
// Tinode WS -- confirmed against the old app's actually-shipped UI (group-management-view.jsx), which
|
|
// never calls topic.setMeta({desc}) or {set sub private} for groups despite the protocol doc showing
|
|
// WS equivalents exist on the wire. Only message send/receive/typing/receipts stay on WS (topics.ts).
|
|
import { fetchAllPages } from './client';
|
|
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
|
|
|
|
export interface GroupPermissions {
|
|
can_edit_profile?: boolean;
|
|
can_invite_members?: boolean;
|
|
can_manage_members?: boolean;
|
|
can_manage_settings?: boolean;
|
|
can_leave?: boolean;
|
|
can_mention_all?: boolean;
|
|
}
|
|
|
|
export interface GroupSummaryItem {
|
|
topic: string;
|
|
name: string;
|
|
avatar_file_id?: string;
|
|
member_count: number;
|
|
my_role: GroupRole;
|
|
updated_at?: string;
|
|
}
|
|
|
|
export function getGroups(category: 'owned' | 'managed' | 'joined' | '' = '', query = '', cursor = '') {
|
|
const qs = new URLSearchParams();
|
|
if (category) qs.set('category', category);
|
|
if (query) qs.set('query', query);
|
|
if (cursor) qs.set('cursor', cursor);
|
|
const suffix = qs.toString();
|
|
return imGet<{ items: GroupSummaryItem[]; next_cursor?: string; has_more?: boolean }>(`/groups${suffix ? `?${suffix}` : ''}`);
|
|
}
|
|
|
|
export interface GroupProfile {
|
|
topic: string;
|
|
name: string;
|
|
avatar_file_id?: string;
|
|
description?: string;
|
|
owner?: string;
|
|
member_count: number;
|
|
version: number;
|
|
permissions?: GroupPermissions;
|
|
}
|
|
|
|
export function createGroup(body: { name: string; description?: string; member_user_ids: string[]; avatar_file_id?: string }) {
|
|
return imPost<GroupProfile>('/groups', body);
|
|
}
|
|
|
|
export function getGroup(topic: string) {
|
|
return imGet<GroupProfile>(`/groups/${encodeURIComponent(topic)}`);
|
|
}
|
|
|
|
export function updateGroup(topic: string, patch: { name?: string; avatar_file_id?: string; description?: string; version: number }) {
|
|
return imPatch<GroupProfile>(`/groups/${encodeURIComponent(topic)}`, patch);
|
|
}
|
|
|
|
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): each field's "can I edit this" flag is
|
|
* a flat `{field}_editable` sibling, NOT a nested `editable: {...}` object as the doc's phrasing
|
|
* ("每个字段返回 editable") could be read either way -- the doc itself never shows a worked
|
|
* example for this response, so this was a guess until checked against the real payload. */
|
|
export interface GroupGovernance {
|
|
join_approval: boolean;
|
|
join_approval_editable?: boolean;
|
|
mute_all: boolean;
|
|
mute_all_editable?: boolean;
|
|
member_upload_files: boolean;
|
|
member_upload_files_editable?: boolean;
|
|
member_private_chat: boolean;
|
|
member_private_chat_editable?: boolean;
|
|
member_invite: boolean;
|
|
member_invite_editable?: boolean;
|
|
version: number;
|
|
}
|
|
|
|
/**
|
|
* Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`) against three original guesses in this
|
|
* type: there is no `announcement` field at all (the summary endpoint carries that instead, see
|
|
* GroupDetailColumn.tsx/ConversationView.tsx's own comments); profile fields (`name`, `description`,
|
|
* `avatar_file_id`, `version`) come back FLAT on this object, not nested under a `profile` key; and
|
|
* `governance` is the FULL `GroupGovernance` shape (every `*_editable` flag and its own `version`),
|
|
* not the 3-field `Pick` this originally guessed. `owner`/`message_retention` are real fields this
|
|
* file hadn't typed at all until this pass.
|
|
*/
|
|
export interface GroupSettings {
|
|
topic: string;
|
|
name: string;
|
|
description?: string;
|
|
avatar_file_id?: string;
|
|
owner: string;
|
|
my_role: GroupRole;
|
|
permissions?: GroupPermissions;
|
|
member_count: number;
|
|
pinned: boolean;
|
|
mute_notifications: boolean;
|
|
message_retention?: string;
|
|
governance?: GroupGovernance;
|
|
version: number;
|
|
}
|
|
|
|
export function getGroupSettings(topic: string) {
|
|
return imGet<GroupSettings>(`/groups/${encodeURIComponent(topic)}/settings`);
|
|
}
|
|
|
|
export interface GroupMemberSettings {
|
|
pinned: boolean;
|
|
mute_notifications: boolean;
|
|
version: number;
|
|
updated_at: string;
|
|
}
|
|
|
|
/** No `version` in the request body -- confirmed live 2026-09-09: the backend struct for this
|
|
* endpoint only declares `pinned`/`mute_notifications`, and strict unknown-field JSON decoding
|
|
* 400s MALFORMED_REQUEST on anything else, `version` included. This endpoint doesn't do optimistic
|
|
* locking at all (the server bumps its own stored version unconditionally on every call); the
|
|
* `version` in the RESPONSE is read-only bookkeeping, not something the client should echo back.
|
|
* This one bug made every group's 置顶聊天/消息免打扰 toggle unusable, on every account/role tested. */
|
|
export function updateGroupMemberSettings(topic: string, patch: { pinned?: boolean; mute_notifications?: boolean }) {
|
|
return imPatch<GroupMemberSettings>(`/groups/${encodeURIComponent(topic)}/member-settings`, patch);
|
|
}
|
|
|
|
export function leaveGroup(topic: string) {
|
|
return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {});
|
|
}
|
|
|
|
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.17) -- get one via
|
|
* `requestActionToken({scene:'group_dissolve', ...})` (stores/securityChallenge.ts) first. Live,
|
|
* fully implemented server-side (a stale comment here previously claimed otherwise) -- confirmed by
|
|
* actually calling it. An explicit empty-object body is required: a bodiless DELETE still carries
|
|
* this client's own `Content-Type: application/json` header, and the server's JSON decoder treats
|
|
* a truly empty body under that header as malformed rather than "no optional fields provided". */
|
|
export function disbandGroup(topic: string, actionToken: string) {
|
|
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`, {
|
|
body: {},
|
|
headers: { 'X-Action-Token': actionToken },
|
|
});
|
|
}
|
|
|
|
export type GroupRole = 'owner' | 'admin' | 'member';
|
|
|
|
export interface GroupMember {
|
|
user_id: string;
|
|
display_name?: string;
|
|
avatar_file_id?: string;
|
|
role: GroupRole;
|
|
mentionable?: boolean;
|
|
eligible?: boolean;
|
|
/** Confirmed live (2026-09-08): present when `eligible:false`, e.g. `"owner"` -- not in the
|
|
* protocol doc's worked examples at all. Not yet surfaced in any UI (the admin/mute/owner-
|
|
* transfer pickers just filter ineligible candidates out rather than showing them grayed out
|
|
* with a reason), but real and worth keeping typed since it's cheap and already observed. */
|
|
ineligible_reason?: string;
|
|
acs?: { want?: string; given?: string; mode?: string };
|
|
}
|
|
|
|
/** `GET .../members` never returns `group_nickname` on any item (confirmed live by the old app) --
|
|
* only getGroupMemberProfile() does. Don't read nickname off this list. */
|
|
export function getGroupMembers(
|
|
topic: string,
|
|
opts: { query?: string; cursor?: string; role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
|
|
) {
|
|
const qs = new URLSearchParams();
|
|
if (opts.query) qs.set('query', opts.query);
|
|
if (opts.cursor) qs.set('cursor', opts.cursor);
|
|
if (opts.role) qs.set('role', opts.role);
|
|
if (opts.eligibleRole) qs.set('eligible_role', opts.eligibleRole);
|
|
if (opts.mentionable) qs.set('mentionable', 'true');
|
|
if (opts.muteEligible) qs.set('mute_eligible', 'true');
|
|
const suffix = qs.toString();
|
|
return imGet<{ items: GroupMember[]; next_cursor?: string; permissions?: { can_mention_all?: boolean } }>(
|
|
`/groups/${encodeURIComponent(topic)}/members${suffix ? `?${suffix}` : ''}`,
|
|
);
|
|
}
|
|
|
|
export function getAllGroupMembers(
|
|
topic: string,
|
|
opts: { role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
|
|
) {
|
|
return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor }));
|
|
}
|
|
|
|
/**
|
|
* `DELETE /groups/{topic}/members/{user_id}` -- confirmed live (2026-09-23) this route now exists
|
|
* (`server/im_http.go`'s `serveIMGroupMember`, `canRemoveIMGroupMember`): owner or admin can remove a
|
|
* plain member, only the owner can remove an admin, nobody (owner included) can remove the owner or
|
|
* themselves. An earlier pass through the protocol doc found no such operation at all -- that was
|
|
* true then, the backend has since added it, this isn't a doc-vs-reality mismatch this time.
|
|
*/
|
|
export function removeGroupMember(topic: string, userId: string) {
|
|
return imDelete<{ topic: string; user_id: string; status: string }>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}`);
|
|
}
|
|
|
|
export interface GroupMemberProfile {
|
|
topic: string;
|
|
user_id: string;
|
|
display_name?: string;
|
|
group_nickname?: string;
|
|
role: GroupRole;
|
|
relationship?: string;
|
|
capabilities?: { can_send?: boolean; can_call?: boolean };
|
|
}
|
|
|
|
/** `topic` must be the `grp...` id, never a `usr...` id -- confirmed by the protocol doc's own warning.
|
|
* Do NOT call this with your own user_id: the backend computes a self/other relationship context
|
|
* for this generic path that errors out when both sides are the same uid (confirmed live 2026-09-09,
|
|
* stable 400 MALFORMED_REQUEST). Use `getMyGroupProfile()` for your own group nickname instead --
|
|
* the backend added a dedicated `members/me/profile` route that sidesteps this entirely. */
|
|
export function getGroupMemberProfile(topic: string, userId: string) {
|
|
return imGet<GroupMemberProfile>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}/profile`);
|
|
}
|
|
|
|
/** Dedicated self-profile route -- unlike getGroupMemberProfile(topic, myUserId), this one actually
|
|
* works (see that function's own warning) and, as a bonus, returns the real optimistic-lock `version`
|
|
* on every read, so callers no longer need to remember the last save's version between edits. */
|
|
export function getMyGroupProfile(topic: string) {
|
|
return imGet<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`);
|
|
}
|
|
|
|
export function updateMyGroupNickname(topic: string, nickname: string, version: number) {
|
|
return imPut<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`, {
|
|
nickname,
|
|
version,
|
|
});
|
|
}
|
|
|
|
export function getGroupRoleLimits(topic: string) {
|
|
return imGet<{ max_admin_count: number; current_admin_count: number }>(`/groups/${encodeURIComponent(topic)}/role-limits`);
|
|
}
|
|
|
|
export function addGroupAdmins(topic: string, userIds: string[], version: number) {
|
|
return imPost<{
|
|
succeeded: Array<{ user_id: string; role: GroupRole }>;
|
|
failed?: Array<{ user_id: string; reason: string }>;
|
|
admins: GroupMember[];
|
|
version: number;
|
|
}>(`/groups/${encodeURIComponent(topic)}/admins/batch`, { user_ids: userIds, version });
|
|
}
|
|
|
|
export function removeGroupAdmin(topic: string, userId: string, version: number) {
|
|
return imDelete<{ user_id: string; role: GroupRole; updated_at: string }>(
|
|
`/groups/${encodeURIComponent(topic)}/admins/${encodeURIComponent(userId)}`,
|
|
{ headers: { 'If-Match': String(version) } },
|
|
);
|
|
}
|
|
|
|
/** Real response is flat (confirmed live: `{muted_at,muted_by,reason,status,user_id,version}`) --
|
|
* no nested `user` object, and no `display_name` at all (unlike e.g. group members, this endpoint
|
|
* never returns a readable name, only the bare id -- callers needing one must cross-reference the
|
|
* member list themselves, same as GroupFilesModal already has to for `uploader_id`). */
|
|
export interface GroupMute {
|
|
user_id: string;
|
|
muted_by?: string;
|
|
muted_at: string;
|
|
expires_at?: string | null;
|
|
reason?: string;
|
|
status?: string;
|
|
version?: number;
|
|
}
|
|
|
|
export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) {
|
|
// NOT `/mutes/batch` -- confirmed against the real backend (im_be/server/im_http.go's routing
|
|
// switch has no such case, so that path 405s unconditionally) -- the real batch-mute endpoint is
|
|
// plain `POST .../mutes` (the same base path `getGroupMutes` GETs from), body shape unchanged.
|
|
return imPost<{ items: Array<{ user_id: string; muted_by: string; muted_at: string; expires_at?: string; reason?: string; version: number }> }>(
|
|
`/groups/${encodeURIComponent(topic)}/mutes`,
|
|
{ members, reason },
|
|
);
|
|
}
|
|
|
|
export function getGroupMutes(topic: string, cursor = '') {
|
|
return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`);
|
|
}
|
|
|
|
/** `getGroupMutes` alone only ever reads the first page -- unlike `getAllGroupMembers`'s own
|
|
* `fetchAllPages` wrapper right above, GroupAdminModal's mutes view called the single-page
|
|
* function directly, silently truncating both the list and its own header count once a group has
|
|
* enough muted members to span more than one page. */
|
|
export function getAllGroupMutes(topic: string) {
|
|
return fetchAllPages((cursor) => getGroupMutes(topic, cursor));
|
|
}
|
|
|
|
export function unmuteGroupMember(topic: string, userId: string) {
|
|
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
|
|
}
|
|
|
|
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- get one via
|
|
* `requestActionToken({scene:'group_owner_transfer', ...})` (stores/securityChallenge.ts) first. */
|
|
export function transferGroupOwner(topic: string, targetUserId: string, version: number, actionToken: string) {
|
|
return imPost<{ owner: string; previous_owner: string; version: number }>(
|
|
`/groups/${encodeURIComponent(topic)}/owner-transfer`,
|
|
{ target_user_id: targetUserId, version },
|
|
{ headers: { 'X-Action-Token': actionToken } },
|
|
);
|
|
}
|
|
|
|
export function getGroupGovernance(topic: string) {
|
|
return imGet<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`);
|
|
}
|
|
|
|
export function updateGroupGovernance(
|
|
topic: string,
|
|
patch: Partial<Pick<GroupGovernance, 'join_approval' | 'mute_all' | 'member_upload_files' | 'member_private_chat' | 'member_invite'>> & { version: number },
|
|
) {
|
|
return imPatch<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`, patch);
|
|
}
|
|
|
|
/**
|
|
* Per-invitee outcome, confirmed by reading im_be's serveIMGroupInvitations directly (not just the
|
|
* doc's own single-outcome worked example): `"joined"` when the group's own governance AND the
|
|
* target's own privacy setting both don't require approval (added immediately, no further action
|
|
* needed); `"already_member"`; `"pending"`/`"pending_approval"` when either side DOES require it (a
|
|
* real GroupInvitation record is created instead, delivered to the target as a system notification --
|
|
* see SystemMessagesView's `group.invitation.created` handling -- and needs their own accept, this
|
|
* app has no UI surface for the inviter to ever learn the outcome beyond "sent"); `"failed"` with a
|
|
* `reason`.
|
|
*/
|
|
export interface GroupInvitationResult {
|
|
user_id: string;
|
|
status: 'joined' | 'already_member' | 'pending' | 'pending_approval' | 'failed';
|
|
invitation_id?: string;
|
|
reason?: string;
|
|
}
|
|
|
|
export function inviteGroupMembers(topic: string, userIds: string[], message?: string) {
|
|
return imPost<{ items: GroupInvitationResult[] }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
|
|
user_ids: userIds,
|
|
message,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The target's own action on a `group.invitation.created` system notification (README/docs
|
|
* :1464/1739 -- doc marks this "待实现" but confirmed by reading im_be's serveIMGroupInvitationResource
|
|
* directly, it's real and live, same doc-drift as the topic-draft endpoint). Joins the group
|
|
* immediately on success (no further approval step at this point -- any approval requirement was
|
|
* already resolved by the invite-creation side choosing "pending"/"pending_approval" as the status
|
|
* instead of creating a real invitation, or is inherent to a `pending_approval` invitation itself,
|
|
* which this same endpoint resolves).
|
|
*/
|
|
export function acceptGroupInvitation(invitationId: string) {
|
|
return imPost<{ invitation_id: string; status: string; topic: string }>(`/group-invitations/${encodeURIComponent(invitationId)}/accept`);
|
|
}
|
|
|
|
/** Confirmed LIVE (2026-09-09) this does NOT match either the protocol doc's own worked example
|
|
* (`docs/IM-前后端对接文档.md:1741`, which shows a nested `applicant:{user_id,display_name}`
|
|
* object) or this file's own previous type -- the real response is a FLAT `user_id` string with no
|
|
* display name anywhere on it at all, plus `inviter_id`/`message`/`handled_by` fields the doc's
|
|
* example doesn't show. Reading `req.applicant.user_id` against this real shape would throw
|
|
* (`applicant` is `undefined`) the first time this modal ever rendered a real request -- caught by
|
|
* live-calling `getGroupJoinRequests` directly, not by reading the doc or the mock. */
|
|
export interface GroupJoinRequest {
|
|
request_id: string;
|
|
user_id: string;
|
|
/** Non-empty only when `source` reflects an invite (mock's "王工 邀请" rows) -- empty string for
|
|
* a self-initiated request (search/QR). */
|
|
inviter_id: string;
|
|
/** The applicant's own join message, if any (mock: `"我是新来的测试同学"`). */
|
|
message?: string;
|
|
status: string;
|
|
source: string;
|
|
created_at: string;
|
|
handled_at?: string;
|
|
handled_by?: string;
|
|
version: number;
|
|
}
|
|
|
|
/** docs/IM-前后端对接文档.md:1591,1740 -- submitting a join request (the requester's own side; every
|
|
* other function in this block is the admin's reviewing side). `source` and `qr_token` are only
|
|
* relevant for the QR-scan path (GroupQRCodeModal/QRScannerModal's `join_group` purpose) -- a plain
|
|
* "request to join" with no QR code involved would omit both, though this app doesn't have another
|
|
* UI entry point that reaches a group's join-requests without already being a member (no public
|
|
* group directory/search exists anywhere in this codebase or its design handoff). */
|
|
export function submitGroupJoinRequest(topic: string, body: { message?: string; source?: 'qr' | string; qr_token?: string } = {}) {
|
|
return imPost<{ request_id: string; topic: string; status: string; created_at: string; version: number }>(
|
|
`/groups/${encodeURIComponent(topic)}/join-requests`,
|
|
body,
|
|
);
|
|
}
|
|
|
|
export function getGroupJoinRequests(topic: string, status: 'pending' | 'all' = 'pending', cursor = '') {
|
|
const qs = new URLSearchParams({ status });
|
|
if (cursor) qs.set('cursor', cursor);
|
|
return imGet<{ items: GroupJoinRequest[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/join-requests?${qs.toString()}`);
|
|
}
|
|
|
|
export function acceptGroupJoinRequest(topic: string, requestId: string, version: number) {
|
|
return imPost<{ request_id: string; status: string; version: number }>(
|
|
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/accept`,
|
|
{ version },
|
|
);
|
|
}
|
|
|
|
/** Body is `{version}` ONLY -- no `reason` field. The protocol doc's own example shows a `reason`
|
|
* key, but the old app's im-api.js confirmed live that the real server struct doesn't have one and
|
|
* every request body is validated with DisallowUnknownFields(), so sending `reason` 400s the call. */
|
|
export function rejectGroupJoinRequest(topic: string, requestId: string, version: number) {
|
|
return imPost<{ request_id: string; status: string; version: number }>(
|
|
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/reject`,
|
|
{ version },
|
|
);
|
|
}
|
|
|
|
/** Confirmed live: `announcement` is always a present object, even with nothing published --
|
|
* every field just comes back zero-valued (`content:""` etc), never `null`. Check `content` for
|
|
* emptiness, not object existence -- same gotcha as `getLatestGroupAnnouncement` below. */
|
|
export function getGroupSummary(topic: string) {
|
|
return imGet<{
|
|
pending_join_request_count: number;
|
|
announcement?: { announcement_id: string; content: string; author: string; published_at: string; version: number } | null;
|
|
permissions?: GroupPermissions;
|
|
}>(`/groups/${encodeURIComponent(topic)}/summary`);
|
|
}
|
|
|
|
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not
|
|
* the `published_by` object this file originally guessed -- resolve it against the group's member
|
|
* list for a display name/role, same as any other bare user id in this API. When nothing has been
|
|
* published yet the endpoint still returns `200` with every field zero-valued (`announcement_id:"",
|
|
* author:"", content:"", published_at:"", version:0`), never `404`/`null` -- callers must check
|
|
* `content` for emptiness, not object existence.
|
|
* `read_count`/`member_count`/`read_summary` were confirmed ABSENT on 2026-09-08 (dropped from this
|
|
* type entirely, not just left optional) -- backend added them in the 2026-09-18 deploy (confirmed
|
|
* by reading `groupAnnouncementPayload` in `im_be/server/im_http.go`, which now sets all three).
|
|
* Optional here because an old cached response (or a tenant not yet on the new deploy) may still
|
|
* omit them. */
|
|
export interface GroupAnnouncement {
|
|
announcement_id: string;
|
|
topic: string;
|
|
content: string;
|
|
version: number;
|
|
published_at: string;
|
|
author: string;
|
|
permissions?: { can_edit?: boolean };
|
|
read_count?: number;
|
|
member_count?: number;
|
|
read_summary?: { read_count: number; member_count: number };
|
|
}
|
|
|
|
export function getLatestGroupAnnouncement(topic: string) {
|
|
return imGet<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/latest`);
|
|
}
|
|
|
|
export function updateCurrentGroupAnnouncement(topic: string, content: string, version: number) {
|
|
return imPut<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/current`, { content, version });
|
|
}
|
|
|
|
export function readGroupAnnouncement(topic: string, announcementId: string) {
|
|
return imPost<{ announcement_id: string; read_at: string }>(
|
|
`/groups/${encodeURIComponent(topic)}/announcements/${encodeURIComponent(announcementId)}/read`,
|
|
{},
|
|
);
|
|
}
|
|
|
|
/** Confirmed live (2026-09-08, real registered file `gfile_PNof3SOlWz0`): fields are FLAT
|
|
* `file_name`/`file_size`/`mime_type` (not the doc-adjacent `name`/`size`/`mime` this type
|
|
* previously declared -- undetected until now because this list only ever rendered its own
|
|
* `暂无群文件` empty state, never a real row), `uploader_id` is a bare string (no nested `uploader`
|
|
* object, no `display_name` alongside it), and there is no `permissions` object at all -- gating a
|
|
* delete affordance needs a different signal (see GroupFilesModal's own comment on how it copes). */
|
|
export interface GroupFile {
|
|
group_file_id: string;
|
|
file_id: string;
|
|
file_name: string;
|
|
file_size: number;
|
|
mime_type: string;
|
|
uploader_id?: string;
|
|
created_at: string;
|
|
download_state?: string;
|
|
}
|
|
|
|
/** Search param is `q`, not `query` as the protocol doc's own example URL shows -- confirmed live by
|
|
* the old app that the real server reads `q` and silently no-ops a `query` param instead. */
|
|
export function getGroupFiles(topic: string, q = '', type = '', cursor = '') {
|
|
const qs = new URLSearchParams();
|
|
if (q) qs.set('q', q);
|
|
if (type) qs.set('type', type);
|
|
if (cursor) qs.set('cursor', cursor);
|
|
const suffix = qs.toString();
|
|
return imGet<{ items: GroupFile[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/files${suffix ? `?${suffix}` : ''}`);
|
|
}
|
|
|
|
/**
|
|
* The doc's minimal `{file_id}` body registers a file with blank name/0 size, since every other
|
|
* field is server-side `omitempty` -- confirmed live by the old app, which always sends the full
|
|
* set below.
|
|
*/
|
|
export function addGroupFile(
|
|
topic: string,
|
|
file: { file_id: string; file_name: string; mime_type: string; file_type: string; file_size: number; source_topic?: string; source_seq?: number },
|
|
) {
|
|
return imPost<GroupFile>(`/groups/${encodeURIComponent(topic)}/files`, file);
|
|
}
|
|
|
|
export function deleteGroupFile(topic: string, groupFileId: string) {
|
|
return imDelete<void>(`/groups/${encodeURIComponent(topic)}/files/${encodeURIComponent(groupFileId)}`);
|
|
}
|