@openmarket/rooms-client 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/sidebar-model.d.ts +28 -1
- package/dist/agent/sidebar-model.js +63 -1
- package/dist/client.d.ts +3 -0
- package/dist/client.js +25 -16
- package/dist/shared/rooms-protocol.d.ts +10 -0
- package/dist/social-client.d.ts +26 -2
- package/dist/social-client.js +37 -0
- package/dist/social-types.d.ts +38 -0
- package/package.json +1 -1
- package/src/agent/sidebar-model.ts +94 -2
- package/src/client.ts +36 -21
- package/src/shared/rooms-protocol.ts +10 -0
- package/src/social-client.ts +58 -0
- package/src/social-types.ts +42 -0
|
@@ -22,6 +22,9 @@ export interface SidebarRoom {
|
|
|
22
22
|
headSeq: number | null;
|
|
23
23
|
lastReadSeq: number | null;
|
|
24
24
|
unread: number;
|
|
25
|
+
/** Unread mention count from the latest read-state snapshot: the server's
|
|
26
|
+
* number verbatim, 0 when the snapshot carried none for this room. */
|
|
27
|
+
mentionUnread: number;
|
|
25
28
|
joined: boolean;
|
|
26
29
|
/** Topics dial from room meta. Absent reads as "allowed" since the
|
|
27
30
|
* 2026-07-12 dial-default flip; explicit "off" keeps topics inert. */
|
|
@@ -40,6 +43,13 @@ export interface SidebarSnapshot {
|
|
|
40
43
|
dms: SidebarDm[];
|
|
41
44
|
popular: SidebarRoom[];
|
|
42
45
|
globalReplyUnread: number;
|
|
46
|
+
/** Per-room unread mention counts exactly as the server sent them in the
|
|
47
|
+
* latest read-state snapshot: sparse (zero-count rooms absent) and
|
|
48
|
+
* global (rooms outside the current listings may appear). Empty when the
|
|
49
|
+
* snapshot carried none; a fresh snapshot replaces it wholesale. */
|
|
50
|
+
mentionCounts: Record<string, number>;
|
|
51
|
+
/** User-wide unread mention total from the same snapshot. */
|
|
52
|
+
mentionTotal: number;
|
|
43
53
|
fetchedAt: number;
|
|
44
54
|
}
|
|
45
55
|
export declare const EMPTY_SIDEBAR_SNAPSHOT: SidebarSnapshot;
|
|
@@ -48,10 +58,22 @@ export declare function computeRoomUnread(input: {
|
|
|
48
58
|
headSeq: number | null;
|
|
49
59
|
lastReadSeq: number | null;
|
|
50
60
|
}): number;
|
|
61
|
+
/** A READ_STATE reply that carries the additive mention decorations beside
|
|
62
|
+
* the cursor map (RoomReadStatePayload satisfies this shape). Old
|
|
63
|
+
* fetchReadState implementations returning the bare cursor Record keep
|
|
64
|
+
* working; mention fields then read as zero. */
|
|
65
|
+
export interface SidebarReadState {
|
|
66
|
+
readState: Record<string, number>;
|
|
67
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
68
|
+
mentionTotal?: number | undefined;
|
|
69
|
+
}
|
|
51
70
|
export declare function buildSidebarSnapshot(input: {
|
|
52
71
|
rooms: RoomMeta[];
|
|
53
72
|
spaces: RoomSpace[];
|
|
54
73
|
readState: Record<string, number>;
|
|
74
|
+
/** Server numbers pass through verbatim; merge policy is the caller's. */
|
|
75
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
76
|
+
mentionTotal?: number | undefined;
|
|
55
77
|
dms: SidebarDm[];
|
|
56
78
|
globalReplyUnread: number;
|
|
57
79
|
joinedUnread: (roomName: string) => number | null;
|
|
@@ -60,7 +82,9 @@ export declare function buildSidebarSnapshot(input: {
|
|
|
60
82
|
export interface SidebarModelDeps {
|
|
61
83
|
fetchRooms: () => Promise<RoomMeta[]>;
|
|
62
84
|
fetchSpaces: () => Promise<RoomSpace[]>;
|
|
63
|
-
|
|
85
|
+
/** Either the bare cursor Record (the 0.2.0 shape) or a SidebarReadState
|
|
86
|
+
* whose mention decorations feed the snapshot's mention fields. */
|
|
87
|
+
fetchReadState: (roomNames: string[]) => Promise<Record<string, number> | SidebarReadState>;
|
|
64
88
|
fetchDms: () => Promise<SidebarDm[]>;
|
|
65
89
|
fetchGlobalReplyUnread: () => Promise<number>;
|
|
66
90
|
joinedUnread: (roomName: string) => number | null;
|
|
@@ -74,6 +98,9 @@ export declare class SidebarModel {
|
|
|
74
98
|
constructor(deps: SidebarModelDeps);
|
|
75
99
|
snapshot(): SidebarSnapshot;
|
|
76
100
|
onUpdate(listener: () => void): () => void;
|
|
101
|
+
/** Apply a same-user realtime DM counter without refetching the full room
|
|
102
|
+
* directory. Username is the stable DM key used by the sidebar model. */
|
|
103
|
+
applyDmUnread(username: string, unread: number): void;
|
|
77
104
|
/**
|
|
78
105
|
* Post-mutation refresh: if a poll-triggered load is already in flight it
|
|
79
106
|
* may predate the caller's writes, so wait it out and load AGAIN. Readers
|
|
@@ -4,6 +4,8 @@ export const EMPTY_SIDEBAR_SNAPSHOT = {
|
|
|
4
4
|
dms: [],
|
|
5
5
|
popular: [],
|
|
6
6
|
globalReplyUnread: 0,
|
|
7
|
+
mentionCounts: {},
|
|
8
|
+
mentionTotal: 0,
|
|
7
9
|
fetchedAt: 0,
|
|
8
10
|
};
|
|
9
11
|
const POPULAR_MAX = 6;
|
|
@@ -34,6 +36,11 @@ export function buildSidebarSnapshot(input) {
|
|
|
34
36
|
headSeq,
|
|
35
37
|
lastReadSeq,
|
|
36
38
|
unread: computeRoomUnread({ joinedUnread, headSeq, lastReadSeq }),
|
|
39
|
+
// Own-key guard like the readState read above: rooms named after
|
|
40
|
+
// Object.prototype members must read 0 from a sparse map, not NaN.
|
|
41
|
+
mentionUnread: Math.max(0, input.mentionCounts && Object.hasOwn(input.mentionCounts, name)
|
|
42
|
+
? (input.mentionCounts[name] ?? 0)
|
|
43
|
+
: 0),
|
|
37
44
|
// Joined = an open context OR a server read cursor (you only get a
|
|
38
45
|
// cursor by having been in the room). This is what decides whether a
|
|
39
46
|
// room lives in your channel tree or in the POPULAR discovery shelf.
|
|
@@ -92,9 +99,30 @@ export function buildSidebarSnapshot(input) {
|
|
|
92
99
|
dms: input.dms,
|
|
93
100
|
popular,
|
|
94
101
|
globalReplyUnread: Math.max(0, input.globalReplyUnread),
|
|
102
|
+
mentionCounts: input.mentionCounts ?? {},
|
|
103
|
+
mentionTotal: Math.max(0, input.mentionTotal ?? 0),
|
|
95
104
|
fetchedAt: input.now,
|
|
96
105
|
};
|
|
97
106
|
}
|
|
107
|
+
/** A bare cursor Record maps room name to seq (numbers only), so an object
|
|
108
|
+
* under the "readState" key can only be the snapshot shape, even for a
|
|
109
|
+
* room literally named "readState" (its cursor would be a number). */
|
|
110
|
+
function normalizeSidebarReadState(reply) {
|
|
111
|
+
const nested = reply.readState;
|
|
112
|
+
if (typeof nested === "object" && nested !== null) {
|
|
113
|
+
const snapshot = reply;
|
|
114
|
+
return {
|
|
115
|
+
readState: snapshot.readState,
|
|
116
|
+
mentionCounts: snapshot.mentionCounts,
|
|
117
|
+
mentionTotal: snapshot.mentionTotal,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
readState: reply,
|
|
122
|
+
mentionCounts: undefined,
|
|
123
|
+
mentionTotal: undefined,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
98
126
|
export class SidebarModel {
|
|
99
127
|
deps;
|
|
100
128
|
snapshotValue = EMPTY_SIDEBAR_SNAPSHOT;
|
|
@@ -112,6 +140,34 @@ export class SidebarModel {
|
|
|
112
140
|
this.listeners.delete(listener);
|
|
113
141
|
};
|
|
114
142
|
}
|
|
143
|
+
/** Apply a same-user realtime DM counter without refetching the full room
|
|
144
|
+
* directory. Username is the stable DM key used by the sidebar model. */
|
|
145
|
+
applyDmUnread(username, unread) {
|
|
146
|
+
const key = normalizeDmUsername(username);
|
|
147
|
+
if (!key)
|
|
148
|
+
return;
|
|
149
|
+
const normalizedUnread = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0;
|
|
150
|
+
let found = false;
|
|
151
|
+
let changed = false;
|
|
152
|
+
const next = this.snapshotValue.dms.map((dm) => {
|
|
153
|
+
if (normalizeDmUsername(dm.username) !== key)
|
|
154
|
+
return dm;
|
|
155
|
+
found = true;
|
|
156
|
+
if (dm.unread === normalizedUnread)
|
|
157
|
+
return dm;
|
|
158
|
+
changed = true;
|
|
159
|
+
return { ...dm, unread: normalizedUnread };
|
|
160
|
+
});
|
|
161
|
+
if (!found && normalizedUnread > 0) {
|
|
162
|
+
next.push({ username: username.replace(/^@+/, ""), unread: normalizedUnread });
|
|
163
|
+
changed = true;
|
|
164
|
+
}
|
|
165
|
+
if (!changed)
|
|
166
|
+
return;
|
|
167
|
+
this.snapshotValue = { ...this.snapshotValue, dms: next };
|
|
168
|
+
for (const listener of this.listeners)
|
|
169
|
+
listener();
|
|
170
|
+
}
|
|
115
171
|
/**
|
|
116
172
|
* Post-mutation refresh: if a poll-triggered load is already in flight it
|
|
117
173
|
* may predate the caller's writes, so wait it out and load AGAIN. Readers
|
|
@@ -146,13 +202,16 @@ export class SidebarModel {
|
|
|
146
202
|
this.deps.fetchDms().catch(() => []),
|
|
147
203
|
this.deps.fetchGlobalReplyUnread().catch(() => 0),
|
|
148
204
|
]);
|
|
149
|
-
const
|
|
205
|
+
const readStateReply = await this.deps
|
|
150
206
|
.fetchReadState(rooms.map((room) => room.name))
|
|
151
207
|
.catch(() => ({}));
|
|
208
|
+
const { readState, mentionCounts, mentionTotal } = normalizeSidebarReadState(readStateReply);
|
|
152
209
|
return buildSidebarSnapshot({
|
|
153
210
|
rooms,
|
|
154
211
|
spaces,
|
|
155
212
|
readState,
|
|
213
|
+
mentionCounts,
|
|
214
|
+
mentionTotal,
|
|
156
215
|
dms,
|
|
157
216
|
globalReplyUnread,
|
|
158
217
|
joinedUnread: this.deps.joinedUnread,
|
|
@@ -160,3 +219,6 @@ export class SidebarModel {
|
|
|
160
219
|
});
|
|
161
220
|
}
|
|
162
221
|
}
|
|
222
|
+
function normalizeDmUsername(username) {
|
|
223
|
+
return username.trim().replace(/^@+/, "").toLowerCase();
|
|
224
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -242,6 +242,9 @@ export { workspaceRoomName } from "./names.js";
|
|
|
242
242
|
export declare function listRooms(config: RoomClientConfig, request?: RoomListRequest): Promise<RoomListResult>;
|
|
243
243
|
export declare function createRoom(config: RoomClientConfig, request: RoomCreateRequest): Promise<RoomCreateResult>;
|
|
244
244
|
export declare function inviteRoom(config: RoomClientConfig, request: RoomInviteRequest): Promise<RoomInviteResult>;
|
|
245
|
+
/** Invite over an already-authenticated socket. Long-lived GUI/TUI sessions
|
|
246
|
+
* use this path so a one-shot invite cannot create a second connection. */
|
|
247
|
+
export declare function inviteRoomOnClient(client: RoomWsClient, request: RoomInviteRequest, timeoutMs?: number): Promise<RoomInvitedPayload>;
|
|
245
248
|
export declare function removeRoomMember(config: RoomClientConfig, request: RoomRemoveMemberRequest): Promise<RoomRemoveMemberResult>;
|
|
246
249
|
export declare function reportRoomTarget(config: RoomClientConfig, request: RoomReportRequest): Promise<RoomReportResult>;
|
|
247
250
|
export declare function muteRoomMember(config: RoomClientConfig, request: RoomMuteRequest): Promise<RoomMuteResult>;
|
package/dist/client.js
CHANGED
|
@@ -101,9 +101,7 @@ async function createRoomViaWebSocket(config, request) {
|
|
|
101
101
|
export async function inviteRoom(config, request) {
|
|
102
102
|
// `@user` and `user` are the same person: strip the typing syntax once,
|
|
103
103
|
// before either transport (the relay resolves the bare name).
|
|
104
|
-
const normalized = request
|
|
105
|
-
? { ...request, username: request.username.trim().replace(/^@+/, "") }
|
|
106
|
-
: request;
|
|
104
|
+
const normalized = normalizeRoomInviteRequest(request);
|
|
107
105
|
if (!config.webSocketCtor) {
|
|
108
106
|
try {
|
|
109
107
|
return await inviteRoomViaHttp(config, normalized);
|
|
@@ -119,24 +117,35 @@ async function inviteRoomViaWebSocket(config, request) {
|
|
|
119
117
|
const client = new RoomWsClient(config);
|
|
120
118
|
try {
|
|
121
119
|
const auth = await client.connect();
|
|
122
|
-
|
|
123
|
-
client.send({
|
|
124
|
-
type: RoomClientMessageType.INVITE,
|
|
125
|
-
payload: {
|
|
126
|
-
requestId,
|
|
127
|
-
room: request.room,
|
|
128
|
-
...(request.username ? { username: request.username } : {}),
|
|
129
|
-
...(request.userId ? { userId: request.userId } : {}),
|
|
130
|
-
},
|
|
131
|
-
timestamp: Date.now(),
|
|
132
|
-
});
|
|
133
|
-
const invited = await client.waitFor(RoomServerMessageType.ROOM_INVITED, (msg) => msg.payload.requestId === requestId, undefined, requestId);
|
|
134
|
-
return { auth, invite: invited.payload };
|
|
120
|
+
return { auth, invite: await inviteRoomOnClient(client, request) };
|
|
135
121
|
}
|
|
136
122
|
finally {
|
|
137
123
|
client.close();
|
|
138
124
|
}
|
|
139
125
|
}
|
|
126
|
+
/** Invite over an already-authenticated socket. Long-lived GUI/TUI sessions
|
|
127
|
+
* use this path so a one-shot invite cannot create a second connection. */
|
|
128
|
+
export async function inviteRoomOnClient(client, request, timeoutMs) {
|
|
129
|
+
const normalized = normalizeRoomInviteRequest(request);
|
|
130
|
+
const requestId = randomUUID();
|
|
131
|
+
client.send({
|
|
132
|
+
type: RoomClientMessageType.INVITE,
|
|
133
|
+
payload: {
|
|
134
|
+
requestId,
|
|
135
|
+
room: normalized.room,
|
|
136
|
+
...(normalized.username ? { username: normalized.username } : {}),
|
|
137
|
+
...(normalized.userId ? { userId: normalized.userId } : {}),
|
|
138
|
+
},
|
|
139
|
+
timestamp: Date.now(),
|
|
140
|
+
});
|
|
141
|
+
const invited = await client.waitFor(RoomServerMessageType.ROOM_INVITED, (msg) => msg.payload.requestId === requestId, timeoutMs, requestId);
|
|
142
|
+
return invited.payload;
|
|
143
|
+
}
|
|
144
|
+
function normalizeRoomInviteRequest(request) {
|
|
145
|
+
return request.username
|
|
146
|
+
? { ...request, username: request.username.trim().replace(/^@+/, "") }
|
|
147
|
+
: request;
|
|
148
|
+
}
|
|
140
149
|
export async function removeRoomMember(config, request) {
|
|
141
150
|
if (!config.webSocketCtor) {
|
|
142
151
|
try {
|
|
@@ -330,6 +330,16 @@ export interface RoomReadStatePayload {
|
|
|
330
330
|
* Present when the store surfaces it and on ROOM_SET_ATTENTION acks
|
|
331
331
|
* (which reply READ_STATE scoped to the one room, readState empty). */
|
|
332
332
|
attention?: Record<string, RoomTopicAttention> | undefined;
|
|
333
|
+
/** Unread mention counts by room, server-computed from the notify
|
|
334
|
+
* worker's ROOM_MENTION rows so badges survive reload and clear through
|
|
335
|
+
* the ack path. Unlike readState/attention the map is SPARSE AND
|
|
336
|
+
* GLOBAL: every room with a nonzero count appears (requested or not),
|
|
337
|
+
* zero-count rooms are absent. Missing on servers older than the
|
|
338
|
+
* mentions-completion store. */
|
|
339
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
340
|
+
/** User-wide unread mention total (drives the app badge). Rides beside
|
|
341
|
+
* mentionCounts; missing on older servers. */
|
|
342
|
+
mentionTotal?: number | undefined;
|
|
333
343
|
requestId?: string | undefined;
|
|
334
344
|
}
|
|
335
345
|
export interface RoomSearchResultsPayload {
|
package/dist/social-client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { RoomDmConversation, RoomDmHistory, RoomDmSendResult, RoomDmUnreadConversation, RoomDoc, RoomDocAttribution, RoomDocChanges, RoomDocConflictHead, RoomDocEditingLease, RoomDocProvenance, RoomDocRevision, RoomDocRunRevertResult, RoomDocWriteResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequests, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomSocialConfig, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSuggestion, RoomSuggestionRequest } from "./social-types.js";
|
|
2
|
-
export type { RoomAttachment, RoomDmConversation, RoomDmHistory, RoomDmMessage, RoomDmParticipant, RoomDmSendResult, RoomDmUnreadConversation, RoomDocAttribution, RoomDocEditingLease, RoomDocMergeRegion, RoomDocRevisionSource, RoomDocRunRevertResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequest, RoomFriendRequests, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomReplyInboxItem, RoomSocialConfig, RoomSpaceRestoreAction, RoomSpaceRestoreActionKind, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSpaceRestoreSummary, RoomSuggestion, RoomSuggestionIntent, RoomSuggestionRequest, RoomSuggestionSource, } from "./social-types.js";
|
|
1
|
+
import type { RoomDmConversation, RoomDmHistory, RoomDmSendResult, RoomDmUnreadConversation, RoomDoc, RoomDocAttribution, RoomDocChanges, RoomDocConflictHead, RoomDocEditingLease, RoomDocProvenance, RoomDocRevision, RoomDocRunRevertResult, RoomDocWriteResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequests, RoomNotificationInboxResult, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomSocialConfig, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSuggestion, RoomSuggestionRequest } from "./social-types.js";
|
|
2
|
+
export type { RoomAttachment, RoomDmConversation, RoomDmHistory, RoomDmMessage, RoomDmParticipant, RoomDmSendResult, RoomDmUnreadConversation, RoomDocAttribution, RoomDocEditingLease, RoomDocMergeRegion, RoomDocRevisionSource, RoomDocRunRevertResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequest, RoomFriendRequests, RoomNotificationInboxItem, RoomNotificationInboxResult, RoomNotificationKind, RoomNotificationMessageData, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomReplyInboxItem, RoomSocialConfig, RoomSpaceRestoreAction, RoomSpaceRestoreActionKind, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSpaceRestoreSummary, RoomSuggestion, RoomSuggestionIntent, RoomSuggestionRequest, RoomSuggestionSource, } from "./social-types.js";
|
|
3
3
|
/** Largest per-file size any tier accepts (plus tier). The server enforces
|
|
4
4
|
* the per-tier caps (free 10MB/file, plus 25MB/file) and daily quotas; this
|
|
5
5
|
* constant only pre-flights uploads no tier could ever accept. */
|
|
@@ -330,6 +330,12 @@ export declare function listRoomChannelMembers(config: RoomSocialConfig, roomId:
|
|
|
330
330
|
export declare function removeSpaceMember(config: RoomSocialConfig, spaceId: string, targetUserId: string): Promise<{
|
|
331
331
|
removed?: boolean;
|
|
332
332
|
}>;
|
|
333
|
+
/** Leave a server as the authenticated caller. This is deliberately separate
|
|
334
|
+
* from the manager-only member-removal route so clients never send their own
|
|
335
|
+
* userId as authorization data. */
|
|
336
|
+
export declare function leaveSpace(config: RoomSocialConfig, spaceId: string): Promise<{
|
|
337
|
+
left?: boolean;
|
|
338
|
+
}>;
|
|
333
339
|
export declare function changeSpaceMemberRole(config: RoomSocialConfig, spaceId: string, targetUserId: string, role: "admin" | "librarian" | "member"): Promise<RoomSpaceMember>;
|
|
334
340
|
/** Set (string) or clear (null) a member's per-space nickname; the store validates and enforces uniqueness. */
|
|
335
341
|
export declare function setSpaceMemberNickname(config: RoomSocialConfig, spaceId: string, targetUserId: string, nickname: string | null): Promise<RoomSpaceMember>;
|
|
@@ -362,6 +368,24 @@ export declare function markRoomRepliesRead(config: RoomSocialConfig, options?:
|
|
|
362
368
|
room?: string;
|
|
363
369
|
before?: string | Date;
|
|
364
370
|
}): Promise<RoomRepliesReadResult>;
|
|
371
|
+
/**
|
|
372
|
+
* The chat-scoped notification inbox (GET /users/notifications): the durable
|
|
373
|
+
* rows behind mention/reply/dm/keyword pushes, newest first, room kinds only.
|
|
374
|
+
* Voucher-reachable like the rest of the chat surface (the bearer token may
|
|
375
|
+
* be an API key or a chat voucher). Pagination is the composite cursor: pass
|
|
376
|
+
* the previous page's lastCursor and lastCursorId back TOGETHER (the store
|
|
377
|
+
* rejects one without the other); unread=true filters to unread rows.
|
|
378
|
+
*/
|
|
379
|
+
export declare function listRoomNotifications(config: RoomSocialConfig, options?: {
|
|
380
|
+
limit?: number | undefined;
|
|
381
|
+
/** Feed a page's lastCursor back here (explicit undefined reads as
|
|
382
|
+
* "first page", so `page.lastCursor ?? undefined` composes under
|
|
383
|
+
* exactOptionalPropertyTypes). */
|
|
384
|
+
cursor?: string | undefined;
|
|
385
|
+
cursorId?: string | undefined;
|
|
386
|
+
unread?: boolean | undefined;
|
|
387
|
+
signal?: AbortSignal | undefined;
|
|
388
|
+
}): Promise<RoomNotificationInboxResult>;
|
|
365
389
|
export declare function suggestRooms(config: RoomSocialConfig, request: RoomSuggestionRequest, options?: {
|
|
366
390
|
signal?: AbortSignal;
|
|
367
391
|
}): Promise<RoomSuggestion[]>;
|
package/dist/social-client.js
CHANGED
|
@@ -500,6 +500,14 @@ export async function listRoomChannelMembers(config, roomId, options = {}) {
|
|
|
500
500
|
export async function removeSpaceMember(config, spaceId, targetUserId) {
|
|
501
501
|
return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}`, { method: "DELETE" });
|
|
502
502
|
}
|
|
503
|
+
/** Leave a server as the authenticated caller. This is deliberately separate
|
|
504
|
+
* from the manager-only member-removal route so clients never send their own
|
|
505
|
+
* userId as authorization data. */
|
|
506
|
+
export async function leaveSpace(config, spaceId) {
|
|
507
|
+
return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/membership`, {
|
|
508
|
+
method: "DELETE",
|
|
509
|
+
});
|
|
510
|
+
}
|
|
503
511
|
export async function changeSpaceMemberRole(config, spaceId, targetUserId, role) {
|
|
504
512
|
const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/role`, { method: "PATCH", body: { role } });
|
|
505
513
|
return result.member;
|
|
@@ -584,6 +592,35 @@ export async function markRoomRepliesRead(config, options = {}) {
|
|
|
584
592
|
});
|
|
585
593
|
return { markedRead: result.markedRead ?? 0 };
|
|
586
594
|
}
|
|
595
|
+
/**
|
|
596
|
+
* The chat-scoped notification inbox (GET /users/notifications): the durable
|
|
597
|
+
* rows behind mention/reply/dm/keyword pushes, newest first, room kinds only.
|
|
598
|
+
* Voucher-reachable like the rest of the chat surface (the bearer token may
|
|
599
|
+
* be an API key or a chat voucher). Pagination is the composite cursor: pass
|
|
600
|
+
* the previous page's lastCursor and lastCursorId back TOGETHER (the store
|
|
601
|
+
* rejects one without the other); unread=true filters to unread rows.
|
|
602
|
+
*/
|
|
603
|
+
export async function listRoomNotifications(config, options = {}) {
|
|
604
|
+
const params = new URLSearchParams();
|
|
605
|
+
if (options.limit !== undefined)
|
|
606
|
+
params.set("limit", String(options.limit));
|
|
607
|
+
if (options.cursor !== undefined)
|
|
608
|
+
params.set("cursor", options.cursor);
|
|
609
|
+
if (options.cursorId !== undefined)
|
|
610
|
+
params.set("cursorId", options.cursorId);
|
|
611
|
+
if (options.unread !== undefined)
|
|
612
|
+
params.set("unread", String(options.unread));
|
|
613
|
+
const init = {};
|
|
614
|
+
if (options.signal)
|
|
615
|
+
init.signal = options.signal;
|
|
616
|
+
const result = await socialRequest(config, `/users/notifications${params.size ? `?${params.toString()}` : ""}`, init);
|
|
617
|
+
return {
|
|
618
|
+
notifications: result.notifications ?? [],
|
|
619
|
+
hasMore: result.hasMore ?? false,
|
|
620
|
+
lastCursor: result.lastCursor ?? null,
|
|
621
|
+
lastCursorId: result.lastCursorId ?? null,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
587
624
|
export async function suggestRooms(config, request, options = {}) {
|
|
588
625
|
const init = {
|
|
589
626
|
method: "POST",
|
package/dist/social-types.d.ts
CHANGED
|
@@ -171,6 +171,44 @@ export interface RoomRepliesResult {
|
|
|
171
171
|
export interface RoomRepliesReadResult {
|
|
172
172
|
markedRead: number;
|
|
173
173
|
}
|
|
174
|
+
/** The room kinds the chat notification inbox returns (the store's
|
|
175
|
+
* ROOM_INBOX_NOTIFICATION_TYPES). ROOM_FOLLOW rows stay out of the inbox:
|
|
176
|
+
* follow is a push channel, not a badge source. */
|
|
177
|
+
export type RoomNotificationKind = "ROOM_MENTION" | "ROOM_REPLY" | "ROOM_DM" | "ROOM_KEYWORD";
|
|
178
|
+
/** Shared room payload on a notification row, wire names verbatim from the
|
|
179
|
+
* store's notification schema (room_message_data). `preview` follows the
|
|
180
|
+
* push body rules (140 chars, empty for sealed content); `hash` is the
|
|
181
|
+
* client deep link. */
|
|
182
|
+
export interface RoomNotificationMessageData {
|
|
183
|
+
kind: "dm" | "mention" | "reply" | "follow" | "keyword";
|
|
184
|
+
room_id?: string;
|
|
185
|
+
room_name?: string;
|
|
186
|
+
space_id?: string;
|
|
187
|
+
conversation_id?: string;
|
|
188
|
+
seq?: number;
|
|
189
|
+
from_handle: string;
|
|
190
|
+
preview: string;
|
|
191
|
+
hash: string;
|
|
192
|
+
}
|
|
193
|
+
/** One chat notification inbox row: the store's explicit field pick
|
|
194
|
+
* (snake_case wire names, dates as ISO strings, room_message_data only
|
|
195
|
+
* when the row carries one). */
|
|
196
|
+
export interface RoomNotificationInboxItem {
|
|
197
|
+
id: string;
|
|
198
|
+
type: RoomNotificationKind;
|
|
199
|
+
created_at: string;
|
|
200
|
+
read_at: string | null;
|
|
201
|
+
room_message_data?: RoomNotificationMessageData;
|
|
202
|
+
}
|
|
203
|
+
/** A newest-first inbox page plus the composite cursor naming the last row
|
|
204
|
+
* returned; feed lastCursor and lastCursorId back together to request the
|
|
205
|
+
* next page. Both are null on an empty page. */
|
|
206
|
+
export interface RoomNotificationInboxResult {
|
|
207
|
+
notifications: RoomNotificationInboxItem[];
|
|
208
|
+
hasMore: boolean;
|
|
209
|
+
lastCursor: string | null;
|
|
210
|
+
lastCursorId: string | null;
|
|
211
|
+
}
|
|
174
212
|
/** A living markdown doc scoped to a space (head state; content on demand). */
|
|
175
213
|
export interface RoomDoc {
|
|
176
214
|
docId: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmarket/rooms-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "OM Rooms protocol client: wire types, WebSocket + REST clients, and chat view-models. Browser-safe (no node:/bun: imports).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -23,6 +23,9 @@ export interface SidebarRoom {
|
|
|
23
23
|
headSeq: number | null;
|
|
24
24
|
lastReadSeq: number | null;
|
|
25
25
|
unread: number;
|
|
26
|
+
/** Unread mention count from the latest read-state snapshot: the server's
|
|
27
|
+
* number verbatim, 0 when the snapshot carried none for this room. */
|
|
28
|
+
mentionUnread: number;
|
|
26
29
|
joined: boolean;
|
|
27
30
|
/** Topics dial from room meta. Absent reads as "allowed" since the
|
|
28
31
|
* 2026-07-12 dial-default flip; explicit "off" keeps topics inert. */
|
|
@@ -40,6 +43,13 @@ export interface SidebarSnapshot {
|
|
|
40
43
|
dms: SidebarDm[];
|
|
41
44
|
popular: SidebarRoom[];
|
|
42
45
|
globalReplyUnread: number;
|
|
46
|
+
/** Per-room unread mention counts exactly as the server sent them in the
|
|
47
|
+
* latest read-state snapshot: sparse (zero-count rooms absent) and
|
|
48
|
+
* global (rooms outside the current listings may appear). Empty when the
|
|
49
|
+
* snapshot carried none; a fresh snapshot replaces it wholesale. */
|
|
50
|
+
mentionCounts: Record<string, number>;
|
|
51
|
+
/** User-wide unread mention total from the same snapshot. */
|
|
52
|
+
mentionTotal: number;
|
|
43
53
|
fetchedAt: number;
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -49,6 +59,8 @@ export const EMPTY_SIDEBAR_SNAPSHOT: SidebarSnapshot = {
|
|
|
49
59
|
dms: [],
|
|
50
60
|
popular: [],
|
|
51
61
|
globalReplyUnread: 0,
|
|
62
|
+
mentionCounts: {},
|
|
63
|
+
mentionTotal: 0,
|
|
52
64
|
fetchedAt: 0,
|
|
53
65
|
};
|
|
54
66
|
|
|
@@ -65,10 +77,23 @@ export function computeRoomUnread(input: {
|
|
|
65
77
|
return Math.max(0, input.headSeq - input.lastReadSeq);
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
/** A READ_STATE reply that carries the additive mention decorations beside
|
|
81
|
+
* the cursor map (RoomReadStatePayload satisfies this shape). Old
|
|
82
|
+
* fetchReadState implementations returning the bare cursor Record keep
|
|
83
|
+
* working; mention fields then read as zero. */
|
|
84
|
+
export interface SidebarReadState {
|
|
85
|
+
readState: Record<string, number>;
|
|
86
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
87
|
+
mentionTotal?: number | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
export function buildSidebarSnapshot(input: {
|
|
69
91
|
rooms: RoomMeta[];
|
|
70
92
|
spaces: RoomSpace[];
|
|
71
93
|
readState: Record<string, number>;
|
|
94
|
+
/** Server numbers pass through verbatim; merge policy is the caller's. */
|
|
95
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
96
|
+
mentionTotal?: number | undefined;
|
|
72
97
|
dms: SidebarDm[];
|
|
73
98
|
globalReplyUnread: number;
|
|
74
99
|
joinedUnread: (roomName: string) => number | null;
|
|
@@ -91,6 +116,14 @@ export function buildSidebarSnapshot(input: {
|
|
|
91
116
|
headSeq,
|
|
92
117
|
lastReadSeq,
|
|
93
118
|
unread: computeRoomUnread({ joinedUnread, headSeq, lastReadSeq }),
|
|
119
|
+
// Own-key guard like the readState read above: rooms named after
|
|
120
|
+
// Object.prototype members must read 0 from a sparse map, not NaN.
|
|
121
|
+
mentionUnread: Math.max(
|
|
122
|
+
0,
|
|
123
|
+
input.mentionCounts && Object.hasOwn(input.mentionCounts, name)
|
|
124
|
+
? (input.mentionCounts[name] ?? 0)
|
|
125
|
+
: 0,
|
|
126
|
+
),
|
|
94
127
|
// Joined = an open context OR a server read cursor (you only get a
|
|
95
128
|
// cursor by having been in the room). This is what decides whether a
|
|
96
129
|
// room lives in your channel tree or in the POPULAR discovery shelf.
|
|
@@ -154,6 +187,8 @@ export function buildSidebarSnapshot(input: {
|
|
|
154
187
|
dms: input.dms,
|
|
155
188
|
popular,
|
|
156
189
|
globalReplyUnread: Math.max(0, input.globalReplyUnread),
|
|
190
|
+
mentionCounts: input.mentionCounts ?? {},
|
|
191
|
+
mentionTotal: Math.max(0, input.mentionTotal ?? 0),
|
|
157
192
|
fetchedAt: input.now,
|
|
158
193
|
};
|
|
159
194
|
}
|
|
@@ -161,13 +196,39 @@ export function buildSidebarSnapshot(input: {
|
|
|
161
196
|
export interface SidebarModelDeps {
|
|
162
197
|
fetchRooms: () => Promise<RoomMeta[]>;
|
|
163
198
|
fetchSpaces: () => Promise<RoomSpace[]>;
|
|
164
|
-
|
|
199
|
+
/** Either the bare cursor Record (the 0.2.0 shape) or a SidebarReadState
|
|
200
|
+
* whose mention decorations feed the snapshot's mention fields. */
|
|
201
|
+
fetchReadState: (roomNames: string[]) => Promise<Record<string, number> | SidebarReadState>;
|
|
165
202
|
fetchDms: () => Promise<SidebarDm[]>;
|
|
166
203
|
fetchGlobalReplyUnread: () => Promise<number>;
|
|
167
204
|
joinedUnread: (roomName: string) => number | null;
|
|
168
205
|
now?: () => number;
|
|
169
206
|
}
|
|
170
207
|
|
|
208
|
+
/** A bare cursor Record maps room name to seq (numbers only), so an object
|
|
209
|
+
* under the "readState" key can only be the snapshot shape, even for a
|
|
210
|
+
* room literally named "readState" (its cursor would be a number). */
|
|
211
|
+
function normalizeSidebarReadState(reply: Record<string, number> | SidebarReadState): {
|
|
212
|
+
readState: Record<string, number>;
|
|
213
|
+
mentionCounts: Record<string, number> | undefined;
|
|
214
|
+
mentionTotal: number | undefined;
|
|
215
|
+
} {
|
|
216
|
+
const nested = (reply as SidebarReadState).readState;
|
|
217
|
+
if (typeof nested === "object" && nested !== null) {
|
|
218
|
+
const snapshot = reply as SidebarReadState;
|
|
219
|
+
return {
|
|
220
|
+
readState: snapshot.readState,
|
|
221
|
+
mentionCounts: snapshot.mentionCounts,
|
|
222
|
+
mentionTotal: snapshot.mentionTotal,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
readState: reply as Record<string, number>,
|
|
227
|
+
mentionCounts: undefined,
|
|
228
|
+
mentionTotal: undefined,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
171
232
|
export class SidebarModel {
|
|
172
233
|
private snapshotValue: SidebarSnapshot = EMPTY_SIDEBAR_SNAPSHOT;
|
|
173
234
|
private inflight: Promise<SidebarSnapshot> | null = null;
|
|
@@ -186,6 +247,30 @@ export class SidebarModel {
|
|
|
186
247
|
};
|
|
187
248
|
}
|
|
188
249
|
|
|
250
|
+
/** Apply a same-user realtime DM counter without refetching the full room
|
|
251
|
+
* directory. Username is the stable DM key used by the sidebar model. */
|
|
252
|
+
applyDmUnread(username: string, unread: number): void {
|
|
253
|
+
const key = normalizeDmUsername(username);
|
|
254
|
+
if (!key) return;
|
|
255
|
+
const normalizedUnread = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0;
|
|
256
|
+
let found = false;
|
|
257
|
+
let changed = false;
|
|
258
|
+
const next = this.snapshotValue.dms.map((dm) => {
|
|
259
|
+
if (normalizeDmUsername(dm.username) !== key) return dm;
|
|
260
|
+
found = true;
|
|
261
|
+
if (dm.unread === normalizedUnread) return dm;
|
|
262
|
+
changed = true;
|
|
263
|
+
return { ...dm, unread: normalizedUnread };
|
|
264
|
+
});
|
|
265
|
+
if (!found && normalizedUnread > 0) {
|
|
266
|
+
next.push({ username: username.replace(/^@+/, ""), unread: normalizedUnread });
|
|
267
|
+
changed = true;
|
|
268
|
+
}
|
|
269
|
+
if (!changed) return;
|
|
270
|
+
this.snapshotValue = { ...this.snapshotValue, dms: next };
|
|
271
|
+
for (const listener of this.listeners) listener();
|
|
272
|
+
}
|
|
273
|
+
|
|
189
274
|
/**
|
|
190
275
|
* Post-mutation refresh: if a poll-triggered load is already in flight it
|
|
191
276
|
* may predate the caller's writes, so wait it out and load AGAIN. Readers
|
|
@@ -219,13 +304,16 @@ export class SidebarModel {
|
|
|
219
304
|
this.deps.fetchDms().catch(() => [] as SidebarDm[]),
|
|
220
305
|
this.deps.fetchGlobalReplyUnread().catch(() => 0),
|
|
221
306
|
]);
|
|
222
|
-
const
|
|
307
|
+
const readStateReply = await this.deps
|
|
223
308
|
.fetchReadState(rooms.map((room) => room.name))
|
|
224
309
|
.catch(() => ({}) as Record<string, number>);
|
|
310
|
+
const { readState, mentionCounts, mentionTotal } = normalizeSidebarReadState(readStateReply);
|
|
225
311
|
return buildSidebarSnapshot({
|
|
226
312
|
rooms,
|
|
227
313
|
spaces,
|
|
228
314
|
readState,
|
|
315
|
+
mentionCounts,
|
|
316
|
+
mentionTotal,
|
|
229
317
|
dms,
|
|
230
318
|
globalReplyUnread,
|
|
231
319
|
joinedUnread: this.deps.joinedUnread,
|
|
@@ -233,3 +321,7 @@ export class SidebarModel {
|
|
|
233
321
|
});
|
|
234
322
|
}
|
|
235
323
|
}
|
|
324
|
+
|
|
325
|
+
function normalizeDmUsername(username: string): string {
|
|
326
|
+
return username.trim().replace(/^@+/, "").toLowerCase();
|
|
327
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -443,9 +443,7 @@ export async function inviteRoom(
|
|
|
443
443
|
): Promise<RoomInviteResult> {
|
|
444
444
|
// `@user` and `user` are the same person: strip the typing syntax once,
|
|
445
445
|
// before either transport (the relay resolves the bare name).
|
|
446
|
-
const normalized
|
|
447
|
-
? { ...request, username: request.username.trim().replace(/^@+/, "") }
|
|
448
|
-
: request;
|
|
446
|
+
const normalized = normalizeRoomInviteRequest(request);
|
|
449
447
|
if (!config.webSocketCtor) {
|
|
450
448
|
try {
|
|
451
449
|
return await inviteRoomViaHttp(config, normalized);
|
|
@@ -463,29 +461,46 @@ async function inviteRoomViaWebSocket(
|
|
|
463
461
|
const client = new RoomWsClient(config);
|
|
464
462
|
try {
|
|
465
463
|
const auth = await client.connect();
|
|
466
|
-
|
|
467
|
-
client.send({
|
|
468
|
-
type: RoomClientMessageType.INVITE,
|
|
469
|
-
payload: {
|
|
470
|
-
requestId,
|
|
471
|
-
room: request.room,
|
|
472
|
-
...(request.username ? { username: request.username } : {}),
|
|
473
|
-
...(request.userId ? { userId: request.userId } : {}),
|
|
474
|
-
},
|
|
475
|
-
timestamp: Date.now(),
|
|
476
|
-
});
|
|
477
|
-
const invited = await client.waitFor(
|
|
478
|
-
RoomServerMessageType.ROOM_INVITED,
|
|
479
|
-
(msg) => msg.payload.requestId === requestId,
|
|
480
|
-
undefined,
|
|
481
|
-
requestId,
|
|
482
|
-
);
|
|
483
|
-
return { auth, invite: invited.payload };
|
|
464
|
+
return { auth, invite: await inviteRoomOnClient(client, request) };
|
|
484
465
|
} finally {
|
|
485
466
|
client.close();
|
|
486
467
|
}
|
|
487
468
|
}
|
|
488
469
|
|
|
470
|
+
/** Invite over an already-authenticated socket. Long-lived GUI/TUI sessions
|
|
471
|
+
* use this path so a one-shot invite cannot create a second connection. */
|
|
472
|
+
export async function inviteRoomOnClient(
|
|
473
|
+
client: RoomWsClient,
|
|
474
|
+
request: RoomInviteRequest,
|
|
475
|
+
timeoutMs?: number,
|
|
476
|
+
): Promise<RoomInvitedPayload> {
|
|
477
|
+
const normalized = normalizeRoomInviteRequest(request);
|
|
478
|
+
const requestId = randomUUID();
|
|
479
|
+
client.send({
|
|
480
|
+
type: RoomClientMessageType.INVITE,
|
|
481
|
+
payload: {
|
|
482
|
+
requestId,
|
|
483
|
+
room: normalized.room,
|
|
484
|
+
...(normalized.username ? { username: normalized.username } : {}),
|
|
485
|
+
...(normalized.userId ? { userId: normalized.userId } : {}),
|
|
486
|
+
},
|
|
487
|
+
timestamp: Date.now(),
|
|
488
|
+
});
|
|
489
|
+
const invited = await client.waitFor(
|
|
490
|
+
RoomServerMessageType.ROOM_INVITED,
|
|
491
|
+
(msg) => msg.payload.requestId === requestId,
|
|
492
|
+
timeoutMs,
|
|
493
|
+
requestId,
|
|
494
|
+
);
|
|
495
|
+
return invited.payload;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function normalizeRoomInviteRequest(request: RoomInviteRequest): RoomInviteRequest {
|
|
499
|
+
return request.username
|
|
500
|
+
? { ...request, username: request.username.trim().replace(/^@+/, "") }
|
|
501
|
+
: request;
|
|
502
|
+
}
|
|
503
|
+
|
|
489
504
|
export async function removeRoomMember(
|
|
490
505
|
config: RoomClientConfig,
|
|
491
506
|
request: RoomRemoveMemberRequest,
|
|
@@ -370,6 +370,16 @@ export interface RoomReadStatePayload {
|
|
|
370
370
|
* Present when the store surfaces it and on ROOM_SET_ATTENTION acks
|
|
371
371
|
* (which reply READ_STATE scoped to the one room, readState empty). */
|
|
372
372
|
attention?: Record<string, RoomTopicAttention> | undefined;
|
|
373
|
+
/** Unread mention counts by room, server-computed from the notify
|
|
374
|
+
* worker's ROOM_MENTION rows so badges survive reload and clear through
|
|
375
|
+
* the ack path. Unlike readState/attention the map is SPARSE AND
|
|
376
|
+
* GLOBAL: every room with a nonzero count appears (requested or not),
|
|
377
|
+
* zero-count rooms are absent. Missing on servers older than the
|
|
378
|
+
* mentions-completion store. */
|
|
379
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
380
|
+
/** User-wide unread mention total (drives the app badge). Rides beside
|
|
381
|
+
* mentionCounts; missing on older servers. */
|
|
382
|
+
mentionTotal?: number | undefined;
|
|
373
383
|
requestId?: string | undefined;
|
|
374
384
|
}
|
|
375
385
|
|
package/src/social-client.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
RoomFollowResult,
|
|
19
19
|
RoomFriend,
|
|
20
20
|
RoomFriendRequests,
|
|
21
|
+
RoomNotificationInboxResult,
|
|
21
22
|
RoomPublicProfile,
|
|
22
23
|
RoomRepliesReadResult,
|
|
23
24
|
RoomRepliesResult,
|
|
@@ -46,6 +47,10 @@ export type {
|
|
|
46
47
|
RoomFriend,
|
|
47
48
|
RoomFriendRequest,
|
|
48
49
|
RoomFriendRequests,
|
|
50
|
+
RoomNotificationInboxItem,
|
|
51
|
+
RoomNotificationInboxResult,
|
|
52
|
+
RoomNotificationKind,
|
|
53
|
+
RoomNotificationMessageData,
|
|
49
54
|
RoomPublicProfile,
|
|
50
55
|
RoomRepliesReadResult,
|
|
51
56
|
RoomRepliesResult,
|
|
@@ -1044,6 +1049,18 @@ export async function removeSpaceMember(
|
|
|
1044
1049
|
);
|
|
1045
1050
|
}
|
|
1046
1051
|
|
|
1052
|
+
/** Leave a server as the authenticated caller. This is deliberately separate
|
|
1053
|
+
* from the manager-only member-removal route so clients never send their own
|
|
1054
|
+
* userId as authorization data. */
|
|
1055
|
+
export async function leaveSpace(
|
|
1056
|
+
config: RoomSocialConfig,
|
|
1057
|
+
spaceId: string,
|
|
1058
|
+
): Promise<{ left?: boolean }> {
|
|
1059
|
+
return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/membership`, {
|
|
1060
|
+
method: "DELETE",
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1047
1064
|
export async function changeSpaceMemberRole(
|
|
1048
1065
|
config: RoomSocialConfig,
|
|
1049
1066
|
spaceId: string,
|
|
@@ -1185,6 +1202,47 @@ export async function markRoomRepliesRead(
|
|
|
1185
1202
|
return { markedRead: result.markedRead ?? 0 };
|
|
1186
1203
|
}
|
|
1187
1204
|
|
|
1205
|
+
/**
|
|
1206
|
+
* The chat-scoped notification inbox (GET /users/notifications): the durable
|
|
1207
|
+
* rows behind mention/reply/dm/keyword pushes, newest first, room kinds only.
|
|
1208
|
+
* Voucher-reachable like the rest of the chat surface (the bearer token may
|
|
1209
|
+
* be an API key or a chat voucher). Pagination is the composite cursor: pass
|
|
1210
|
+
* the previous page's lastCursor and lastCursorId back TOGETHER (the store
|
|
1211
|
+
* rejects one without the other); unread=true filters to unread rows.
|
|
1212
|
+
*/
|
|
1213
|
+
export async function listRoomNotifications(
|
|
1214
|
+
config: RoomSocialConfig,
|
|
1215
|
+
options: {
|
|
1216
|
+
limit?: number | undefined;
|
|
1217
|
+
/** Feed a page's lastCursor back here (explicit undefined reads as
|
|
1218
|
+
* "first page", so `page.lastCursor ?? undefined` composes under
|
|
1219
|
+
* exactOptionalPropertyTypes). */
|
|
1220
|
+
cursor?: string | undefined;
|
|
1221
|
+
cursorId?: string | undefined;
|
|
1222
|
+
unread?: boolean | undefined;
|
|
1223
|
+
signal?: AbortSignal | undefined;
|
|
1224
|
+
} = {},
|
|
1225
|
+
): Promise<RoomNotificationInboxResult> {
|
|
1226
|
+
const params = new URLSearchParams();
|
|
1227
|
+
if (options.limit !== undefined) params.set("limit", String(options.limit));
|
|
1228
|
+
if (options.cursor !== undefined) params.set("cursor", options.cursor);
|
|
1229
|
+
if (options.cursorId !== undefined) params.set("cursorId", options.cursorId);
|
|
1230
|
+
if (options.unread !== undefined) params.set("unread", String(options.unread));
|
|
1231
|
+
const init: { signal?: AbortSignal } = {};
|
|
1232
|
+
if (options.signal) init.signal = options.signal;
|
|
1233
|
+
const result = await socialRequest<Partial<RoomNotificationInboxResult>>(
|
|
1234
|
+
config,
|
|
1235
|
+
`/users/notifications${params.size ? `?${params.toString()}` : ""}`,
|
|
1236
|
+
init,
|
|
1237
|
+
);
|
|
1238
|
+
return {
|
|
1239
|
+
notifications: result.notifications ?? [],
|
|
1240
|
+
hasMore: result.hasMore ?? false,
|
|
1241
|
+
lastCursor: result.lastCursor ?? null,
|
|
1242
|
+
lastCursorId: result.lastCursorId ?? null,
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1188
1246
|
export async function suggestRooms(
|
|
1189
1247
|
config: RoomSocialConfig,
|
|
1190
1248
|
request: RoomSuggestionRequest,
|
package/src/social-types.ts
CHANGED
|
@@ -192,6 +192,48 @@ export interface RoomRepliesReadResult {
|
|
|
192
192
|
markedRead: number;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/** The room kinds the chat notification inbox returns (the store's
|
|
196
|
+
* ROOM_INBOX_NOTIFICATION_TYPES). ROOM_FOLLOW rows stay out of the inbox:
|
|
197
|
+
* follow is a push channel, not a badge source. */
|
|
198
|
+
export type RoomNotificationKind = "ROOM_MENTION" | "ROOM_REPLY" | "ROOM_DM" | "ROOM_KEYWORD";
|
|
199
|
+
|
|
200
|
+
/** Shared room payload on a notification row, wire names verbatim from the
|
|
201
|
+
* store's notification schema (room_message_data). `preview` follows the
|
|
202
|
+
* push body rules (140 chars, empty for sealed content); `hash` is the
|
|
203
|
+
* client deep link. */
|
|
204
|
+
export interface RoomNotificationMessageData {
|
|
205
|
+
kind: "dm" | "mention" | "reply" | "follow" | "keyword";
|
|
206
|
+
room_id?: string;
|
|
207
|
+
room_name?: string;
|
|
208
|
+
space_id?: string;
|
|
209
|
+
conversation_id?: string;
|
|
210
|
+
seq?: number;
|
|
211
|
+
from_handle: string;
|
|
212
|
+
preview: string;
|
|
213
|
+
hash: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** One chat notification inbox row: the store's explicit field pick
|
|
217
|
+
* (snake_case wire names, dates as ISO strings, room_message_data only
|
|
218
|
+
* when the row carries one). */
|
|
219
|
+
export interface RoomNotificationInboxItem {
|
|
220
|
+
id: string;
|
|
221
|
+
type: RoomNotificationKind;
|
|
222
|
+
created_at: string;
|
|
223
|
+
read_at: string | null;
|
|
224
|
+
room_message_data?: RoomNotificationMessageData;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A newest-first inbox page plus the composite cursor naming the last row
|
|
228
|
+
* returned; feed lastCursor and lastCursorId back together to request the
|
|
229
|
+
* next page. Both are null on an empty page. */
|
|
230
|
+
export interface RoomNotificationInboxResult {
|
|
231
|
+
notifications: RoomNotificationInboxItem[];
|
|
232
|
+
hasMore: boolean;
|
|
233
|
+
lastCursor: string | null;
|
|
234
|
+
lastCursorId: string | null;
|
|
235
|
+
}
|
|
236
|
+
|
|
195
237
|
/** A living markdown doc scoped to a space (head state; content on demand). */
|
|
196
238
|
export interface RoomDoc {
|
|
197
239
|
docId: string;
|