@openmarket/rooms-client 0.3.0 → 0.4.0

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/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type RoomAccessMode, type RoomAttachmentInput, type RoomAuditLogPayload, type RoomAuthSuccessPayload, type RoomBackfillPayload, type RoomDmHistoryResultPayload, type RoomDmMessagePayload, type RoomDmOpenedPayload, type RoomFriendPresence, type RoomInvitedPayload, type RoomLedgerEntry, type RoomMemberRemovedPayload, type RoomMemberRoleChangedPayload, type RoomMeta, type RoomPinsPayload, type RoomPresencePayload, type RoomReactorsPayload, type RoomReadStatePayload, type RoomReportedPayload, type RoomRole, type RoomSearchResultsPayload, type RoomSearchScope, type RoomSpace, type RoomThreadPayload, type RoomTopic, type RoomTopicAttention, type RoomTopicResultPayload, type RoomTopicsResultPayload, type RoomUserStatus, type RoomWhoPayload } from "./types.js";
1
+ import { type RoomAccessMode, type RoomAttachmentInput, type RoomAuditLogPayload, type RoomAuthSuccessPayload, type RoomBackfillPayload, type RoomClientPlatform, type RoomDmHistoryResultPayload, type RoomDmMessagePayload, type RoomDmOpenedPayload, type RoomFriendPresence, type RoomInvitedPayload, type RoomLedgerEntry, type RoomMemberRemovedPayload, type RoomMemberRoleChangedPayload, type RoomMeta, type RoomPinsPayload, type RoomPresencePayload, type RoomReactorsPayload, type RoomReadStatePayload, type RoomReportedPayload, type RoomRole, type RoomSearchResultsPayload, type RoomSearchScope, type RoomSpace, type RoomThreadPayload, type RoomTopic, type RoomTopicAttention, type RoomTopicResultPayload, type RoomTopicsResultPayload, type RoomUserStatus, type RoomWhoPayload } from "./types.js";
2
2
  import { type RoomWebSocketCtor, RoomWsClient } from "./ws-client.js";
3
3
  export interface RoomClientConfig {
4
4
  url: string;
@@ -283,9 +283,18 @@ export declare function joinRoom(config: RoomClientConfig, options: RoomJoinOpti
283
283
  /** Re-join a room on an already-connected client (used after a reconnect). */
284
284
  export declare function rejoinRoomOnClient(client: RoomWsClient, options: RoomJoinOptions): Promise<RoomBackfillPayload>;
285
285
  export declare function attachRejoinOnReconnect(client: RoomWsClient, options: RoomJoinOptions, latestSeq: () => number | undefined, onRejoined?: (backfill: RoomBackfillPayload) => void, onError?: (err: unknown) => void): () => void;
286
- export declare function postRoomMessage(client: RoomWsClient, room: string, text: string, inReplyTo?: number, attachments?: RoomAttachmentInput[], topicId?: string): string;
286
+ /**
287
+ * Post into a joined room and return the frame's clientMsgId (what MESSAGE
288
+ * echoes carry back for optimistic-send reconciliation). `clientMsgId` is
289
+ * caller-suppliable so an offline send queue can retry one logical message
290
+ * under one stable id (same contract as sayRoom / sendRoomDmOnClient);
291
+ * omitted, it mints a UUID exactly as before. The relay validates the id
292
+ * (1..128 chars after trim); dedupe on (userId, clientMsgId) is server-side
293
+ * work and NOT implied here.
294
+ */
295
+ export declare function postRoomMessage(client: RoomWsClient, room: string, text: string, inReplyTo?: number, attachments?: RoomAttachmentInput[], topicId?: string, clientMsgId?: string): string;
287
296
  export declare function sendRoomTyping(client: RoomWsClient, room: string, state: "start" | "stop"): void;
288
- export declare function sendRoomStatus(client: RoomWsClient, status: RoomUserStatus, statusText?: string | null): void;
297
+ export declare function sendRoomStatus(client: RoomWsClient, status: RoomUserStatus, statusText?: string | null, platform?: RoomClientPlatform): void;
289
298
  /**
290
299
  * Fetch a joined room's meta (title, access, postingRoles, viewerRole) over
291
300
  * the live socket via a scoped SEARCH. Returns null on timeout/no match so
@@ -310,7 +319,18 @@ export declare function requestRoomReactors(client: RoomWsClient, request: {
310
319
  * persists it durably; a one-shot socket has no joined rooms, so live
311
320
  * rosters pick the change up from the store (the TUI/GUI send STATUS on
312
321
  * their live connection instead, which also broadcasts PRESENCE_DELTA). */
313
- export declare function setRoomStatusOnce(config: RoomClientConfig, status: RoomUserStatus, statusText?: string | null): Promise<void>;
322
+ export declare function setRoomStatusOnce(config: RoomClientConfig, status: RoomUserStatus, statusText?: string | null, platform?: RoomClientPlatform): Promise<void>;
323
+ /**
324
+ * Set the caller's room-level attention (notification posture) on a live
325
+ * client. The relay validates room participation, persists through the
326
+ * store, and acks with a READ_STATE frame scoped to this room whose
327
+ * attention map carries the persisted value (readState stays empty: cursors
328
+ * are untouched). Private per-user state; nothing broadcasts.
329
+ */
330
+ export declare function setRoomAttentionOnClient(client: RoomWsClient, request: {
331
+ room: string;
332
+ attention: RoomTopicAttention;
333
+ }): Promise<RoomReadStatePayload>;
314
334
  /** Add/remove one reaction (one-shot). Returns the updated entry. */
315
335
  export declare function reactRoom(config: RoomClientConfig, options: {
316
336
  room: string;
package/dist/client.js CHANGED
@@ -618,21 +618,30 @@ export function attachRejoinOnReconnect(client, options, latestSeq, onRejoined,
618
618
  .catch((err) => onError?.(err));
619
619
  });
620
620
  }
621
- export function postRoomMessage(client, room, text, inReplyTo, attachments, topicId) {
622
- const clientMsgId = randomUUID();
621
+ /**
622
+ * Post into a joined room and return the frame's clientMsgId (what MESSAGE
623
+ * echoes carry back for optimistic-send reconciliation). `clientMsgId` is
624
+ * caller-suppliable so an offline send queue can retry one logical message
625
+ * under one stable id (same contract as sayRoom / sendRoomDmOnClient);
626
+ * omitted, it mints a UUID exactly as before. The relay validates the id
627
+ * (1..128 chars after trim); dedupe on (userId, clientMsgId) is server-side
628
+ * work and NOT implied here.
629
+ */
630
+ export function postRoomMessage(client, room, text, inReplyTo, attachments, topicId, clientMsgId) {
631
+ const msgId = clientMsgId ?? randomUUID();
623
632
  client.send({
624
633
  type: RoomClientMessageType.POST,
625
634
  payload: {
626
635
  room,
627
636
  ...(text ? { text } : {}),
628
637
  ...(attachments?.length ? { attachments: toRoomAttachmentPayloads(attachments) } : {}),
629
- clientMsgId,
638
+ clientMsgId: msgId,
630
639
  ...(inReplyTo !== undefined ? { inReplyTo } : {}),
631
640
  ...(topicId ? { topicId } : {}),
632
641
  },
633
642
  timestamp: Date.now(),
634
643
  });
635
- return clientMsgId;
644
+ return msgId;
636
645
  }
637
646
  export function sendRoomTyping(client, room, state) {
638
647
  if (client.isGuest())
@@ -643,10 +652,16 @@ export function sendRoomTyping(client, room, state) {
643
652
  timestamp: Date.now(),
644
653
  });
645
654
  }
646
- export function sendRoomStatus(client, status, statusText) {
655
+ export function sendRoomStatus(client, status, statusText, platform) {
647
656
  client.send({
648
657
  type: RoomClientMessageType.STATUS,
649
- payload: { status, ...(statusText !== undefined ? { statusText } : {}) },
658
+ payload: {
659
+ status,
660
+ ...(statusText !== undefined ? { statusText } : {}),
661
+ // Session identity, declared once per connection (or on change): the
662
+ // relay carries it onto presence member entries.
663
+ ...(platform ? { platform } : {}),
664
+ },
650
665
  timestamp: Date.now(),
651
666
  });
652
667
  }
@@ -769,11 +784,11 @@ export async function requestRoomReactors(client, request) {
769
784
  * persists it durably; a one-shot socket has no joined rooms, so live
770
785
  * rosters pick the change up from the store (the TUI/GUI send STATUS on
771
786
  * their live connection instead, which also broadcasts PRESENCE_DELTA). */
772
- export async function setRoomStatusOnce(config, status, statusText) {
787
+ export async function setRoomStatusOnce(config, status, statusText, platform) {
773
788
  const client = new RoomWsClient(config);
774
789
  try {
775
790
  await client.connect();
776
- sendRoomStatus(client, status, statusText);
791
+ sendRoomStatus(client, status, statusText, platform);
777
792
  // STATUS has no ack frame: give the socket a beat to flush before close.
778
793
  await new Promise((resolve) => setTimeout(resolve, 200));
779
794
  }
@@ -781,6 +796,24 @@ export async function setRoomStatusOnce(config, status, statusText) {
781
796
  client.close();
782
797
  }
783
798
  }
799
+ /**
800
+ * Set the caller's room-level attention (notification posture) on a live
801
+ * client. The relay validates room participation, persists through the
802
+ * store, and acks with a READ_STATE frame scoped to this room whose
803
+ * attention map carries the persisted value (readState stays empty: cursors
804
+ * are untouched). Private per-user state; nothing broadcasts.
805
+ */
806
+ export async function setRoomAttentionOnClient(client, request) {
807
+ const requestId = randomUUID();
808
+ const room = normalizeRoomName(request.room);
809
+ client.send({
810
+ type: RoomClientMessageType.ROOM_SET_ATTENTION,
811
+ payload: { requestId, room, attention: request.attention },
812
+ timestamp: Date.now(),
813
+ });
814
+ const result = await client.waitFor(RoomServerMessageType.READ_STATE, (msg) => msg.payload.requestId === requestId, undefined, requestId);
815
+ return result.payload;
816
+ }
784
817
  /** One-shot session helper for the config-based mutation verbs below (the
785
818
  * agent tools' transport: connect, join, act, wait for the echo, close). */
786
819
  async function withRoomEcho(config, room, seq, send) {
@@ -48,6 +48,7 @@ export declare const RoomClientMessageType: {
48
48
  readonly TOPIC_RESOLVE: "TOPIC_RESOLVE";
49
49
  readonly TOPIC_SET_ATTENTION: "TOPIC_SET_ATTENTION";
50
50
  readonly TOPIC_LIST: "TOPIC_LIST";
51
+ readonly ROOM_SET_ATTENTION: "ROOM_SET_ATTENTION";
51
52
  };
52
53
  export type RoomClientMessageType = (typeof RoomClientMessageType)[keyof typeof RoomClientMessageType];
53
54
  export declare const RoomServerMessageType: {
@@ -127,6 +128,9 @@ export type RoomRole = "owner" | "admin" | "mod" | "member";
127
128
  * semantics of an absent value). */
128
129
  export type RoomTopicsPolicy = "off" | "allowed" | "required";
129
130
  export type RoomTopicAttention = "follow" | "default" | "mute";
131
+ /** Self-declared client class on STATUS frames; presence member entries
132
+ * carry it through so rosters can render where someone is connected from. */
133
+ export type RoomClientPlatform = "web" | "desktop" | "mobile" | "tui";
130
134
  export interface RoomMember {
131
135
  userId: string;
132
136
  username: string;
@@ -140,6 +144,8 @@ export interface RoomMember {
140
144
  joinedAt: number;
141
145
  avatarUrl?: string | null;
142
146
  statusText?: string | null;
147
+ /** Present when the member's session declared one via STATUS. */
148
+ platform?: RoomClientPlatform | undefined;
143
149
  }
144
150
  export interface RoomMeta {
145
151
  name: string;
@@ -164,6 +170,10 @@ export interface RoomMeta {
164
170
  * 2026-07-12 dial-default flip (post-flip relays always advertise the
165
171
  * stored value, "off" included); explicit "off" means topics are inert. */
166
172
  topicsPolicy?: RoomTopicsPolicy | undefined;
173
+ /** The viewer's room attention (notification posture), a per-viewer field
174
+ * like viewerRole: surfaced on rooms listings for the caller. Absent
175
+ * reads as "default". */
176
+ attention?: RoomTopicAttention | undefined;
167
177
  tags?: string[] | undefined;
168
178
  entities?: string[] | undefined;
169
179
  sourceRefs?: Array<Record<string, unknown>> | undefined;
@@ -295,7 +305,12 @@ export interface RoomPinsPayload {
295
305
  requestId?: string | undefined;
296
306
  }
297
307
  export interface RoomReadStatePayload {
308
+ /** Partial map: rooms absent from a reply are unchanged. */
298
309
  readState: Record<string, number>;
310
+ /** Per-room attention for the requesting user, keyed like readState.
311
+ * Present when the store surfaces it and on ROOM_SET_ATTENTION acks
312
+ * (which reply READ_STATE scoped to the one room, readState empty). */
313
+ attention?: Record<string, RoomTopicAttention> | undefined;
299
314
  requestId?: string | undefined;
300
315
  }
301
316
  export interface RoomSearchResultsPayload {
@@ -743,6 +758,15 @@ export interface RoomTopicSetAttentionPayload {
743
758
  topicId: string;
744
759
  attention: RoomTopicAttention;
745
760
  }
761
+ /** Room-level attention (notification posture). Private per-user state on
762
+ * the caller's read row; topic attention wins for topic-scoped messages
763
+ * and room attention wins over space attention. The relay acks with a
764
+ * READ_STATE frame scoped to the room (see RoomReadStatePayload). */
765
+ export interface RoomSetAttentionPayload {
766
+ requestId?: string;
767
+ room: string;
768
+ attention: RoomTopicAttention;
769
+ }
746
770
  export interface RoomTopicListPayload {
747
771
  requestId?: string;
748
772
  room: string;
@@ -1017,6 +1041,7 @@ export type RoomClientMessage = {
1017
1041
  payload: {
1018
1042
  status: RoomUserStatus;
1019
1043
  statusText?: string | null;
1044
+ platform?: RoomClientPlatform;
1020
1045
  };
1021
1046
  timestamp?: number;
1022
1047
  } | {
@@ -1092,6 +1117,10 @@ export type RoomClientMessage = {
1092
1117
  type: typeof RoomClientMessageType.TOPIC_LIST;
1093
1118
  payload: RoomTopicListPayload;
1094
1119
  timestamp?: number;
1120
+ } | {
1121
+ type: typeof RoomClientMessageType.ROOM_SET_ATTENTION;
1122
+ payload: RoomSetAttentionPayload;
1123
+ timestamp?: number;
1095
1124
  };
1096
1125
  export type RoomServerMessage = {
1097
1126
  type: typeof RoomServerMessageType.AUTH_SUCCESS;
@@ -48,6 +48,7 @@ export const RoomClientMessageType = {
48
48
  TOPIC_RESOLVE: "TOPIC_RESOLVE",
49
49
  TOPIC_SET_ATTENTION: "TOPIC_SET_ATTENTION",
50
50
  TOPIC_LIST: "TOPIC_LIST",
51
+ ROOM_SET_ATTENTION: "ROOM_SET_ATTENTION",
51
52
  };
52
53
  export const RoomServerMessageType = {
53
54
  AUTH_SUCCESS: "AUTH_SUCCESS",
@@ -130,6 +130,9 @@ export interface RoomDmMessage {
130
130
  emoji: string;
131
131
  users: string[];
132
132
  }>;
133
+ /** Client-minted idempotency tag echoed back on DM sends, so a sender's
134
+ * durable queue can settle its optimistic row. Additive (0.4.0). */
135
+ clientMsgId?: string;
133
136
  }
134
137
  export interface RoomDmHistory {
135
138
  conversationId: string;
package/dist/types.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export type { RoomAccessMode, RoomAttachment, RoomAttachmentInput, RoomAuditEntry, RoomAuditLogPayload, RoomAuditLogRequestPayload, RoomAuthSuccessPayload, RoomBackfillPayload, RoomClientMessage, RoomCreatedPayload, RoomCreatePayload, RoomDmHistoryPayload, RoomDmHistoryResultPayload, RoomDmInboxUpdatedPayload, RoomDmMessage, RoomDmMessagePayload, RoomDmOpenedPayload, RoomDmOpenPayload, RoomDmParticipant, RoomDmReadPayload, RoomDmSendPayload, RoomEntryUpdatedPayload, RoomErrorPayload, RoomFriendPresence, RoomInvitedPayload, RoomInvitePayload, RoomLedgerEntry, RoomMember, RoomMemberRemovedPayload, RoomMemberRoleChangedPayload, RoomMemberTier, RoomMessagePayload, RoomMeta, RoomModerateMemberPayload, RoomMutedPayload, RoomPinsPayload, RoomPresenceDeltaPayload, RoomPresencePayload, RoomReactorsPayload, RoomReadStatePayload, RoomRemoveMemberPayload, RoomReportedPayload, RoomReportPayload, RoomResyncPayload, RoomRole, RoomRoleChangePayload, RoomSearchPayload, RoomSearchResultsPayload, RoomSearchScope, RoomServerMessage, RoomServerMessageOf, RoomSetReadOnlyPayload, RoomSpace, RoomSpaceJoinedPayload, RoomSpacesPayload, RoomThreadPayload, RoomTopic, RoomTopicAttention, RoomTopicBucketState, RoomTopicCreatePayload, RoomTopicListPayload, RoomTopicMergePayload, RoomTopicMovePayload, RoomTopicRenamePayload, RoomTopicResolvePayload, RoomTopicResultPayload, RoomTopicSetAttentionPayload, RoomTopicsPolicy, RoomTopicsResultPayload, RoomTopicUpdatedPayload, RoomTypingPayload, RoomUserStatus, RoomWhoPayload, } from "./shared/rooms-protocol.js";
1
+ export type { RoomAccessMode, RoomAttachment, RoomAttachmentInput, RoomAuditEntry, RoomAuditLogPayload, RoomAuditLogRequestPayload, RoomAuthSuccessPayload, RoomBackfillPayload, RoomClientMessage, RoomClientPlatform, RoomCreatedPayload, RoomCreatePayload, RoomDmHistoryPayload, RoomDmHistoryResultPayload, RoomDmInboxUpdatedPayload, RoomDmMessage, RoomDmMessagePayload, RoomDmOpenedPayload, RoomDmOpenPayload, RoomDmParticipant, RoomDmReadPayload, RoomDmSendPayload, RoomEntryUpdatedPayload, RoomErrorPayload, RoomFriendPresence, RoomInvitedPayload, RoomInvitePayload, RoomLedgerEntry, RoomMember, RoomMemberRemovedPayload, RoomMemberRoleChangedPayload, RoomMemberTier, RoomMessagePayload, RoomMeta, RoomModerateMemberPayload, RoomMutedPayload, RoomPinsPayload, RoomPresenceDeltaPayload, RoomPresencePayload, RoomReactorsPayload, RoomReadStatePayload, RoomRemoveMemberPayload, RoomReportedPayload, RoomReportPayload, RoomResyncPayload, RoomRole, RoomRoleChangePayload, RoomSearchPayload, RoomSearchResultsPayload, RoomSearchScope, RoomServerMessage, RoomServerMessageOf, RoomSetAttentionPayload, RoomSetReadOnlyPayload, RoomSpace, RoomSpaceJoinedPayload, RoomSpacesPayload, RoomThreadPayload, RoomTopic, RoomTopicAttention, RoomTopicBucketState, RoomTopicCreatePayload, RoomTopicListPayload, RoomTopicMergePayload, RoomTopicMovePayload, RoomTopicRenamePayload, RoomTopicResolvePayload, RoomTopicResultPayload, RoomTopicSetAttentionPayload, RoomTopicsPolicy, RoomTopicsResultPayload, RoomTopicUpdatedPayload, RoomTypingPayload, RoomUserStatus, RoomWhoPayload, } from "./shared/rooms-protocol.js";
2
2
  export { applyRoomPresenceDelta, isSystemRoomLedgerEntryType, ROOM_PRESENCE_SAMPLE_LIMIT, RoomClientMessageType, RoomLedgerEntryType, RoomServerMessageType, SUPPORTED_ROOM_PROTOCOL_VERSION, } from "./shared/rooms-protocol.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmarket/rooms-client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",
package/src/client.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  type RoomBackfillPayload,
14
14
  type RoomClientMessage,
15
15
  RoomClientMessageType,
16
+ type RoomClientPlatform,
16
17
  type RoomDmHistoryResultPayload,
17
18
  type RoomDmMessagePayload,
18
19
  type RoomDmOpenedPayload,
@@ -1147,6 +1148,15 @@ export function attachRejoinOnReconnect(
1147
1148
  });
1148
1149
  }
1149
1150
 
1151
+ /**
1152
+ * Post into a joined room and return the frame's clientMsgId (what MESSAGE
1153
+ * echoes carry back for optimistic-send reconciliation). `clientMsgId` is
1154
+ * caller-suppliable so an offline send queue can retry one logical message
1155
+ * under one stable id (same contract as sayRoom / sendRoomDmOnClient);
1156
+ * omitted, it mints a UUID exactly as before. The relay validates the id
1157
+ * (1..128 chars after trim); dedupe on (userId, clientMsgId) is server-side
1158
+ * work and NOT implied here.
1159
+ */
1150
1160
  export function postRoomMessage(
1151
1161
  client: RoomWsClient,
1152
1162
  room: string,
@@ -1154,21 +1164,22 @@ export function postRoomMessage(
1154
1164
  inReplyTo?: number,
1155
1165
  attachments?: RoomAttachmentInput[],
1156
1166
  topicId?: string,
1167
+ clientMsgId?: string,
1157
1168
  ): string {
1158
- const clientMsgId = randomUUID();
1169
+ const msgId = clientMsgId ?? randomUUID();
1159
1170
  client.send({
1160
1171
  type: RoomClientMessageType.POST,
1161
1172
  payload: {
1162
1173
  room,
1163
1174
  ...(text ? { text } : {}),
1164
1175
  ...(attachments?.length ? { attachments: toRoomAttachmentPayloads(attachments) } : {}),
1165
- clientMsgId,
1176
+ clientMsgId: msgId,
1166
1177
  ...(inReplyTo !== undefined ? { inReplyTo } : {}),
1167
1178
  ...(topicId ? { topicId } : {}),
1168
1179
  },
1169
1180
  timestamp: Date.now(),
1170
1181
  });
1171
- return clientMsgId;
1182
+ return msgId;
1172
1183
  }
1173
1184
 
1174
1185
  export function sendRoomTyping(client: RoomWsClient, room: string, state: "start" | "stop"): void {
@@ -1184,10 +1195,17 @@ export function sendRoomStatus(
1184
1195
  client: RoomWsClient,
1185
1196
  status: RoomUserStatus,
1186
1197
  statusText?: string | null,
1198
+ platform?: RoomClientPlatform,
1187
1199
  ): void {
1188
1200
  client.send({
1189
1201
  type: RoomClientMessageType.STATUS,
1190
- payload: { status, ...(statusText !== undefined ? { statusText } : {}) },
1202
+ payload: {
1203
+ status,
1204
+ ...(statusText !== undefined ? { statusText } : {}),
1205
+ // Session identity, declared once per connection (or on change): the
1206
+ // relay carries it onto presence member entries.
1207
+ ...(platform ? { platform } : {}),
1208
+ },
1191
1209
  timestamp: Date.now(),
1192
1210
  });
1193
1211
  }
@@ -1393,11 +1411,12 @@ export async function setRoomStatusOnce(
1393
1411
  config: RoomClientConfig,
1394
1412
  status: RoomUserStatus,
1395
1413
  statusText?: string | null,
1414
+ platform?: RoomClientPlatform,
1396
1415
  ): Promise<void> {
1397
1416
  const client = new RoomWsClient(config);
1398
1417
  try {
1399
1418
  await client.connect();
1400
- sendRoomStatus(client, status, statusText);
1419
+ sendRoomStatus(client, status, statusText, platform);
1401
1420
  // STATUS has no ack frame: give the socket a beat to flush before close.
1402
1421
  await new Promise((resolve) => setTimeout(resolve, 200));
1403
1422
  } finally {
@@ -1405,6 +1424,33 @@ export async function setRoomStatusOnce(
1405
1424
  }
1406
1425
  }
1407
1426
 
1427
+ /**
1428
+ * Set the caller's room-level attention (notification posture) on a live
1429
+ * client. The relay validates room participation, persists through the
1430
+ * store, and acks with a READ_STATE frame scoped to this room whose
1431
+ * attention map carries the persisted value (readState stays empty: cursors
1432
+ * are untouched). Private per-user state; nothing broadcasts.
1433
+ */
1434
+ export async function setRoomAttentionOnClient(
1435
+ client: RoomWsClient,
1436
+ request: { room: string; attention: RoomTopicAttention },
1437
+ ): Promise<RoomReadStatePayload> {
1438
+ const requestId = randomUUID();
1439
+ const room = normalizeRoomName(request.room);
1440
+ client.send({
1441
+ type: RoomClientMessageType.ROOM_SET_ATTENTION,
1442
+ payload: { requestId, room, attention: request.attention },
1443
+ timestamp: Date.now(),
1444
+ });
1445
+ const result = await client.waitFor(
1446
+ RoomServerMessageType.READ_STATE,
1447
+ (msg) => msg.payload.requestId === requestId,
1448
+ undefined,
1449
+ requestId,
1450
+ );
1451
+ return result.payload;
1452
+ }
1453
+
1408
1454
  /** One-shot session helper for the config-based mutation verbs below (the
1409
1455
  * agent tools' transport: connect, join, act, wait for the echo, close). */
1410
1456
  async function withRoomEcho(
@@ -48,6 +48,7 @@ export const RoomClientMessageType = {
48
48
  TOPIC_RESOLVE: "TOPIC_RESOLVE",
49
49
  TOPIC_SET_ATTENTION: "TOPIC_SET_ATTENTION",
50
50
  TOPIC_LIST: "TOPIC_LIST",
51
+ ROOM_SET_ATTENTION: "ROOM_SET_ATTENTION",
51
52
  } as const;
52
53
 
53
54
  export type RoomClientMessageType =
@@ -152,6 +153,9 @@ export type RoomRole = "owner" | "admin" | "mod" | "member";
152
153
  * semantics of an absent value). */
153
154
  export type RoomTopicsPolicy = "off" | "allowed" | "required";
154
155
  export type RoomTopicAttention = "follow" | "default" | "mute";
156
+ /** Self-declared client class on STATUS frames; presence member entries
157
+ * carry it through so rosters can render where someone is connected from. */
158
+ export type RoomClientPlatform = "web" | "desktop" | "mobile" | "tui";
155
159
 
156
160
  export interface RoomMember {
157
161
  userId: string;
@@ -166,6 +170,8 @@ export interface RoomMember {
166
170
  joinedAt: number;
167
171
  avatarUrl?: string | null;
168
172
  statusText?: string | null;
173
+ /** Present when the member's session declared one via STATUS. */
174
+ platform?: RoomClientPlatform | undefined;
169
175
  }
170
176
 
171
177
  export interface RoomMeta {
@@ -191,6 +197,10 @@ export interface RoomMeta {
191
197
  * 2026-07-12 dial-default flip (post-flip relays always advertise the
192
198
  * stored value, "off" included); explicit "off" means topics are inert. */
193
199
  topicsPolicy?: RoomTopicsPolicy | undefined;
200
+ /** The viewer's room attention (notification posture), a per-viewer field
201
+ * like viewerRole: surfaced on rooms listings for the caller. Absent
202
+ * reads as "default". */
203
+ attention?: RoomTopicAttention | undefined;
194
204
  tags?: string[] | undefined;
195
205
  entities?: string[] | undefined;
196
206
  sourceRefs?: Array<Record<string, unknown>> | undefined;
@@ -331,7 +341,12 @@ export interface RoomPinsPayload {
331
341
  }
332
342
 
333
343
  export interface RoomReadStatePayload {
344
+ /** Partial map: rooms absent from a reply are unchanged. */
334
345
  readState: Record<string, number>;
346
+ /** Per-room attention for the requesting user, keyed like readState.
347
+ * Present when the store surfaces it and on ROOM_SET_ATTENTION acks
348
+ * (which reply READ_STATE scoped to the one room, readState empty). */
349
+ attention?: Record<string, RoomTopicAttention> | undefined;
335
350
  requestId?: string | undefined;
336
351
  }
337
352
 
@@ -862,6 +877,16 @@ export interface RoomTopicSetAttentionPayload {
862
877
  attention: RoomTopicAttention;
863
878
  }
864
879
 
880
+ /** Room-level attention (notification posture). Private per-user state on
881
+ * the caller's read row; topic attention wins for topic-scoped messages
882
+ * and room attention wins over space attention. The relay acks with a
883
+ * READ_STATE frame scoped to the room (see RoomReadStatePayload). */
884
+ export interface RoomSetAttentionPayload {
885
+ requestId?: string;
886
+ room: string;
887
+ attention: RoomTopicAttention;
888
+ }
889
+
865
890
  export interface RoomTopicListPayload {
866
891
  requestId?: string;
867
892
  room: string;
@@ -1129,7 +1154,11 @@ export type RoomClientMessage =
1129
1154
  }
1130
1155
  | {
1131
1156
  type: typeof RoomClientMessageType.STATUS;
1132
- payload: { status: RoomUserStatus; statusText?: string | null };
1157
+ payload: {
1158
+ status: RoomUserStatus;
1159
+ statusText?: string | null;
1160
+ platform?: RoomClientPlatform;
1161
+ };
1133
1162
  timestamp?: number;
1134
1163
  }
1135
1164
  | {
@@ -1213,6 +1242,11 @@ export type RoomClientMessage =
1213
1242
  type: typeof RoomClientMessageType.TOPIC_LIST;
1214
1243
  payload: RoomTopicListPayload;
1215
1244
  timestamp?: number;
1245
+ }
1246
+ | {
1247
+ type: typeof RoomClientMessageType.ROOM_SET_ATTENTION;
1248
+ payload: RoomSetAttentionPayload;
1249
+ timestamp?: number;
1216
1250
  };
1217
1251
 
1218
1252
  export type RoomServerMessage =
@@ -144,6 +144,9 @@ export interface RoomDmMessage {
144
144
  imageUrl?: string | null;
145
145
  /** Aggregated reactions (per-emoji userId lists). Additive. */
146
146
  reactions?: Array<{ emoji: string; users: string[] }>;
147
+ /** Client-minted idempotency tag echoed back on DM sends, so a sender's
148
+ * durable queue can settle its optimistic row. Additive (0.4.0). */
149
+ clientMsgId?: string;
147
150
  }
148
151
 
149
152
  export interface RoomDmHistory {
package/src/types.ts CHANGED
@@ -8,6 +8,7 @@ export type {
8
8
  RoomAuthSuccessPayload,
9
9
  RoomBackfillPayload,
10
10
  RoomClientMessage,
11
+ RoomClientPlatform,
11
12
  RoomCreatedPayload,
12
13
  RoomCreatePayload,
13
14
  RoomDmHistoryPayload,
@@ -50,6 +51,7 @@ export type {
50
51
  RoomSearchScope,
51
52
  RoomServerMessage,
52
53
  RoomServerMessageOf,
54
+ RoomSetAttentionPayload,
53
55
  RoomSetReadOnlyPayload,
54
56
  RoomSpace,
55
57
  RoomSpaceJoinedPayload,