@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.
- package/dist/agent/sidebar-model.d.ts +25 -1
- package/dist/agent/sidebar-model.js +32 -1
- package/dist/shared/rooms-protocol.d.ts +10 -0
- package/dist/social-client.d.ts +20 -2
- package/dist/social-client.js +29 -0
- package/dist/social-types.d.ts +38 -0
- package/package.json +1 -1
- package/src/agent/sidebar-model.ts +66 -2
- package/src/shared/rooms-protocol.ts +10 -0
- package/src/social-client.ts +46 -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;
|
|
@@ -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
|
|
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 {
|
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. */
|
|
@@ -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[]>;
|
package/dist/social-client.js
CHANGED
|
@@ -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",
|
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.5.
|
|
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;
|
|
@@ -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
|
|
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
|
|
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,
|
|
@@ -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,
|
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;
|