@openmarket/rooms-client 0.4.1 → 0.5.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.
@@ -74,6 +74,9 @@ export declare class SidebarModel {
74
74
  constructor(deps: SidebarModelDeps);
75
75
  snapshot(): SidebarSnapshot;
76
76
  onUpdate(listener: () => void): () => void;
77
+ /** Apply a same-user realtime DM counter without refetching the full room
78
+ * directory. Username is the stable DM key used by the sidebar model. */
79
+ applyDmUnread(username: string, unread: number): void;
77
80
  /**
78
81
  * Post-mutation refresh: if a poll-triggered load is already in flight it
79
82
  * may predate the caller's writes, so wait it out and load AGAIN. Readers
@@ -112,6 +112,34 @@ export class SidebarModel {
112
112
  this.listeners.delete(listener);
113
113
  };
114
114
  }
115
+ /** Apply a same-user realtime DM counter without refetching the full room
116
+ * directory. Username is the stable DM key used by the sidebar model. */
117
+ applyDmUnread(username, unread) {
118
+ const key = normalizeDmUsername(username);
119
+ if (!key)
120
+ return;
121
+ const normalizedUnread = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0;
122
+ let found = false;
123
+ let changed = false;
124
+ const next = this.snapshotValue.dms.map((dm) => {
125
+ if (normalizeDmUsername(dm.username) !== key)
126
+ return dm;
127
+ found = true;
128
+ if (dm.unread === normalizedUnread)
129
+ return dm;
130
+ changed = true;
131
+ return { ...dm, unread: normalizedUnread };
132
+ });
133
+ if (!found && normalizedUnread > 0) {
134
+ next.push({ username: username.replace(/^@+/, ""), unread: normalizedUnread });
135
+ changed = true;
136
+ }
137
+ if (!changed)
138
+ return;
139
+ this.snapshotValue = { ...this.snapshotValue, dms: next };
140
+ for (const listener of this.listeners)
141
+ listener();
142
+ }
115
143
  /**
116
144
  * Post-mutation refresh: if a poll-triggered load is already in flight it
117
145
  * may predate the caller's writes, so wait it out and load AGAIN. Readers
@@ -160,3 +188,6 @@ export class SidebarModel {
160
188
  });
161
189
  }
162
190
  }
191
+ function normalizeDmUsername(username) {
192
+ return username.trim().replace(/^@+/, "").toLowerCase();
193
+ }
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type RoomAccessMode, type RoomAttachmentInput, type RoomAuditLogPayload, type RoomAuthSuccessPayload, type RoomBackfillPayload, type RoomClientPlatform, type RoomDmHistoryResultPayload, type RoomDmInboxUpdatedPayload, 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 RoomDmInboxUpdatedPayload, type RoomDmMessageDeletedPayload, type RoomDmMessageEditedPayload, type RoomDmMessagePayload, type RoomDmOpenedPayload, type RoomDmPinsResultPayload, type RoomDmReactionUpdatedPayload, type RoomForwardMetadata, 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;
@@ -212,6 +212,10 @@ export interface RoomDmSendRequest {
212
212
  * URL. Stored on the message's imageUrl; the client signs keys on render.
213
213
  * Additive — omitted for text-only sends. */
214
214
  imageUrl?: string;
215
+ metadata?: RoomForwardMetadata;
216
+ }
217
+ export interface RoomDmReplyRequest extends RoomDmSendRequest {
218
+ replyTo: string;
215
219
  }
216
220
  export interface RoomDmReadRequest {
217
221
  username?: string;
@@ -238,6 +242,9 @@ export { workspaceRoomName } from "./names.js";
238
242
  export declare function listRooms(config: RoomClientConfig, request?: RoomListRequest): Promise<RoomListResult>;
239
243
  export declare function createRoom(config: RoomClientConfig, request: RoomCreateRequest): Promise<RoomCreateResult>;
240
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>;
241
248
  export declare function removeRoomMember(config: RoomClientConfig, request: RoomRemoveMemberRequest): Promise<RoomRemoveMemberResult>;
242
249
  export declare function reportRoomTarget(config: RoomClientConfig, request: RoomReportRequest): Promise<RoomReportResult>;
243
250
  export declare function muteRoomMember(config: RoomClientConfig, request: RoomMuteRequest): Promise<RoomMuteResult>;
@@ -260,20 +267,20 @@ export declare function openRoomDmOnClient(client: RoomWsClient, request: RoomDm
260
267
  export declare function requestRoomDmHistoryOnClient(client: RoomWsClient, request: RoomDmHistoryRequest, timeoutMs?: number): Promise<RoomDmHistoryResultPayload>;
261
268
  export declare function markRoomDmReadOnClient(client: RoomWsClient, request: RoomDmReadRequest, timeoutMs?: number): Promise<RoomDmInboxUpdatedPayload>;
262
269
  export declare function sendRoomDmOnClient(client: RoomWsClient, request: RoomDmSendRequest, timeoutMs?: number): Promise<RoomDmMessagePayload>;
263
- /** Edit one of my DM messages. Fire-and-forget over the WS; the relay fans out
264
- * DM_MESSAGE_EDITED to both participants. */
270
+ export declare function replyRoomDmOnClient(client: RoomWsClient, request: RoomDmReplyRequest, timeoutMs?: number): Promise<RoomDmMessagePayload>;
271
+ /** Edit one of my DM messages and wait for the correlated relay update. */
265
272
  export declare function editRoomDmOnClient(client: RoomWsClient, request: {
266
273
  conversationId?: string;
267
274
  username?: string;
268
275
  messageId: string;
269
276
  content: string;
270
- }): void;
277
+ }, timeoutMs?: number): Promise<RoomDmMessageEditedPayload>;
271
278
  /** Soft-delete one of my DM messages. The relay fans out DM_MESSAGE_DELETED. */
272
279
  export declare function deleteRoomDmOnClient(client: RoomWsClient, request: {
273
280
  conversationId?: string;
274
281
  username?: string;
275
282
  messageId: string;
276
- }): void;
283
+ }, timeoutMs?: number): Promise<RoomDmMessageDeletedPayload>;
277
284
  /** Toggle a reaction on a DM message. The relay fans out DM_REACTION_UPDATED. */
278
285
  export declare function reactRoomDmOnClient(client: RoomWsClient, request: {
279
286
  conversationId?: string;
@@ -281,7 +288,23 @@ export declare function reactRoomDmOnClient(client: RoomWsClient, request: {
281
288
  messageId: string;
282
289
  emoji: string;
283
290
  op: "add" | "remove";
291
+ }, timeoutMs?: number): Promise<RoomDmReactionUpdatedPayload>;
292
+ /** Set shared pin state for a DM message. The relay fans out DM_PIN_UPDATED. */
293
+ export declare function pinRoomDmOnClient(client: RoomWsClient, request: {
294
+ conversationId?: string;
295
+ username?: string;
296
+ messageId: string;
284
297
  }): void;
298
+ export declare function unpinRoomDmOnClient(client: RoomWsClient, request: {
299
+ conversationId?: string;
300
+ username?: string;
301
+ messageId: string;
302
+ }): void;
303
+ export declare function requestRoomDmPinsOnClient(client: RoomWsClient, request: {
304
+ conversationId?: string;
305
+ username?: string;
306
+ limit?: number;
307
+ }, timeoutMs?: number): Promise<RoomDmPinsResultPayload>;
285
308
  export declare function peekRoom(config: RoomClientConfig, options: RoomPeekOptions): Promise<RoomBackfillPayload>;
286
309
  export declare function sayRoom(config: RoomClientConfig, options: RoomSayOptions): Promise<RoomSayResult>;
287
310
  export declare function joinRoom(config: RoomClientConfig, options: RoomJoinOptions): Promise<RoomJoinedSession>;
@@ -297,7 +320,7 @@ export declare function attachRejoinOnReconnect(client: RoomWsClient, options: R
297
320
  * (1..128 chars after trim); dedupe on (userId, clientMsgId) is server-side
298
321
  * work and NOT implied here.
299
322
  */
300
- export declare function postRoomMessage(client: RoomWsClient, room: string, text: string, inReplyTo?: number, attachments?: RoomAttachmentInput[], topicId?: string, clientMsgId?: string): string;
323
+ export declare function postRoomMessage(client: RoomWsClient, room: string, text: string, inReplyTo?: number, attachments?: RoomAttachmentInput[], topicId?: string, clientMsgId?: string, metadata?: RoomForwardMetadata): string;
301
324
  export declare function sendRoomTyping(client: RoomWsClient, room: string, state: "start" | "stop"): void;
302
325
  export declare function sendRoomStatus(client: RoomWsClient, status: RoomUserStatus, statusText?: string | null, platform?: RoomClientPlatform): void;
303
326
  /**
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 {
@@ -480,6 +489,7 @@ export async function sendRoomDmOnClient(client, request, timeoutMs) {
480
489
  ...(request.username ? { username: request.username } : {}),
481
490
  ...(request.conversationId ? { conversationId: request.conversationId } : {}),
482
491
  ...(request.imageUrl ? { imageUrl: request.imageUrl } : {}),
492
+ ...(request.metadata ? { metadata: request.metadata } : {}),
483
493
  },
484
494
  timestamp: Date.now(),
485
495
  });
@@ -488,12 +498,35 @@ export async function sendRoomDmOnClient(client, request, timeoutMs) {
488
498
  msg.payload.message.clientMsgId === clientMsgId, timeoutMs, requestId);
489
499
  return sent.payload;
490
500
  }
491
- /** Edit one of my DM messages. Fire-and-forget over the WS; the relay fans out
492
- * DM_MESSAGE_EDITED to both participants. */
493
- export function editRoomDmOnClient(client, request) {
501
+ export async function replyRoomDmOnClient(client, request, timeoutMs) {
502
+ const requestId = randomUUID();
503
+ const clientMsgId = request.clientMsgId ?? randomUUID();
504
+ client.send({
505
+ type: RoomClientMessageType.DM_REPLY,
506
+ payload: {
507
+ requestId,
508
+ content: request.content,
509
+ replyTo: request.replyTo,
510
+ clientMsgId,
511
+ ...(request.username ? { username: request.username } : {}),
512
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
513
+ ...(request.imageUrl ? { imageUrl: request.imageUrl } : {}),
514
+ ...(request.metadata ? { metadata: request.metadata } : {}),
515
+ },
516
+ timestamp: Date.now(),
517
+ });
518
+ const sent = await client.waitFor(RoomServerMessageType.DM_MESSAGE, (msg) => msg.payload.requestId === requestId ||
519
+ msg.payload.clientMsgId === clientMsgId ||
520
+ msg.payload.message.clientMsgId === clientMsgId, timeoutMs, requestId);
521
+ return sent.payload;
522
+ }
523
+ /** Edit one of my DM messages and wait for the correlated relay update. */
524
+ export async function editRoomDmOnClient(client, request, timeoutMs) {
525
+ const requestId = randomUUID();
494
526
  client.send({
495
527
  type: RoomClientMessageType.DM_EDIT,
496
528
  payload: {
529
+ requestId,
497
530
  messageId: request.messageId,
498
531
  content: request.content,
499
532
  ...(request.conversationId ? { conversationId: request.conversationId } : {}),
@@ -501,24 +534,32 @@ export function editRoomDmOnClient(client, request) {
501
534
  },
502
535
  timestamp: Date.now(),
503
536
  });
537
+ const updated = await client.waitFor(RoomServerMessageType.DM_MESSAGE_EDITED, (msg) => msg.payload.requestId === requestId, timeoutMs, requestId);
538
+ return updated.payload;
504
539
  }
505
540
  /** Soft-delete one of my DM messages. The relay fans out DM_MESSAGE_DELETED. */
506
- export function deleteRoomDmOnClient(client, request) {
541
+ export async function deleteRoomDmOnClient(client, request, timeoutMs) {
542
+ const requestId = randomUUID();
507
543
  client.send({
508
544
  type: RoomClientMessageType.DM_DELETE,
509
545
  payload: {
546
+ requestId,
510
547
  messageId: request.messageId,
511
548
  ...(request.conversationId ? { conversationId: request.conversationId } : {}),
512
549
  ...(request.username ? { username: request.username } : {}),
513
550
  },
514
551
  timestamp: Date.now(),
515
552
  });
553
+ const updated = await client.waitFor(RoomServerMessageType.DM_MESSAGE_DELETED, (msg) => msg.payload.requestId === requestId, timeoutMs, requestId);
554
+ return updated.payload;
516
555
  }
517
556
  /** Toggle a reaction on a DM message. The relay fans out DM_REACTION_UPDATED. */
518
- export function reactRoomDmOnClient(client, request) {
557
+ export async function reactRoomDmOnClient(client, request, timeoutMs) {
558
+ const requestId = randomUUID();
519
559
  client.send({
520
560
  type: RoomClientMessageType.DM_REACT,
521
561
  payload: {
562
+ requestId,
522
563
  messageId: request.messageId,
523
564
  emoji: request.emoji,
524
565
  op: request.op,
@@ -527,6 +568,46 @@ export function reactRoomDmOnClient(client, request) {
527
568
  },
528
569
  timestamp: Date.now(),
529
570
  });
571
+ const updated = await client.waitFor(RoomServerMessageType.DM_REACTION_UPDATED, (msg) => msg.payload.requestId === requestId, timeoutMs, requestId);
572
+ return updated.payload;
573
+ }
574
+ /** Set shared pin state for a DM message. The relay fans out DM_PIN_UPDATED. */
575
+ export function pinRoomDmOnClient(client, request) {
576
+ client.send({
577
+ type: RoomClientMessageType.DM_PIN,
578
+ payload: {
579
+ messageId: request.messageId,
580
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
581
+ ...(request.username ? { username: request.username } : {}),
582
+ },
583
+ timestamp: Date.now(),
584
+ });
585
+ }
586
+ export function unpinRoomDmOnClient(client, request) {
587
+ client.send({
588
+ type: RoomClientMessageType.DM_UNPIN,
589
+ payload: {
590
+ messageId: request.messageId,
591
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
592
+ ...(request.username ? { username: request.username } : {}),
593
+ },
594
+ timestamp: Date.now(),
595
+ });
596
+ }
597
+ export async function requestRoomDmPinsOnClient(client, request, timeoutMs) {
598
+ const requestId = randomUUID();
599
+ client.send({
600
+ type: RoomClientMessageType.DM_PINS,
601
+ payload: {
602
+ requestId,
603
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
604
+ ...(request.username ? { username: request.username } : {}),
605
+ ...(request.limit ? { limit: request.limit } : {}),
606
+ },
607
+ timestamp: Date.now(),
608
+ });
609
+ const result = await client.waitFor(RoomServerMessageType.DM_PINS, (message) => message.payload.requestId === requestId, timeoutMs, requestId);
610
+ return result.payload;
530
611
  }
531
612
  export async function peekRoom(config, options) {
532
613
  if (!config.webSocketCtor) {
@@ -641,7 +722,7 @@ export function attachRejoinOnReconnect(client, options, latestSeq, onRejoined,
641
722
  * (1..128 chars after trim); dedupe on (userId, clientMsgId) is server-side
642
723
  * work and NOT implied here.
643
724
  */
644
- export function postRoomMessage(client, room, text, inReplyTo, attachments, topicId, clientMsgId) {
725
+ export function postRoomMessage(client, room, text, inReplyTo, attachments, topicId, clientMsgId, metadata) {
645
726
  const msgId = clientMsgId ?? randomUUID();
646
727
  client.send({
647
728
  type: RoomClientMessageType.POST,
@@ -652,6 +733,7 @@ export function postRoomMessage(client, room, text, inReplyTo, attachments, topi
652
733
  clientMsgId: msgId,
653
734
  ...(inReplyTo !== undefined ? { inReplyTo } : {}),
654
735
  ...(topicId ? { topicId } : {}),
736
+ ...(metadata ? { metadata } : {}),
655
737
  },
656
738
  timestamp: Date.now(),
657
739
  });
@@ -37,10 +37,14 @@ export declare const RoomClientMessageType: {
37
37
  readonly DM_OPEN: "DM_OPEN";
38
38
  readonly DM_HISTORY: "DM_HISTORY";
39
39
  readonly DM_SEND: "DM_SEND";
40
+ readonly DM_REPLY: "DM_REPLY";
40
41
  readonly DM_READ: "DM_READ";
41
42
  readonly DM_EDIT: "DM_EDIT";
42
43
  readonly DM_DELETE: "DM_DELETE";
43
44
  readonly DM_REACT: "DM_REACT";
45
+ readonly DM_PIN: "DM_PIN";
46
+ readonly DM_UNPIN: "DM_UNPIN";
47
+ readonly DM_PINS: "DM_PINS";
44
48
  readonly TOPIC_CREATE: "TOPIC_CREATE";
45
49
  readonly TOPIC_RENAME: "TOPIC_RENAME";
46
50
  readonly TOPIC_MOVE: "TOPIC_MOVE";
@@ -94,6 +98,8 @@ export declare const RoomServerMessageType: {
94
98
  readonly DM_MESSAGE_EDITED: "DM_MESSAGE_EDITED";
95
99
  readonly DM_MESSAGE_DELETED: "DM_MESSAGE_DELETED";
96
100
  readonly DM_REACTION_UPDATED: "DM_REACTION_UPDATED";
101
+ readonly DM_PIN_UPDATED: "DM_PIN_UPDATED";
102
+ readonly DM_PINS: "DM_PINS";
97
103
  readonly TOPIC: "TOPIC";
98
104
  readonly TOPICS: "TOPICS";
99
105
  readonly TOPIC_UPDATED: "TOPIC_UPDATED";
@@ -225,6 +231,19 @@ export interface RoomLedgerEntry {
225
231
  pinnedAt?: string | number | Date | null | undefined;
226
232
  attachments?: RoomAttachment[] | undefined;
227
233
  roomRole?: RoomRole | null | undefined;
234
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
235
+ }
236
+ export type RoomForwardedFrom = {
237
+ kind: "room";
238
+ room: string;
239
+ seq: number;
240
+ handle: string;
241
+ } | {
242
+ kind: "dm";
243
+ handle: string;
244
+ };
245
+ export interface RoomForwardMetadata {
246
+ forwardedFrom: RoomForwardedFrom;
228
247
  }
229
248
  export interface RoomAttachment {
230
249
  /**
@@ -472,12 +491,14 @@ export interface RoomDmMessage {
472
491
  createdAt?: string | number | undefined;
473
492
  /** Sealed DM: content is '' and the opaque envelope rides here. */
474
493
  secret?: RoomLedgerSecretPayload | null | undefined;
475
- metadata?: Record<string, unknown> | undefined;
494
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
476
495
  /** Legacy public chat-image URL (renders as-is, no signing). */
477
496
  imageUrl?: string | null | undefined;
478
497
  /** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
479
498
  * Additive: old messages carry none and render unchanged. */
480
499
  attachments?: RoomAttachment[] | undefined;
500
+ /** Shared pin state. Legacy messages omit it and read as unpinned. */
501
+ pinned?: boolean | undefined;
481
502
  }
482
503
  export interface RoomDmOpenPayload {
483
504
  requestId?: string;
@@ -502,6 +523,11 @@ export interface RoomDmSendPayload {
502
523
  /** DM-attachment key or legacy public image URL; stored on the message's
503
524
  * imageUrl. Additive — text-only sends omit it. */
504
525
  imageUrl?: string;
526
+ metadata?: RoomForwardMetadata | undefined;
527
+ }
528
+ /** Client → server: send a DM whose parent is another message in the same conversation. */
529
+ export interface RoomDmReplyPayload extends RoomDmSendPayload {
530
+ replyTo: string;
505
531
  }
506
532
  /** Client → server: edit one of my DM messages (sealed messages are rejected). */
507
533
  export interface RoomDmEditPayload {
@@ -520,6 +546,7 @@ export interface RoomDmDeletePayload {
520
546
  }
521
547
  /** Server → both participants: a DM message was edited. */
522
548
  export interface RoomDmMessageEditedPayload {
549
+ requestId?: string;
523
550
  conversationId: string;
524
551
  messageId: string;
525
552
  content: string;
@@ -528,6 +555,7 @@ export interface RoomDmMessageEditedPayload {
528
555
  }
529
556
  /** Server → both participants: a DM message was soft-deleted (tombstone). */
530
557
  export interface RoomDmMessageDeletedPayload {
558
+ requestId?: string;
531
559
  conversationId: string;
532
560
  messageId: string;
533
561
  deletedAt: string | number;
@@ -544,6 +572,7 @@ export interface RoomDmReactPayload {
544
572
  }
545
573
  /** Server → both participants: a DM message's aggregated reactions changed. */
546
574
  export interface RoomDmReactionUpdatedPayload {
575
+ requestId?: string;
547
576
  conversationId: string;
548
577
  messageId: string;
549
578
  reactions: Array<{
@@ -552,6 +581,31 @@ export interface RoomDmReactionUpdatedPayload {
552
581
  }>;
553
582
  participant: RoomDmParticipant;
554
583
  }
584
+ export interface RoomDmPinPayload {
585
+ requestId?: string;
586
+ conversationId?: string;
587
+ username?: string;
588
+ messageId: string;
589
+ }
590
+ export interface RoomDmPinsPayload {
591
+ requestId?: string;
592
+ conversationId?: string;
593
+ username?: string;
594
+ limit?: number;
595
+ }
596
+ export interface RoomDmPinUpdatedPayload {
597
+ requestId?: string;
598
+ conversationId: string;
599
+ participant: RoomDmParticipant;
600
+ messageId: string;
601
+ pinned: boolean;
602
+ }
603
+ export interface RoomDmPinsResultPayload {
604
+ requestId?: string;
605
+ conversationId: string;
606
+ participant: RoomDmParticipant;
607
+ pins: RoomDmMessage[];
608
+ }
555
609
  export interface RoomDmReadPayload {
556
610
  requestId?: string;
557
611
  username?: string;
@@ -889,6 +943,7 @@ export type RoomClientMessage = {
889
943
  clientMsgId?: string;
890
944
  reason?: string;
891
945
  requestId?: string;
946
+ metadata?: RoomForwardMetadata;
892
947
  };
893
948
  timestamp?: number;
894
949
  } | {
@@ -1074,6 +1129,10 @@ export type RoomClientMessage = {
1074
1129
  type: typeof RoomClientMessageType.DM_SEND;
1075
1130
  payload: RoomDmSendPayload;
1076
1131
  timestamp?: number;
1132
+ } | {
1133
+ type: typeof RoomClientMessageType.DM_REPLY;
1134
+ payload: RoomDmReplyPayload;
1135
+ timestamp?: number;
1077
1136
  } | {
1078
1137
  type: typeof RoomClientMessageType.DM_READ;
1079
1138
  payload: RoomDmReadPayload;
@@ -1090,6 +1149,18 @@ export type RoomClientMessage = {
1090
1149
  type: typeof RoomClientMessageType.DM_REACT;
1091
1150
  payload: RoomDmReactPayload;
1092
1151
  timestamp?: number;
1152
+ } | {
1153
+ type: typeof RoomClientMessageType.DM_PIN;
1154
+ payload: RoomDmPinPayload;
1155
+ timestamp?: number;
1156
+ } | {
1157
+ type: typeof RoomClientMessageType.DM_UNPIN;
1158
+ payload: RoomDmPinPayload;
1159
+ timestamp?: number;
1160
+ } | {
1161
+ type: typeof RoomClientMessageType.DM_PINS;
1162
+ payload: RoomDmPinsPayload;
1163
+ timestamp?: number;
1093
1164
  } | {
1094
1165
  type: typeof RoomClientMessageType.TOPIC_CREATE;
1095
1166
  payload: RoomTopicCreatePayload;
@@ -1296,6 +1367,14 @@ export type RoomServerMessage = {
1296
1367
  type: typeof RoomServerMessageType.DM_REACTION_UPDATED;
1297
1368
  payload: RoomDmReactionUpdatedPayload;
1298
1369
  timestamp: number;
1370
+ } | {
1371
+ type: typeof RoomServerMessageType.DM_PIN_UPDATED;
1372
+ payload: RoomDmPinUpdatedPayload;
1373
+ timestamp: number;
1374
+ } | {
1375
+ type: typeof RoomServerMessageType.DM_PINS;
1376
+ payload: RoomDmPinsResultPayload;
1377
+ timestamp: number;
1299
1378
  } | {
1300
1379
  type: typeof RoomServerMessageType.TOPIC;
1301
1380
  payload: RoomTopicResultPayload;
@@ -37,10 +37,14 @@ export const RoomClientMessageType = {
37
37
  DM_OPEN: "DM_OPEN",
38
38
  DM_HISTORY: "DM_HISTORY",
39
39
  DM_SEND: "DM_SEND",
40
+ DM_REPLY: "DM_REPLY",
40
41
  DM_READ: "DM_READ",
41
42
  DM_EDIT: "DM_EDIT",
42
43
  DM_DELETE: "DM_DELETE",
43
44
  DM_REACT: "DM_REACT",
45
+ DM_PIN: "DM_PIN",
46
+ DM_UNPIN: "DM_UNPIN",
47
+ DM_PINS: "DM_PINS",
44
48
  TOPIC_CREATE: "TOPIC_CREATE",
45
49
  TOPIC_RENAME: "TOPIC_RENAME",
46
50
  TOPIC_MOVE: "TOPIC_MOVE",
@@ -93,6 +97,8 @@ export const RoomServerMessageType = {
93
97
  DM_MESSAGE_EDITED: "DM_MESSAGE_EDITED",
94
98
  DM_MESSAGE_DELETED: "DM_MESSAGE_DELETED",
95
99
  DM_REACTION_UPDATED: "DM_REACTION_UPDATED",
100
+ DM_PIN_UPDATED: "DM_PIN_UPDATED",
101
+ DM_PINS: "DM_PINS",
96
102
  TOPIC: "TOPIC",
97
103
  TOPICS: "TOPICS",
98
104
  TOPIC_UPDATED: "TOPIC_UPDATED",
@@ -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>;
@@ -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;
@@ -130,6 +130,8 @@ export interface RoomDmMessage {
130
130
  emoji: string;
131
131
  users: string[];
132
132
  }>;
133
+ /** Shared pin state. Additive; absent means unpinned. */
134
+ pinned?: boolean;
133
135
  /** Client-minted idempotency tag echoed back on DM sends, so a sender's
134
136
  * durable queue can settle its optimistic row. Additive (0.4.0). */
135
137
  clientMsgId?: string;
package/dist/types.d.ts CHANGED
@@ -1,2 +1,2 @@
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";
1
+ export type { RoomAccessMode, RoomAttachment, RoomAttachmentInput, RoomAuditEntry, RoomAuditLogPayload, RoomAuditLogRequestPayload, RoomAuthSuccessPayload, RoomBackfillPayload, RoomClientMessage, RoomClientPlatform, RoomCreatedPayload, RoomCreatePayload, RoomDmHistoryPayload, RoomDmHistoryResultPayload, RoomDmInboxUpdatedPayload, RoomDmMessage, RoomDmMessageDeletedPayload, RoomDmMessageEditedPayload, RoomDmMessagePayload, RoomDmOpenedPayload, RoomDmOpenPayload, RoomDmParticipant, RoomDmPinsResultPayload, RoomDmPinUpdatedPayload, RoomDmReactionUpdatedPayload, RoomDmReadPayload, RoomDmReplyPayload, RoomDmSendPayload, RoomEntryUpdatedPayload, RoomErrorPayload, RoomForwardedFrom, RoomForwardMetadata, 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/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.6.0";
2
- export declare const RUNNER_VERSION = "0.6.0";
1
+ export declare const VERSION = "0.7.0";
2
+ export declare const RUNNER_VERSION = "0.7.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.6.0";
2
- export const RUNNER_VERSION = "0.6.0";
1
+ export const VERSION = "0.7.0";
2
+ export const RUNNER_VERSION = "0.7.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmarket/rooms-client",
3
- "version": "0.4.1",
3
+ "version": "0.5.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",
@@ -17,7 +17,12 @@
17
17
  "bun": ">=1.3.0",
18
18
  "node": ">=22"
19
19
  },
20
- "files": ["src", "dist", "README.md", "LICENSE"],
20
+ "files": [
21
+ "src",
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
21
26
  "publishConfig": {
22
27
  "access": "public"
23
28
  },
@@ -186,6 +186,30 @@ export class SidebarModel {
186
186
  };
187
187
  }
188
188
 
189
+ /** Apply a same-user realtime DM counter without refetching the full room
190
+ * directory. Username is the stable DM key used by the sidebar model. */
191
+ applyDmUnread(username: string, unread: number): void {
192
+ const key = normalizeDmUsername(username);
193
+ if (!key) return;
194
+ const normalizedUnread = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0;
195
+ let found = false;
196
+ let changed = false;
197
+ const next = this.snapshotValue.dms.map((dm) => {
198
+ if (normalizeDmUsername(dm.username) !== key) return dm;
199
+ found = true;
200
+ if (dm.unread === normalizedUnread) return dm;
201
+ changed = true;
202
+ return { ...dm, unread: normalizedUnread };
203
+ });
204
+ if (!found && normalizedUnread > 0) {
205
+ next.push({ username: username.replace(/^@+/, ""), unread: normalizedUnread });
206
+ changed = true;
207
+ }
208
+ if (!changed) return;
209
+ this.snapshotValue = { ...this.snapshotValue, dms: next };
210
+ for (const listener of this.listeners) listener();
211
+ }
212
+
189
213
  /**
190
214
  * Post-mutation refresh: if a poll-triggered load is already in flight it
191
215
  * may predate the caller's writes, so wait it out and load AGAIN. Readers
@@ -233,3 +257,7 @@ export class SidebarModel {
233
257
  });
234
258
  }
235
259
  }
260
+
261
+ function normalizeDmUsername(username: string): string {
262
+ return username.trim().replace(/^@+/, "").toLowerCase();
263
+ }
package/src/client.ts CHANGED
@@ -16,8 +16,13 @@ import {
16
16
  type RoomClientPlatform,
17
17
  type RoomDmHistoryResultPayload,
18
18
  type RoomDmInboxUpdatedPayload,
19
+ type RoomDmMessageDeletedPayload,
20
+ type RoomDmMessageEditedPayload,
19
21
  type RoomDmMessagePayload,
20
22
  type RoomDmOpenedPayload,
23
+ type RoomDmPinsResultPayload,
24
+ type RoomDmReactionUpdatedPayload,
25
+ type RoomForwardMetadata,
21
26
  type RoomFriendPresence,
22
27
  type RoomInvitedPayload,
23
28
  type RoomLedgerEntry,
@@ -292,6 +297,11 @@ export interface RoomDmSendRequest {
292
297
  * URL. Stored on the message's imageUrl; the client signs keys on render.
293
298
  * Additive — omitted for text-only sends. */
294
299
  imageUrl?: string;
300
+ metadata?: RoomForwardMetadata;
301
+ }
302
+
303
+ export interface RoomDmReplyRequest extends RoomDmSendRequest {
304
+ replyTo: string;
295
305
  }
296
306
 
297
307
  export interface RoomDmReadRequest {
@@ -433,9 +443,7 @@ export async function inviteRoom(
433
443
  ): Promise<RoomInviteResult> {
434
444
  // `@user` and `user` are the same person: strip the typing syntax once,
435
445
  // before either transport (the relay resolves the bare name).
436
- const normalized: RoomInviteRequest = request.username
437
- ? { ...request, username: request.username.trim().replace(/^@+/, "") }
438
- : request;
446
+ const normalized = normalizeRoomInviteRequest(request);
439
447
  if (!config.webSocketCtor) {
440
448
  try {
441
449
  return await inviteRoomViaHttp(config, normalized);
@@ -453,29 +461,46 @@ async function inviteRoomViaWebSocket(
453
461
  const client = new RoomWsClient(config);
454
462
  try {
455
463
  const auth = await client.connect();
456
- const requestId = randomUUID();
457
- client.send({
458
- type: RoomClientMessageType.INVITE,
459
- payload: {
460
- requestId,
461
- room: request.room,
462
- ...(request.username ? { username: request.username } : {}),
463
- ...(request.userId ? { userId: request.userId } : {}),
464
- },
465
- timestamp: Date.now(),
466
- });
467
- const invited = await client.waitFor(
468
- RoomServerMessageType.ROOM_INVITED,
469
- (msg) => msg.payload.requestId === requestId,
470
- undefined,
471
- requestId,
472
- );
473
- return { auth, invite: invited.payload };
464
+ return { auth, invite: await inviteRoomOnClient(client, request) };
474
465
  } finally {
475
466
  client.close();
476
467
  }
477
468
  }
478
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
+
479
504
  export async function removeRoomMember(
480
505
  config: RoomClientConfig,
481
506
  request: RoomRemoveMemberRequest,
@@ -969,6 +994,7 @@ export async function sendRoomDmOnClient(
969
994
  ...(request.username ? { username: request.username } : {}),
970
995
  ...(request.conversationId ? { conversationId: request.conversationId } : {}),
971
996
  ...(request.imageUrl ? { imageUrl: request.imageUrl } : {}),
997
+ ...(request.metadata ? { metadata: request.metadata } : {}),
972
998
  },
973
999
  timestamp: Date.now(),
974
1000
  });
@@ -984,15 +1010,50 @@ export async function sendRoomDmOnClient(
984
1010
  return sent.payload;
985
1011
  }
986
1012
 
987
- /** Edit one of my DM messages. Fire-and-forget over the WS; the relay fans out
988
- * DM_MESSAGE_EDITED to both participants. */
989
- export function editRoomDmOnClient(
1013
+ export async function replyRoomDmOnClient(
1014
+ client: RoomWsClient,
1015
+ request: RoomDmReplyRequest,
1016
+ timeoutMs?: number,
1017
+ ): Promise<RoomDmMessagePayload> {
1018
+ const requestId = randomUUID();
1019
+ const clientMsgId = request.clientMsgId ?? randomUUID();
1020
+ client.send({
1021
+ type: RoomClientMessageType.DM_REPLY,
1022
+ payload: {
1023
+ requestId,
1024
+ content: request.content,
1025
+ replyTo: request.replyTo,
1026
+ clientMsgId,
1027
+ ...(request.username ? { username: request.username } : {}),
1028
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
1029
+ ...(request.imageUrl ? { imageUrl: request.imageUrl } : {}),
1030
+ ...(request.metadata ? { metadata: request.metadata } : {}),
1031
+ },
1032
+ timestamp: Date.now(),
1033
+ });
1034
+ const sent = await client.waitFor(
1035
+ RoomServerMessageType.DM_MESSAGE,
1036
+ (msg) =>
1037
+ msg.payload.requestId === requestId ||
1038
+ msg.payload.clientMsgId === clientMsgId ||
1039
+ msg.payload.message.clientMsgId === clientMsgId,
1040
+ timeoutMs,
1041
+ requestId,
1042
+ );
1043
+ return sent.payload;
1044
+ }
1045
+
1046
+ /** Edit one of my DM messages and wait for the correlated relay update. */
1047
+ export async function editRoomDmOnClient(
990
1048
  client: RoomWsClient,
991
1049
  request: { conversationId?: string; username?: string; messageId: string; content: string },
992
- ): void {
1050
+ timeoutMs?: number,
1051
+ ): Promise<RoomDmMessageEditedPayload> {
1052
+ const requestId = randomUUID();
993
1053
  client.send({
994
1054
  type: RoomClientMessageType.DM_EDIT,
995
1055
  payload: {
1056
+ requestId,
996
1057
  messageId: request.messageId,
997
1058
  content: request.content,
998
1059
  ...(request.conversationId ? { conversationId: request.conversationId } : {}),
@@ -1000,26 +1061,43 @@ export function editRoomDmOnClient(
1000
1061
  },
1001
1062
  timestamp: Date.now(),
1002
1063
  });
1064
+ const updated = await client.waitFor(
1065
+ RoomServerMessageType.DM_MESSAGE_EDITED,
1066
+ (msg) => msg.payload.requestId === requestId,
1067
+ timeoutMs,
1068
+ requestId,
1069
+ );
1070
+ return updated.payload;
1003
1071
  }
1004
1072
 
1005
1073
  /** Soft-delete one of my DM messages. The relay fans out DM_MESSAGE_DELETED. */
1006
- export function deleteRoomDmOnClient(
1074
+ export async function deleteRoomDmOnClient(
1007
1075
  client: RoomWsClient,
1008
1076
  request: { conversationId?: string; username?: string; messageId: string },
1009
- ): void {
1077
+ timeoutMs?: number,
1078
+ ): Promise<RoomDmMessageDeletedPayload> {
1079
+ const requestId = randomUUID();
1010
1080
  client.send({
1011
1081
  type: RoomClientMessageType.DM_DELETE,
1012
1082
  payload: {
1083
+ requestId,
1013
1084
  messageId: request.messageId,
1014
1085
  ...(request.conversationId ? { conversationId: request.conversationId } : {}),
1015
1086
  ...(request.username ? { username: request.username } : {}),
1016
1087
  },
1017
1088
  timestamp: Date.now(),
1018
1089
  });
1090
+ const updated = await client.waitFor(
1091
+ RoomServerMessageType.DM_MESSAGE_DELETED,
1092
+ (msg) => msg.payload.requestId === requestId,
1093
+ timeoutMs,
1094
+ requestId,
1095
+ );
1096
+ return updated.payload;
1019
1097
  }
1020
1098
 
1021
1099
  /** Toggle a reaction on a DM message. The relay fans out DM_REACTION_UPDATED. */
1022
- export function reactRoomDmOnClient(
1100
+ export async function reactRoomDmOnClient(
1023
1101
  client: RoomWsClient,
1024
1102
  request: {
1025
1103
  conversationId?: string;
@@ -1028,10 +1106,13 @@ export function reactRoomDmOnClient(
1028
1106
  emoji: string;
1029
1107
  op: "add" | "remove";
1030
1108
  },
1031
- ): void {
1109
+ timeoutMs?: number,
1110
+ ): Promise<RoomDmReactionUpdatedPayload> {
1111
+ const requestId = randomUUID();
1032
1112
  client.send({
1033
1113
  type: RoomClientMessageType.DM_REACT,
1034
1114
  payload: {
1115
+ requestId,
1035
1116
  messageId: request.messageId,
1036
1117
  emoji: request.emoji,
1037
1118
  op: request.op,
@@ -1040,6 +1121,69 @@ export function reactRoomDmOnClient(
1040
1121
  },
1041
1122
  timestamp: Date.now(),
1042
1123
  });
1124
+ const updated = await client.waitFor(
1125
+ RoomServerMessageType.DM_REACTION_UPDATED,
1126
+ (msg) => msg.payload.requestId === requestId,
1127
+ timeoutMs,
1128
+ requestId,
1129
+ );
1130
+ return updated.payload;
1131
+ }
1132
+
1133
+ /** Set shared pin state for a DM message. The relay fans out DM_PIN_UPDATED. */
1134
+ export function pinRoomDmOnClient(
1135
+ client: RoomWsClient,
1136
+ request: { conversationId?: string; username?: string; messageId: string },
1137
+ ): void {
1138
+ client.send({
1139
+ type: RoomClientMessageType.DM_PIN,
1140
+ payload: {
1141
+ messageId: request.messageId,
1142
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
1143
+ ...(request.username ? { username: request.username } : {}),
1144
+ },
1145
+ timestamp: Date.now(),
1146
+ });
1147
+ }
1148
+
1149
+ export function unpinRoomDmOnClient(
1150
+ client: RoomWsClient,
1151
+ request: { conversationId?: string; username?: string; messageId: string },
1152
+ ): void {
1153
+ client.send({
1154
+ type: RoomClientMessageType.DM_UNPIN,
1155
+ payload: {
1156
+ messageId: request.messageId,
1157
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
1158
+ ...(request.username ? { username: request.username } : {}),
1159
+ },
1160
+ timestamp: Date.now(),
1161
+ });
1162
+ }
1163
+
1164
+ export async function requestRoomDmPinsOnClient(
1165
+ client: RoomWsClient,
1166
+ request: { conversationId?: string; username?: string; limit?: number },
1167
+ timeoutMs?: number,
1168
+ ): Promise<RoomDmPinsResultPayload> {
1169
+ const requestId = randomUUID();
1170
+ client.send({
1171
+ type: RoomClientMessageType.DM_PINS,
1172
+ payload: {
1173
+ requestId,
1174
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
1175
+ ...(request.username ? { username: request.username } : {}),
1176
+ ...(request.limit ? { limit: request.limit } : {}),
1177
+ },
1178
+ timestamp: Date.now(),
1179
+ });
1180
+ const result = await client.waitFor(
1181
+ RoomServerMessageType.DM_PINS,
1182
+ (message) => message.payload.requestId === requestId,
1183
+ timeoutMs,
1184
+ requestId,
1185
+ );
1186
+ return result.payload;
1043
1187
  }
1044
1188
 
1045
1189
  export async function peekRoom(
@@ -1195,6 +1339,7 @@ export function postRoomMessage(
1195
1339
  attachments?: RoomAttachmentInput[],
1196
1340
  topicId?: string,
1197
1341
  clientMsgId?: string,
1342
+ metadata?: RoomForwardMetadata,
1198
1343
  ): string {
1199
1344
  const msgId = clientMsgId ?? randomUUID();
1200
1345
  client.send({
@@ -1206,6 +1351,7 @@ export function postRoomMessage(
1206
1351
  clientMsgId: msgId,
1207
1352
  ...(inReplyTo !== undefined ? { inReplyTo } : {}),
1208
1353
  ...(topicId ? { topicId } : {}),
1354
+ ...(metadata ? { metadata } : {}),
1209
1355
  },
1210
1356
  timestamp: Date.now(),
1211
1357
  });
@@ -37,10 +37,14 @@ export const RoomClientMessageType = {
37
37
  DM_OPEN: "DM_OPEN",
38
38
  DM_HISTORY: "DM_HISTORY",
39
39
  DM_SEND: "DM_SEND",
40
+ DM_REPLY: "DM_REPLY",
40
41
  DM_READ: "DM_READ",
41
42
  DM_EDIT: "DM_EDIT",
42
43
  DM_DELETE: "DM_DELETE",
43
44
  DM_REACT: "DM_REACT",
45
+ DM_PIN: "DM_PIN",
46
+ DM_UNPIN: "DM_UNPIN",
47
+ DM_PINS: "DM_PINS",
44
48
  TOPIC_CREATE: "TOPIC_CREATE",
45
49
  TOPIC_RENAME: "TOPIC_RENAME",
46
50
  TOPIC_MOVE: "TOPIC_MOVE",
@@ -97,6 +101,8 @@ export const RoomServerMessageType = {
97
101
  DM_MESSAGE_EDITED: "DM_MESSAGE_EDITED",
98
102
  DM_MESSAGE_DELETED: "DM_MESSAGE_DELETED",
99
103
  DM_REACTION_UPDATED: "DM_REACTION_UPDATED",
104
+ DM_PIN_UPDATED: "DM_PIN_UPDATED",
105
+ DM_PINS: "DM_PINS",
100
106
  TOPIC: "TOPIC",
101
107
  TOPICS: "TOPICS",
102
108
  TOPIC_UPDATED: "TOPIC_UPDATED",
@@ -253,6 +259,23 @@ export interface RoomLedgerEntry {
253
259
  pinnedAt?: string | number | Date | null | undefined;
254
260
  attachments?: RoomAttachment[] | undefined;
255
261
  roomRole?: RoomRole | null | undefined;
262
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
263
+ }
264
+
265
+ export type RoomForwardedFrom =
266
+ | {
267
+ kind: "room";
268
+ room: string;
269
+ seq: number;
270
+ handle: string;
271
+ }
272
+ | {
273
+ kind: "dm";
274
+ handle: string;
275
+ };
276
+
277
+ export interface RoomForwardMetadata {
278
+ forwardedFrom: RoomForwardedFrom;
256
279
  }
257
280
 
258
281
  export interface RoomAttachment {
@@ -560,12 +583,14 @@ export interface RoomDmMessage {
560
583
  createdAt?: string | number | undefined;
561
584
  /** Sealed DM: content is '' and the opaque envelope rides here. */
562
585
  secret?: RoomLedgerSecretPayload | null | undefined;
563
- metadata?: Record<string, unknown> | undefined;
586
+ metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
564
587
  /** Legacy public chat-image URL (renders as-is, no signing). */
565
588
  imageUrl?: string | null | undefined;
566
589
  /** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
567
590
  * Additive: old messages carry none and render unchanged. */
568
591
  attachments?: RoomAttachment[] | undefined;
592
+ /** Shared pin state. Legacy messages omit it and read as unpinned. */
593
+ pinned?: boolean | undefined;
569
594
  }
570
595
 
571
596
  export interface RoomDmOpenPayload {
@@ -593,6 +618,12 @@ export interface RoomDmSendPayload {
593
618
  /** DM-attachment key or legacy public image URL; stored on the message's
594
619
  * imageUrl. Additive — text-only sends omit it. */
595
620
  imageUrl?: string;
621
+ metadata?: RoomForwardMetadata | undefined;
622
+ }
623
+
624
+ /** Client → server: send a DM whose parent is another message in the same conversation. */
625
+ export interface RoomDmReplyPayload extends RoomDmSendPayload {
626
+ replyTo: string;
596
627
  }
597
628
 
598
629
  /** Client → server: edit one of my DM messages (sealed messages are rejected). */
@@ -614,6 +645,7 @@ export interface RoomDmDeletePayload {
614
645
 
615
646
  /** Server → both participants: a DM message was edited. */
616
647
  export interface RoomDmMessageEditedPayload {
648
+ requestId?: string;
617
649
  conversationId: string;
618
650
  messageId: string;
619
651
  content: string;
@@ -623,6 +655,7 @@ export interface RoomDmMessageEditedPayload {
623
655
 
624
656
  /** Server → both participants: a DM message was soft-deleted (tombstone). */
625
657
  export interface RoomDmMessageDeletedPayload {
658
+ requestId?: string;
626
659
  conversationId: string;
627
660
  messageId: string;
628
661
  deletedAt: string | number;
@@ -641,12 +674,42 @@ export interface RoomDmReactPayload {
641
674
 
642
675
  /** Server → both participants: a DM message's aggregated reactions changed. */
643
676
  export interface RoomDmReactionUpdatedPayload {
677
+ requestId?: string;
644
678
  conversationId: string;
645
679
  messageId: string;
646
680
  reactions: Array<{ emoji: string; users: string[] }>;
647
681
  participant: RoomDmParticipant;
648
682
  }
649
683
 
684
+ export interface RoomDmPinPayload {
685
+ requestId?: string;
686
+ conversationId?: string;
687
+ username?: string;
688
+ messageId: string;
689
+ }
690
+
691
+ export interface RoomDmPinsPayload {
692
+ requestId?: string;
693
+ conversationId?: string;
694
+ username?: string;
695
+ limit?: number;
696
+ }
697
+
698
+ export interface RoomDmPinUpdatedPayload {
699
+ requestId?: string;
700
+ conversationId: string;
701
+ participant: RoomDmParticipant;
702
+ messageId: string;
703
+ pinned: boolean;
704
+ }
705
+
706
+ export interface RoomDmPinsResultPayload {
707
+ requestId?: string;
708
+ conversationId: string;
709
+ participant: RoomDmParticipant;
710
+ pins: RoomDmMessage[];
711
+ }
712
+
650
713
  export interface RoomDmReadPayload {
651
714
  requestId?: string;
652
715
  username?: string;
@@ -1014,6 +1077,7 @@ export type RoomClientMessage =
1014
1077
  clientMsgId?: string;
1015
1078
  reason?: string;
1016
1079
  requestId?: string;
1080
+ metadata?: RoomForwardMetadata;
1017
1081
  };
1018
1082
  timestamp?: number;
1019
1083
  }
@@ -1189,6 +1253,11 @@ export type RoomClientMessage =
1189
1253
  payload: RoomDmSendPayload;
1190
1254
  timestamp?: number;
1191
1255
  }
1256
+ | {
1257
+ type: typeof RoomClientMessageType.DM_REPLY;
1258
+ payload: RoomDmReplyPayload;
1259
+ timestamp?: number;
1260
+ }
1192
1261
  | {
1193
1262
  type: typeof RoomClientMessageType.DM_READ;
1194
1263
  payload: RoomDmReadPayload;
@@ -1209,6 +1278,21 @@ export type RoomClientMessage =
1209
1278
  payload: RoomDmReactPayload;
1210
1279
  timestamp?: number;
1211
1280
  }
1281
+ | {
1282
+ type: typeof RoomClientMessageType.DM_PIN;
1283
+ payload: RoomDmPinPayload;
1284
+ timestamp?: number;
1285
+ }
1286
+ | {
1287
+ type: typeof RoomClientMessageType.DM_UNPIN;
1288
+ payload: RoomDmPinPayload;
1289
+ timestamp?: number;
1290
+ }
1291
+ | {
1292
+ type: typeof RoomClientMessageType.DM_PINS;
1293
+ payload: RoomDmPinsPayload;
1294
+ timestamp?: number;
1295
+ }
1212
1296
  | {
1213
1297
  type: typeof RoomClientMessageType.TOPIC_CREATE;
1214
1298
  payload: RoomTopicCreatePayload;
@@ -1461,6 +1545,16 @@ export type RoomServerMessage =
1461
1545
  payload: RoomDmReactionUpdatedPayload;
1462
1546
  timestamp: number;
1463
1547
  }
1548
+ | {
1549
+ type: typeof RoomServerMessageType.DM_PIN_UPDATED;
1550
+ payload: RoomDmPinUpdatedPayload;
1551
+ timestamp: number;
1552
+ }
1553
+ | {
1554
+ type: typeof RoomServerMessageType.DM_PINS;
1555
+ payload: RoomDmPinsResultPayload;
1556
+ timestamp: number;
1557
+ }
1464
1558
  | {
1465
1559
  type: typeof RoomServerMessageType.TOPIC;
1466
1560
  payload: RoomTopicResultPayload;
@@ -1044,6 +1044,18 @@ export async function removeSpaceMember(
1044
1044
  );
1045
1045
  }
1046
1046
 
1047
+ /** Leave a server as the authenticated caller. This is deliberately separate
1048
+ * from the manager-only member-removal route so clients never send their own
1049
+ * userId as authorization data. */
1050
+ export async function leaveSpace(
1051
+ config: RoomSocialConfig,
1052
+ spaceId: string,
1053
+ ): Promise<{ left?: boolean }> {
1054
+ return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/membership`, {
1055
+ method: "DELETE",
1056
+ });
1057
+ }
1058
+
1047
1059
  export async function changeSpaceMemberRole(
1048
1060
  config: RoomSocialConfig,
1049
1061
  spaceId: string,
@@ -144,6 +144,8 @@ 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
+ /** Shared pin state. Additive; absent means unpinned. */
148
+ pinned?: boolean;
147
149
  /** Client-minted idempotency tag echoed back on DM sends, so a sender's
148
150
  * durable queue can settle its optimistic row. Additive (0.4.0). */
149
151
  clientMsgId?: string;
package/src/types.ts CHANGED
@@ -15,14 +15,22 @@ export type {
15
15
  RoomDmHistoryResultPayload,
16
16
  RoomDmInboxUpdatedPayload,
17
17
  RoomDmMessage,
18
+ RoomDmMessageDeletedPayload,
19
+ RoomDmMessageEditedPayload,
18
20
  RoomDmMessagePayload,
19
21
  RoomDmOpenedPayload,
20
22
  RoomDmOpenPayload,
21
23
  RoomDmParticipant,
24
+ RoomDmPinsResultPayload,
25
+ RoomDmPinUpdatedPayload,
26
+ RoomDmReactionUpdatedPayload,
22
27
  RoomDmReadPayload,
28
+ RoomDmReplyPayload,
23
29
  RoomDmSendPayload,
24
30
  RoomEntryUpdatedPayload,
25
31
  RoomErrorPayload,
32
+ RoomForwardedFrom,
33
+ RoomForwardMetadata,
26
34
  RoomFriendPresence,
27
35
  RoomInvitedPayload,
28
36
  RoomInvitePayload,
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.6.0";
2
- export const RUNNER_VERSION = "0.6.0";
1
+ export const VERSION = "0.7.0";
2
+ export const RUNNER_VERSION = "0.7.0";