@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.
@@ -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;
@@ -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 readState = await this.deps
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.username
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
- const requestId = randomUUID();
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 {
@@ -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[]>;
@@ -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",
@@ -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.4.2",
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;
@@ -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 readState = await this.deps
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: RoomInviteRequest = request.username
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
- const requestId = randomUUID();
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
 
@@ -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,
@@ -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;