@openmarket/rooms-client 0.5.0 → 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.
@@ -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
- fetchReadState: (roomNames: string[]) => Promise<Record<string, number>>;
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;
@@ -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;
@@ -174,13 +202,16 @@ export class SidebarModel {
174
202
  this.deps.fetchDms().catch(() => []),
175
203
  this.deps.fetchGlobalReplyUnread().catch(() => 0),
176
204
  ]);
177
- const readState = await this.deps
205
+ const readStateReply = await this.deps
178
206
  .fetchReadState(rooms.map((room) => room.name))
179
207
  .catch(() => ({}));
208
+ const { readState, mentionCounts, mentionTotal } = normalizeSidebarReadState(readStateReply);
180
209
  return buildSidebarSnapshot({
181
210
  rooms,
182
211
  spaces,
183
212
  readState,
213
+ mentionCounts,
214
+ mentionTotal,
184
215
  dms,
185
216
  globalReplyUnread,
186
217
  joinedUnread: this.deps.joinedUnread,
@@ -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 {
@@ -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. */
@@ -368,6 +368,24 @@ export declare function markRoomRepliesRead(config: RoomSocialConfig, options?:
368
368
  room?: string;
369
369
  before?: string | Date;
370
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>;
371
389
  export declare function suggestRooms(config: RoomSocialConfig, request: RoomSuggestionRequest, options?: {
372
390
  signal?: AbortSignal;
373
391
  }): Promise<RoomSuggestion[]>;
@@ -592,6 +592,35 @@ export async function markRoomRepliesRead(config, options = {}) {
592
592
  });
593
593
  return { markedRead: result.markedRead ?? 0 };
594
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
+ }
595
624
  export async function suggestRooms(config, request, options = {}) {
596
625
  const init = {
597
626
  method: "POST",
@@ -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.5.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
- fetchReadState: (roomNames: string[]) => Promise<Record<string, number>>;
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;
@@ -243,13 +304,16 @@ export class SidebarModel {
243
304
  this.deps.fetchDms().catch(() => [] as SidebarDm[]),
244
305
  this.deps.fetchGlobalReplyUnread().catch(() => 0),
245
306
  ]);
246
- const readState = await this.deps
307
+ const readStateReply = await this.deps
247
308
  .fetchReadState(rooms.map((room) => room.name))
248
309
  .catch(() => ({}) as Record<string, number>);
310
+ const { readState, mentionCounts, mentionTotal } = normalizeSidebarReadState(readStateReply);
249
311
  return buildSidebarSnapshot({
250
312
  rooms,
251
313
  spaces,
252
314
  readState,
315
+ mentionCounts,
316
+ mentionTotal,
253
317
  dms,
254
318
  globalReplyUnread,
255
319
  joinedUnread: this.deps.joinedUnread,
@@ -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
 
@@ -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,
@@ -1197,6 +1202,47 @@ export async function markRoomRepliesRead(
1197
1202
  return { markedRead: result.markedRead ?? 0 };
1198
1203
  }
1199
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
+
1200
1246
  export async function suggestRooms(
1201
1247
  config: RoomSocialConfig,
1202
1248
  request: RoomSuggestionRequest,
@@ -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;