@openmarket/rooms-client 0.1.0 → 0.3.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.
@@ -89,18 +89,35 @@ export interface ArmedRoomState {
89
89
  }
90
90
  /** A fresh armed state for a newly armed room. */
91
91
  export declare function makeArmedRoomState(roomId: string, lastSeq?: number): ArmedRoomState;
92
+ /**
93
+ * A fresh armed state that CARRIES the cost/rate counters from a prior armed
94
+ * session of the same room. Disarm/rearm must not zero the cooldown, hourly
95
+ * window, or session budget: otherwise toggling /disarm + /arm bypasses every
96
+ * cost guard. Error and chain counters DO reset (rearming is an explicit
97
+ * operator action; a fresh process still starts with nothing retained).
98
+ */
99
+ export declare function rearmedRoomState(roomId: string, retired: ArmedRoomState | null, lastSeq?: number): ArmedRoomState;
100
+ /** Seqs of the user's own posts in a window of entries; the reply-trigger
101
+ * check tests `inReplyTo` against this set. Pure. */
102
+ export declare function ownSeqsOf(entries: ReadonlyArray<RoomLedgerEntry>, ownUserId: string): Set<number>;
92
103
  /**
93
104
  * Whether a single incoming entry is a qualifying TRIGGER: a NEW human POST in
94
- * the room that @mentions the user's own handle. Agent posts, system entries,
95
- * the user's own posts, and non-mentions never trigger. Pure.
105
+ * the room that @mentions the user's own handle, OR (when `ownSeqs` is given)
106
+ * directly replies to one of the user's own messages. Reply semantics match a
107
+ * mention (replying pings the author), so armed mode treats both as addressed
108
+ * to the user. Agent posts, system entries, the user's own posts, and
109
+ * non-mentions never trigger. Pure.
96
110
  */
97
- export declare function isTriggeringEntry(entry: RoomLedgerEntry, ownHandle: string, ownUserId: string): boolean;
111
+ export declare function isTriggeringEntry(entry: RoomLedgerEntry, ownHandle: string, ownUserId: string, ownSeqs?: ReadonlySet<number>): boolean;
98
112
  /**
99
113
  * The newest qualifying trigger among entries strictly newer than
100
114
  * `afterSeq`, or null if none. Used by the loop to decide whether the latest
101
- * batch of room messages contains a reason to act. Pure.
115
+ * batch of room messages contains a reason to act. Reply triggers resolve
116
+ * against the user's own seqs within `entries` unless a wider `ownSeqs`
117
+ * window is supplied (the live single-entry path passes the room cache).
118
+ * Pure.
102
119
  */
103
- export declare function findTrigger(entries: ReadonlyArray<RoomLedgerEntry>, ownHandle: string, ownUserId: string, afterSeq: number): RoomLedgerEntry | null;
120
+ export declare function findTrigger(entries: ReadonlyArray<RoomLedgerEntry>, ownHandle: string, ownUserId: string, afterSeq: number, ownSeqs?: ReadonlySet<number>): RoomLedgerEntry | null;
104
121
  /** Autonomous posts inside the rolling hourly window as of `now`. */
105
122
  export declare function postsInWindow(state: ArmedRoomState, now?: number): number;
106
123
  /** Remaining cooldown (ms) before the next autonomous post is allowed; 0 when
@@ -83,12 +83,41 @@ export function makeArmedRoomState(roomId, lastSeq = 0) {
83
83
  lastEvaluatedSeq: lastSeq,
84
84
  };
85
85
  }
86
+ /**
87
+ * A fresh armed state that CARRIES the cost/rate counters from a prior armed
88
+ * session of the same room. Disarm/rearm must not zero the cooldown, hourly
89
+ * window, or session budget: otherwise toggling /disarm + /arm bypasses every
90
+ * cost guard. Error and chain counters DO reset (rearming is an explicit
91
+ * operator action; a fresh process still starts with nothing retained).
92
+ */
93
+ export function rearmedRoomState(roomId, retired, lastSeq = 0) {
94
+ const state = makeArmedRoomState(roomId, lastSeq);
95
+ if (!retired || retired.roomId !== roomId)
96
+ return state;
97
+ state.postTimes = [...retired.postTimes];
98
+ state.postsThisSession = retired.postsThisSession;
99
+ state.tokensThisSession = retired.tokensThisSession;
100
+ return state;
101
+ }
102
+ /** Seqs of the user's own posts in a window of entries; the reply-trigger
103
+ * check tests `inReplyTo` against this set. Pure. */
104
+ export function ownSeqsOf(entries, ownUserId) {
105
+ const seqs = new Set();
106
+ for (const entry of entries) {
107
+ if (entry.userId === ownUserId)
108
+ seqs.add(entry.seq);
109
+ }
110
+ return seqs;
111
+ }
86
112
  /**
87
113
  * Whether a single incoming entry is a qualifying TRIGGER: a NEW human POST in
88
- * the room that @mentions the user's own handle. Agent posts, system entries,
89
- * the user's own posts, and non-mentions never trigger. Pure.
114
+ * the room that @mentions the user's own handle, OR (when `ownSeqs` is given)
115
+ * directly replies to one of the user's own messages. Reply semantics match a
116
+ * mention (replying pings the author), so armed mode treats both as addressed
117
+ * to the user. Agent posts, system entries, the user's own posts, and
118
+ * non-mentions never trigger. Pure.
90
119
  */
91
- export function isTriggeringEntry(entry, ownHandle, ownUserId) {
120
+ export function isTriggeringEntry(entry, ownHandle, ownUserId, ownSeqs) {
92
121
  if (entry.type !== RoomLedgerEntryType.POST)
93
122
  return false;
94
123
  if (entry.isAgent)
@@ -102,6 +131,10 @@ export function isTriggeringEntry(entry, ownHandle, ownUserId) {
102
131
  return false;
103
132
  if (me.length > 0 && fromHandle === me)
104
133
  return false;
134
+ // A direct reply to one of our messages is addressed to us. The set only
135
+ // spans the cached window, so replies to long-scrolled-out posts stay inert.
136
+ if (entry.inReplyTo !== undefined && ownSeqs?.has(entry.inReplyTo))
137
+ return true;
105
138
  // Must EXPLICITLY @mention the user's own handle. A bare-substring match (the
106
139
  // old mentionsAgent) fired on ordinary words for short handles like "may" /
107
140
  // "btc"; armed posting has no human review, so it triggers ONLY on an `@handle`
@@ -111,14 +144,17 @@ export function isTriggeringEntry(entry, ownHandle, ownUserId) {
111
144
  /**
112
145
  * The newest qualifying trigger among entries strictly newer than
113
146
  * `afterSeq`, or null if none. Used by the loop to decide whether the latest
114
- * batch of room messages contains a reason to act. Pure.
147
+ * batch of room messages contains a reason to act. Reply triggers resolve
148
+ * against the user's own seqs within `entries` unless a wider `ownSeqs`
149
+ * window is supplied (the live single-entry path passes the room cache).
150
+ * Pure.
115
151
  */
116
- export function findTrigger(entries, ownHandle, ownUserId, afterSeq) {
152
+ export function findTrigger(entries, ownHandle, ownUserId, afterSeq, ownSeqs = ownSeqsOf(entries, ownUserId)) {
117
153
  let found = null;
118
154
  for (const entry of entries) {
119
155
  if (entry.seq <= afterSeq)
120
156
  continue;
121
- if (isTriggeringEntry(entry, ownHandle, ownUserId))
157
+ if (isTriggeringEntry(entry, ownHandle, ownUserId, ownSeqs))
122
158
  found = entry;
123
159
  }
124
160
  return found;
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type RoomAccessMode, type RoomAttachment, 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 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;
@@ -39,7 +39,7 @@ export interface RoomJoinOptions {
39
39
  }
40
40
  export interface RoomSayOptions extends RoomJoinOptions {
41
41
  text?: string;
42
- attachments?: RoomAttachment[];
42
+ attachments?: RoomAttachmentInput[];
43
43
  inReplyTo?: number;
44
44
  /** Post directly into a topic; the room's linear stream is unaffected. */
45
45
  topicId?: string;
@@ -208,6 +208,10 @@ export interface RoomDmSendRequest {
208
208
  conversationId?: string;
209
209
  content: string;
210
210
  clientMsgId?: string;
211
+ /** A private DM-attachment key (dm-attachments/…) or a legacy public image
212
+ * URL. Stored on the message's imageUrl; the client signs keys on render.
213
+ * Additive — omitted for text-only sends. */
214
+ imageUrl?: string;
211
215
  }
212
216
  export interface RoomThreadRequest {
213
217
  room: string;
@@ -251,13 +255,35 @@ export declare function requestRoomFriends(client: RoomWsClient): Promise<RoomFr
251
255
  export declare function openRoomDmOnClient(client: RoomWsClient, request: RoomDmOpenRequest, timeoutMs?: number): Promise<RoomDmOpenedPayload>;
252
256
  export declare function requestRoomDmHistoryOnClient(client: RoomWsClient, request: RoomDmHistoryRequest, timeoutMs?: number): Promise<RoomDmHistoryResultPayload>;
253
257
  export declare function sendRoomDmOnClient(client: RoomWsClient, request: RoomDmSendRequest, timeoutMs?: number): Promise<RoomDmMessagePayload>;
258
+ /** Edit one of my DM messages. Fire-and-forget over the WS; the relay fans out
259
+ * DM_MESSAGE_EDITED to both participants. */
260
+ export declare function editRoomDmOnClient(client: RoomWsClient, request: {
261
+ conversationId?: string;
262
+ username?: string;
263
+ messageId: string;
264
+ content: string;
265
+ }): void;
266
+ /** Soft-delete one of my DM messages. The relay fans out DM_MESSAGE_DELETED. */
267
+ export declare function deleteRoomDmOnClient(client: RoomWsClient, request: {
268
+ conversationId?: string;
269
+ username?: string;
270
+ messageId: string;
271
+ }): void;
272
+ /** Toggle a reaction on a DM message. The relay fans out DM_REACTION_UPDATED. */
273
+ export declare function reactRoomDmOnClient(client: RoomWsClient, request: {
274
+ conversationId?: string;
275
+ username?: string;
276
+ messageId: string;
277
+ emoji: string;
278
+ op: "add" | "remove";
279
+ }): void;
254
280
  export declare function peekRoom(config: RoomClientConfig, options: RoomPeekOptions): Promise<RoomBackfillPayload>;
255
281
  export declare function sayRoom(config: RoomClientConfig, options: RoomSayOptions): Promise<RoomSayResult>;
256
282
  export declare function joinRoom(config: RoomClientConfig, options: RoomJoinOptions): Promise<RoomJoinedSession>;
257
283
  /** Re-join a room on an already-connected client (used after a reconnect). */
258
284
  export declare function rejoinRoomOnClient(client: RoomWsClient, options: RoomJoinOptions): Promise<RoomBackfillPayload>;
259
285
  export declare function attachRejoinOnReconnect(client: RoomWsClient, options: RoomJoinOptions, latestSeq: () => number | undefined, onRejoined?: (backfill: RoomBackfillPayload) => void, onError?: (err: unknown) => void): () => void;
260
- export declare function postRoomMessage(client: RoomWsClient, room: string, text: string, inReplyTo?: number, attachments?: RoomAttachment[], topicId?: string): string;
286
+ export declare function postRoomMessage(client: RoomWsClient, room: string, text: string, inReplyTo?: number, attachments?: RoomAttachmentInput[], topicId?: string): string;
261
287
  export declare function sendRoomTyping(client: RoomWsClient, room: string, state: "start" | "stop"): void;
262
288
  export declare function sendRoomStatus(client: RoomWsClient, status: RoomUserStatus, statusText?: string | null): void;
263
289
  /**
package/dist/client.js CHANGED
@@ -465,6 +465,7 @@ export async function sendRoomDmOnClient(client, request, timeoutMs) {
465
465
  clientMsgId,
466
466
  ...(request.username ? { username: request.username } : {}),
467
467
  ...(request.conversationId ? { conversationId: request.conversationId } : {}),
468
+ ...(request.imageUrl ? { imageUrl: request.imageUrl } : {}),
468
469
  },
469
470
  timestamp: Date.now(),
470
471
  });
@@ -473,6 +474,46 @@ export async function sendRoomDmOnClient(client, request, timeoutMs) {
473
474
  msg.payload.message.clientMsgId === clientMsgId, timeoutMs, requestId);
474
475
  return sent.payload;
475
476
  }
477
+ /** Edit one of my DM messages. Fire-and-forget over the WS; the relay fans out
478
+ * DM_MESSAGE_EDITED to both participants. */
479
+ export function editRoomDmOnClient(client, request) {
480
+ client.send({
481
+ type: RoomClientMessageType.DM_EDIT,
482
+ payload: {
483
+ messageId: request.messageId,
484
+ content: request.content,
485
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
486
+ ...(request.username ? { username: request.username } : {}),
487
+ },
488
+ timestamp: Date.now(),
489
+ });
490
+ }
491
+ /** Soft-delete one of my DM messages. The relay fans out DM_MESSAGE_DELETED. */
492
+ export function deleteRoomDmOnClient(client, request) {
493
+ client.send({
494
+ type: RoomClientMessageType.DM_DELETE,
495
+ payload: {
496
+ messageId: request.messageId,
497
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
498
+ ...(request.username ? { username: request.username } : {}),
499
+ },
500
+ timestamp: Date.now(),
501
+ });
502
+ }
503
+ /** Toggle a reaction on a DM message. The relay fans out DM_REACTION_UPDATED. */
504
+ export function reactRoomDmOnClient(client, request) {
505
+ client.send({
506
+ type: RoomClientMessageType.DM_REACT,
507
+ payload: {
508
+ messageId: request.messageId,
509
+ emoji: request.emoji,
510
+ op: request.op,
511
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
512
+ ...(request.username ? { username: request.username } : {}),
513
+ },
514
+ timestamp: Date.now(),
515
+ });
516
+ }
476
517
  export async function peekRoom(config, options) {
477
518
  if (!config.webSocketCtor) {
478
519
  try {
@@ -38,6 +38,9 @@ export declare const RoomClientMessageType: {
38
38
  readonly DM_HISTORY: "DM_HISTORY";
39
39
  readonly DM_SEND: "DM_SEND";
40
40
  readonly DM_READ: "DM_READ";
41
+ readonly DM_EDIT: "DM_EDIT";
42
+ readonly DM_DELETE: "DM_DELETE";
43
+ readonly DM_REACT: "DM_REACT";
41
44
  readonly TOPIC_CREATE: "TOPIC_CREATE";
42
45
  readonly TOPIC_RENAME: "TOPIC_RENAME";
43
46
  readonly TOPIC_MOVE: "TOPIC_MOVE";
@@ -87,6 +90,9 @@ export declare const RoomServerMessageType: {
87
90
  readonly DM_HISTORY: "DM_HISTORY";
88
91
  readonly DM_MESSAGE: "DM_MESSAGE";
89
92
  readonly DM_INBOX_UPDATED: "DM_INBOX_UPDATED";
93
+ readonly DM_MESSAGE_EDITED: "DM_MESSAGE_EDITED";
94
+ readonly DM_MESSAGE_DELETED: "DM_MESSAGE_DELETED";
95
+ readonly DM_REACTION_UPDATED: "DM_REACTION_UPDATED";
90
96
  readonly TOPIC: "TOPIC";
91
97
  readonly TOPICS: "TOPICS";
92
98
  readonly TOPIC_UPDATED: "TOPIC_UPDATED";
@@ -211,13 +217,27 @@ export interface RoomLedgerEntry {
211
217
  roomRole?: RoomRole | null | undefined;
212
218
  }
213
219
  export interface RoomAttachment {
214
- /** Object key under room-attachments/; clients sign it at render time. */
215
- key: string;
220
+ /**
221
+ * Object key under room-attachments/; clients sign it at render time.
222
+ * Absent while scanStatus is "pending" on entries served to members: the
223
+ * store withholds the signable key until the scan clears, so render the
224
+ * metadata as a placeholder and do not attempt signing without a key.
225
+ */
226
+ key?: string | undefined;
216
227
  name: string;
217
228
  size: number;
218
229
  mime: string;
219
230
  scanStatus?: "pending" | "clean" | "held" | undefined;
220
231
  }
232
+ /**
233
+ * A postable attachment reference. Outbound frames must always carry the
234
+ * stored key (senders reference their own uploads, which include it); only
235
+ * read views of pending entries may be keyless, and those must never be
236
+ * forwarded back into a post.
237
+ */
238
+ export type RoomAttachmentInput = RoomAttachment & {
239
+ key: string;
240
+ };
221
241
  /**
222
242
  * A registry-backed topic inside a channel. The id is permanent: rename
223
243
  * changes `name` only, and links (`om://topic/<topicId>`) keep resolving.
@@ -438,6 +458,11 @@ export interface RoomDmMessage {
438
458
  /** Sealed DM: content is '' and the opaque envelope rides here. */
439
459
  secret?: RoomLedgerSecretPayload | null | undefined;
440
460
  metadata?: Record<string, unknown> | undefined;
461
+ /** Legacy public chat-image URL (renders as-is, no signing). */
462
+ imageUrl?: string | null | undefined;
463
+ /** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
464
+ * Additive: old messages carry none and render unchanged. */
465
+ attachments?: RoomAttachment[] | undefined;
441
466
  }
442
467
  export interface RoomDmOpenPayload {
443
468
  requestId?: string;
@@ -459,6 +484,58 @@ export interface RoomDmSendPayload {
459
484
  conversationId?: string;
460
485
  content: string;
461
486
  clientMsgId?: string;
487
+ /** DM-attachment key or legacy public image URL; stored on the message's
488
+ * imageUrl. Additive — text-only sends omit it. */
489
+ imageUrl?: string;
490
+ }
491
+ /** Client → server: edit one of my DM messages (sealed messages are rejected). */
492
+ export interface RoomDmEditPayload {
493
+ requestId?: string;
494
+ conversationId?: string;
495
+ username?: string;
496
+ messageId: string;
497
+ content: string;
498
+ }
499
+ /** Client → server: soft-delete one of my DM messages (tombstone in place). */
500
+ export interface RoomDmDeletePayload {
501
+ requestId?: string;
502
+ conversationId?: string;
503
+ username?: string;
504
+ messageId: string;
505
+ }
506
+ /** Server → both participants: a DM message was edited. */
507
+ export interface RoomDmMessageEditedPayload {
508
+ conversationId: string;
509
+ messageId: string;
510
+ content: string;
511
+ editedAt: string | number;
512
+ participant: RoomDmParticipant;
513
+ }
514
+ /** Server → both participants: a DM message was soft-deleted (tombstone). */
515
+ export interface RoomDmMessageDeletedPayload {
516
+ conversationId: string;
517
+ messageId: string;
518
+ deletedAt: string | number;
519
+ participant: RoomDmParticipant;
520
+ }
521
+ /** Client → server: toggle a reaction on a DM message. */
522
+ export interface RoomDmReactPayload {
523
+ requestId?: string;
524
+ conversationId?: string;
525
+ username?: string;
526
+ messageId: string;
527
+ emoji: string;
528
+ op: "add" | "remove";
529
+ }
530
+ /** Server → both participants: a DM message's aggregated reactions changed. */
531
+ export interface RoomDmReactionUpdatedPayload {
532
+ conversationId: string;
533
+ messageId: string;
534
+ reactions: Array<{
535
+ emoji: string;
536
+ users: string[];
537
+ }>;
538
+ participant: RoomDmParticipant;
462
539
  }
463
540
  export interface RoomDmReadPayload {
464
541
  requestId?: string;
@@ -780,7 +857,7 @@ export type RoomClientMessage = {
780
857
  payload: {
781
858
  room: string;
782
859
  text?: string;
783
- attachments?: RoomAttachment[];
860
+ attachments?: RoomAttachmentInput[];
784
861
  inReplyTo?: number;
785
862
  /** Post directly into a topic; the room's linear stream is unaffected. */
786
863
  topicId?: string;
@@ -975,6 +1052,18 @@ export type RoomClientMessage = {
975
1052
  type: typeof RoomClientMessageType.DM_READ;
976
1053
  payload: RoomDmReadPayload;
977
1054
  timestamp?: number;
1055
+ } | {
1056
+ type: typeof RoomClientMessageType.DM_EDIT;
1057
+ payload: RoomDmEditPayload;
1058
+ timestamp?: number;
1059
+ } | {
1060
+ type: typeof RoomClientMessageType.DM_DELETE;
1061
+ payload: RoomDmDeletePayload;
1062
+ timestamp?: number;
1063
+ } | {
1064
+ type: typeof RoomClientMessageType.DM_REACT;
1065
+ payload: RoomDmReactPayload;
1066
+ timestamp?: number;
978
1067
  } | {
979
1068
  type: typeof RoomClientMessageType.TOPIC_CREATE;
980
1069
  payload: RoomTopicCreatePayload;
@@ -1165,6 +1254,18 @@ export type RoomServerMessage = {
1165
1254
  type: typeof RoomServerMessageType.DM_INBOX_UPDATED;
1166
1255
  payload: RoomDmInboxUpdatedPayload;
1167
1256
  timestamp: number;
1257
+ } | {
1258
+ type: typeof RoomServerMessageType.DM_MESSAGE_EDITED;
1259
+ payload: RoomDmMessageEditedPayload;
1260
+ timestamp: number;
1261
+ } | {
1262
+ type: typeof RoomServerMessageType.DM_MESSAGE_DELETED;
1263
+ payload: RoomDmMessageDeletedPayload;
1264
+ timestamp: number;
1265
+ } | {
1266
+ type: typeof RoomServerMessageType.DM_REACTION_UPDATED;
1267
+ payload: RoomDmReactionUpdatedPayload;
1268
+ timestamp: number;
1168
1269
  } | {
1169
1270
  type: typeof RoomServerMessageType.TOPIC;
1170
1271
  payload: RoomTopicResultPayload;
@@ -38,6 +38,9 @@ export const RoomClientMessageType = {
38
38
  DM_HISTORY: "DM_HISTORY",
39
39
  DM_SEND: "DM_SEND",
40
40
  DM_READ: "DM_READ",
41
+ DM_EDIT: "DM_EDIT",
42
+ DM_DELETE: "DM_DELETE",
43
+ DM_REACT: "DM_REACT",
41
44
  TOPIC_CREATE: "TOPIC_CREATE",
42
45
  TOPIC_RENAME: "TOPIC_RENAME",
43
46
  TOPIC_MOVE: "TOPIC_MOVE",
@@ -86,6 +89,9 @@ export const RoomServerMessageType = {
86
89
  DM_HISTORY: "DM_HISTORY",
87
90
  DM_MESSAGE: "DM_MESSAGE",
88
91
  DM_INBOX_UPDATED: "DM_INBOX_UPDATED",
92
+ DM_MESSAGE_EDITED: "DM_MESSAGE_EDITED",
93
+ DM_MESSAGE_DELETED: "DM_MESSAGE_DELETED",
94
+ DM_REACTION_UPDATED: "DM_REACTION_UPDATED",
89
95
  TOPIC: "TOPIC",
90
96
  TOPICS: "TOPICS",
91
97
  TOPIC_UPDATED: "TOPIC_UPDATED",
@@ -1,5 +1,8 @@
1
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
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";
3
+ /** Largest per-file size any tier accepts (plus tier). The server enforces
4
+ * the per-tier caps (free 10MB/file, plus 25MB/file) and daily quotas; this
5
+ * constant only pre-flights uploads no tier could ever accept. */
3
6
  export declare const ROOM_ATTACHMENT_MAX_BYTES: number;
4
7
  export declare class RoomSocialError extends Error {
5
8
  readonly name = "RoomSocialError";
@@ -175,6 +178,12 @@ export interface RoomSpaceMember {
175
178
  /** Named identity roles (RoomSpaceRole.roleId). */
176
179
  roleIds?: string[];
177
180
  joinedAt?: string;
181
+ /** The member's handle/username, resolved server-side from their room
182
+ * memberships. Absent when the store couldn't resolve a real name (never
183
+ * the raw userId). */
184
+ handle?: string;
185
+ /** Per-space display name; null or absent means the account username. */
186
+ nickname?: string | null;
178
187
  }
179
188
  export declare function getUserPrefs(config: RoomSocialConfig): Promise<Record<string, unknown>>;
180
189
  /** Whole-blob replace (16KB cap server-side; last writer wins). */
@@ -321,6 +330,8 @@ export declare function removeSpaceMember(config: RoomSocialConfig, spaceId: str
321
330
  removed?: boolean;
322
331
  }>;
323
332
  export declare function changeSpaceMemberRole(config: RoomSocialConfig, spaceId: string, targetUserId: string, role: "admin" | "member"): Promise<RoomSpaceMember>;
333
+ /** Set (string) or clear (null) a member's per-space nickname; the store validates and enforces uniqueness. */
334
+ export declare function setSpaceMemberNickname(config: RoomSocialConfig, spaceId: string, targetUserId: string, nickname: string | null): Promise<RoomSpaceMember>;
324
335
  export declare function createRoomSpace(config: RoomSocialConfig, input: {
325
336
  name: string;
326
337
  access?: "open" | "invite";
@@ -329,7 +340,10 @@ export declare function restoreRoomDoc(config: RoomSocialConfig, docId: string,
329
340
  note?: string;
330
341
  asAgent?: boolean;
331
342
  }): Promise<RoomDoc>;
332
- export declare function getRoomAttachmentUrl(config: RoomSocialConfig, room: string, key: string): Promise<{
343
+ export declare function getRoomAttachmentUrl(config: RoomSocialConfig, room: string, key: string, opts?: {
344
+ name?: string;
345
+ download?: boolean;
346
+ }): Promise<{
333
347
  url: string;
334
348
  expiresAt: string | null;
335
349
  }>;
@@ -355,6 +369,7 @@ export declare function socialRequest<T>(config: RoomSocialConfig, path: string,
355
369
  body?: unknown;
356
370
  signal?: AbortSignal;
357
371
  formData?: FormData;
372
+ headers?: Record<string, string>;
358
373
  }): Promise<T>;
359
374
  export declare function cleanUsername(username: string): string;
360
375
  export declare function cleanRoomId(room: string): string;
@@ -1,6 +1,9 @@
1
1
  import { resolveRoomsWsUrl } from "./client.js";
2
2
  import { envString, ROOM_CHAT_API_URL } from "./endpoints.js";
3
- export const ROOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
3
+ /** Largest per-file size any tier accepts (plus tier). The server enforces
4
+ * the per-tier caps (free 10MB/file, plus 25MB/file) and daily quotas; this
5
+ * constant only pre-flights uploads no tier could ever accept. */
6
+ export const ROOM_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
4
7
  export class RoomSocialError extends Error {
5
8
  name = "RoomSocialError";
6
9
  status;
@@ -501,6 +504,11 @@ export async function changeSpaceMemberRole(config, spaceId, targetUserId, role)
501
504
  const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/role`, { method: "PATCH", body: { role } });
502
505
  return result.member;
503
506
  }
507
+ /** Set (string) or clear (null) a member's per-space nickname; the store validates and enforces uniqueness. */
508
+ export async function setSpaceMemberNickname(config, spaceId, targetUserId, nickname) {
509
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/nickname`, { method: "PATCH", body: { nickname } });
510
+ return result.member;
511
+ }
504
512
  export async function createRoomSpace(config, input) {
505
513
  const result = await socialRequest(config, "/spaces", {
506
514
  method: "POST",
@@ -512,9 +520,22 @@ export async function restoreRoomDoc(config, docId, input = {}) {
512
520
  const result = await socialRequest(config, `/docs/${encodeURIComponent(docId)}/restore`, { method: "POST", body: input });
513
521
  return result.doc;
514
522
  }
515
- export async function getRoomAttachmentUrl(config, room, key) {
523
+ export async function getRoomAttachmentUrl(config, room, key, opts = {}) {
524
+ // The object key identifies the object and stays in the query. The
525
+ // download filename and force-download flag ride REQUEST HEADERS, never
526
+ // the URL: a private-room filename in the query string leaked into the
527
+ // relay's access logs and error telemetry (Morgan, Sentry).
516
528
  const params = new URLSearchParams({ key });
517
- const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/attachment-url?${params.toString()}`, { method: "GET" });
529
+ const headers = {};
530
+ // Percent-encode the name: Fetch/HTTP header values are byte strings, so a
531
+ // raw Unicode filename (报告.py, an emoji) throws a TypeError before the
532
+ // request is sent. The store decodes with decodeURIComponent.
533
+ if (opts.name)
534
+ headers["X-Om-Download-Name"] = encodeURIComponent(opts.name);
535
+ // Forces a Content-Disposition attachment even for inline mimes.
536
+ if (opts.download)
537
+ headers["X-Om-Download"] = "1";
538
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/attachment-url?${params.toString()}`, { method: "GET", headers });
518
539
  if (!result.url) {
519
540
  throw new RoomSocialError("Attachment sign failed", 502, result);
520
541
  }
@@ -582,6 +603,7 @@ export async function socialRequest(config, path, init = {}) {
582
603
  Authorization: `Bearer ${config.apiKey}`,
583
604
  Accept: "application/json",
584
605
  ...(hasJsonBody ? { "Content-Type": "application/json" } : {}),
606
+ ...(init.headers ?? {}),
585
607
  },
586
608
  ...(hasJsonBody
587
609
  ? { body: JSON.stringify(init.body) }
@@ -653,6 +675,63 @@ function requestPath(url) {
653
675
  function trimTrailingSlash(value) {
654
676
  return value.replace(/\/+$/, "");
655
677
  }
678
+ /** Source/config/text extensions declared as text/plain so the server's
679
+ * UTF-8 code-file lane accepts them. Markup extensions the server
680
+ * blocklists (html, svg, xml, ...) are deliberately absent: they fall
681
+ * through to application/octet-stream and the server rejects them. */
682
+ const CODE_TEXT_EXTENSIONS = new Set([
683
+ "js",
684
+ "jsx",
685
+ "ts",
686
+ "tsx",
687
+ "py",
688
+ "rb",
689
+ "go",
690
+ "rs",
691
+ "java",
692
+ "c",
693
+ "h",
694
+ "cc",
695
+ "cpp",
696
+ "hpp",
697
+ "cs",
698
+ "php",
699
+ "sh",
700
+ "bash",
701
+ "zsh",
702
+ "fish",
703
+ "ps1",
704
+ "sql",
705
+ "yaml",
706
+ "yml",
707
+ "toml",
708
+ "ini",
709
+ "cfg",
710
+ "conf",
711
+ "log",
712
+ "diff",
713
+ "patch",
714
+ "tex",
715
+ "r",
716
+ "jl",
717
+ "kt",
718
+ "kts",
719
+ "swift",
720
+ "scala",
721
+ "clj",
722
+ "cljs",
723
+ "ex",
724
+ "exs",
725
+ "erl",
726
+ "lua",
727
+ "pl",
728
+ "pm",
729
+ "dart",
730
+ "gradle",
731
+ "properties",
732
+ "bat",
733
+ "cmd",
734
+ ]);
656
735
  export function mimeFromFilename(filename) {
657
736
  const lower = filename.toLowerCase();
658
737
  if (lower.endsWith(".png"))
@@ -663,6 +742,10 @@ export function mimeFromFilename(filename) {
663
742
  return "image/webp";
664
743
  if (lower.endsWith(".gif"))
665
744
  return "image/gif";
745
+ if (lower.endsWith(".mp4"))
746
+ return "video/mp4";
747
+ if (lower.endsWith(".webm"))
748
+ return "video/webm";
666
749
  if (lower.endsWith(".pdf"))
667
750
  return "application/pdf";
668
751
  if (lower.endsWith(".txt"))
@@ -682,5 +765,8 @@ export function mimeFromFilename(filename) {
682
765
  if (lower.endsWith(".pptx")) {
683
766
  return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
684
767
  }
768
+ const dot = lower.lastIndexOf(".");
769
+ if (dot !== -1 && CODE_TEXT_EXTENSIONS.has(lower.slice(dot + 1)))
770
+ return "text/plain";
685
771
  return "application/octet-stream";
686
772
  }
@@ -44,16 +44,26 @@ export interface RoomFollowResult {
44
44
  isNewFollow?: boolean;
45
45
  }
46
46
  export interface RoomAttachment {
47
- /** Object key under room-attachments/; clients sign it at render time. */
48
- key: string;
47
+ /**
48
+ * Object key under room-attachments/; clients sign it at render time.
49
+ * Absent while scanStatus is "pending" on entries served to members: the
50
+ * store withholds the signable key until the scan clears, so render the
51
+ * metadata as a placeholder and do not attempt signing without a key.
52
+ */
53
+ key?: string | undefined;
49
54
  name: string;
50
55
  size: number;
51
56
  mime: string;
52
57
  scanStatus?: "pending" | "clean" | "held" | undefined;
53
58
  }
54
59
  export interface RoomAttachmentUpload {
55
- /** The stored attachment plus its first signed URL, for immediate render. */
60
+ /**
61
+ * The stored attachment plus its first signed URL, for immediate render.
62
+ * The uploader always receives the key; only member views of PENDING
63
+ * entries withhold it.
64
+ */
56
65
  attachment: RoomAttachment & {
66
+ key: string;
57
67
  url: string;
58
68
  expiresAt: string | null;
59
69
  };
@@ -112,6 +122,14 @@ export interface RoomDmMessage {
112
122
  /** Sealed DM: content is '' and the opaque envelope rides here. */
113
123
  secret?: RoomLedgerSecretPayload | null;
114
124
  metadata?: Record<string, unknown>;
125
+ /** DM image: a private dm-attachments/ key (signed on render) or a legacy
126
+ * public URL. Additive — old messages carry none. */
127
+ imageUrl?: string | null;
128
+ /** Aggregated reactions (per-emoji userId lists). Additive. */
129
+ reactions?: Array<{
130
+ emoji: string;
131
+ users: string[];
132
+ }>;
115
133
  }
116
134
  export interface RoomDmHistory {
117
135
  conversationId: string;
package/dist/types.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export type { RoomAccessMode, RoomAttachment, 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, 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";
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.5.0";
2
- export declare const RUNNER_VERSION = "0.5.0";
1
+ export declare const VERSION = "0.5.1";
2
+ export declare const RUNNER_VERSION = "0.5.1";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.5.0";
2
- export const RUNNER_VERSION = "0.5.0";
1
+ export const VERSION = "0.5.1";
2
+ export const RUNNER_VERSION = "0.5.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmarket/rooms-client",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -126,15 +126,49 @@ export function makeArmedRoomState(roomId: string, lastSeq = 0): ArmedRoomState
126
126
  };
127
127
  }
128
128
 
129
+ /**
130
+ * A fresh armed state that CARRIES the cost/rate counters from a prior armed
131
+ * session of the same room. Disarm/rearm must not zero the cooldown, hourly
132
+ * window, or session budget: otherwise toggling /disarm + /arm bypasses every
133
+ * cost guard. Error and chain counters DO reset (rearming is an explicit
134
+ * operator action; a fresh process still starts with nothing retained).
135
+ */
136
+ export function rearmedRoomState(
137
+ roomId: string,
138
+ retired: ArmedRoomState | null,
139
+ lastSeq = 0,
140
+ ): ArmedRoomState {
141
+ const state = makeArmedRoomState(roomId, lastSeq);
142
+ if (!retired || retired.roomId !== roomId) return state;
143
+ state.postTimes = [...retired.postTimes];
144
+ state.postsThisSession = retired.postsThisSession;
145
+ state.tokensThisSession = retired.tokensThisSession;
146
+ return state;
147
+ }
148
+
149
+ /** Seqs of the user's own posts in a window of entries; the reply-trigger
150
+ * check tests `inReplyTo` against this set. Pure. */
151
+ export function ownSeqsOf(entries: ReadonlyArray<RoomLedgerEntry>, ownUserId: string): Set<number> {
152
+ const seqs = new Set<number>();
153
+ for (const entry of entries) {
154
+ if (entry.userId === ownUserId) seqs.add(entry.seq);
155
+ }
156
+ return seqs;
157
+ }
158
+
129
159
  /**
130
160
  * Whether a single incoming entry is a qualifying TRIGGER: a NEW human POST in
131
- * the room that @mentions the user's own handle. Agent posts, system entries,
132
- * the user's own posts, and non-mentions never trigger. Pure.
161
+ * the room that @mentions the user's own handle, OR (when `ownSeqs` is given)
162
+ * directly replies to one of the user's own messages. Reply semantics match a
163
+ * mention (replying pings the author), so armed mode treats both as addressed
164
+ * to the user. Agent posts, system entries, the user's own posts, and
165
+ * non-mentions never trigger. Pure.
133
166
  */
134
167
  export function isTriggeringEntry(
135
168
  entry: RoomLedgerEntry,
136
169
  ownHandle: string,
137
170
  ownUserId: string,
171
+ ownSeqs?: ReadonlySet<number>,
138
172
  ): boolean {
139
173
  if (entry.type !== RoomLedgerEntryType.POST) return false;
140
174
  if (entry.isAgent) return false;
@@ -144,6 +178,9 @@ export function isTriggeringEntry(
144
178
  const me = normalizeHandle(ownHandle).toLowerCase();
145
179
  if (entry.userId === ownUserId) return false;
146
180
  if (me.length > 0 && fromHandle === me) return false;
181
+ // A direct reply to one of our messages is addressed to us. The set only
182
+ // spans the cached window, so replies to long-scrolled-out posts stay inert.
183
+ if (entry.inReplyTo !== undefined && ownSeqs?.has(entry.inReplyTo)) return true;
147
184
  // Must EXPLICITLY @mention the user's own handle. A bare-substring match (the
148
185
  // old mentionsAgent) fired on ordinary words for short handles like "may" /
149
186
  // "btc"; armed posting has no human review, so it triggers ONLY on an `@handle`
@@ -154,18 +191,22 @@ export function isTriggeringEntry(
154
191
  /**
155
192
  * The newest qualifying trigger among entries strictly newer than
156
193
  * `afterSeq`, or null if none. Used by the loop to decide whether the latest
157
- * batch of room messages contains a reason to act. Pure.
194
+ * batch of room messages contains a reason to act. Reply triggers resolve
195
+ * against the user's own seqs within `entries` unless a wider `ownSeqs`
196
+ * window is supplied (the live single-entry path passes the room cache).
197
+ * Pure.
158
198
  */
159
199
  export function findTrigger(
160
200
  entries: ReadonlyArray<RoomLedgerEntry>,
161
201
  ownHandle: string,
162
202
  ownUserId: string,
163
203
  afterSeq: number,
204
+ ownSeqs: ReadonlySet<number> = ownSeqsOf(entries, ownUserId),
164
205
  ): RoomLedgerEntry | null {
165
206
  let found: RoomLedgerEntry | null = null;
166
207
  for (const entry of entries) {
167
208
  if (entry.seq <= afterSeq) continue;
168
- if (isTriggeringEntry(entry, ownHandle, ownUserId)) found = entry;
209
+ if (isTriggeringEntry(entry, ownHandle, ownUserId, ownSeqs)) found = entry;
169
210
  }
170
211
  return found;
171
212
  }
package/src/client.ts CHANGED
@@ -7,7 +7,7 @@ import { authFromApiKey } from "./jwt.js";
7
7
  import { consumeRoomSuggestionAttribution } from "./suggestion-attribution.js";
8
8
  import {
9
9
  type RoomAccessMode,
10
- type RoomAttachment,
10
+ type RoomAttachmentInput,
11
11
  type RoomAuditLogPayload,
12
12
  type RoomAuthSuccessPayload,
13
13
  type RoomBackfillPayload,
@@ -82,7 +82,7 @@ export interface RoomJoinOptions {
82
82
 
83
83
  export interface RoomSayOptions extends RoomJoinOptions {
84
84
  text?: string;
85
- attachments?: RoomAttachment[];
85
+ attachments?: RoomAttachmentInput[];
86
86
  inReplyTo?: number;
87
87
  /** Post directly into a topic; the room's linear stream is unaffected. */
88
88
  topicId?: string;
@@ -261,7 +261,9 @@ export interface RoomJoinedSession {
261
261
  client: RoomWsClient;
262
262
  }
263
263
 
264
- function toRoomAttachmentPayloads(attachments: readonly RoomAttachment[]): RoomAttachment[] {
264
+ function toRoomAttachmentPayloads(
265
+ attachments: readonly RoomAttachmentInput[],
266
+ ): RoomAttachmentInput[] {
265
267
  return attachments.map(({ key, name, size, mime }) => ({ key, name, size, mime }));
266
268
  }
267
269
 
@@ -284,6 +286,10 @@ export interface RoomDmSendRequest {
284
286
  conversationId?: string;
285
287
  content: string;
286
288
  clientMsgId?: string;
289
+ /** A private DM-attachment key (dm-attachments/…) or a legacy public image
290
+ * URL. Stored on the message's imageUrl; the client signs keys on render.
291
+ * Additive — omitted for text-only sends. */
292
+ imageUrl?: string;
287
293
  }
288
294
 
289
295
  export interface RoomThreadRequest {
@@ -931,6 +937,7 @@ export async function sendRoomDmOnClient(
931
937
  clientMsgId,
932
938
  ...(request.username ? { username: request.username } : {}),
933
939
  ...(request.conversationId ? { conversationId: request.conversationId } : {}),
940
+ ...(request.imageUrl ? { imageUrl: request.imageUrl } : {}),
934
941
  },
935
942
  timestamp: Date.now(),
936
943
  });
@@ -946,6 +953,64 @@ export async function sendRoomDmOnClient(
946
953
  return sent.payload;
947
954
  }
948
955
 
956
+ /** Edit one of my DM messages. Fire-and-forget over the WS; the relay fans out
957
+ * DM_MESSAGE_EDITED to both participants. */
958
+ export function editRoomDmOnClient(
959
+ client: RoomWsClient,
960
+ request: { conversationId?: string; username?: string; messageId: string; content: string },
961
+ ): void {
962
+ client.send({
963
+ type: RoomClientMessageType.DM_EDIT,
964
+ payload: {
965
+ messageId: request.messageId,
966
+ content: request.content,
967
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
968
+ ...(request.username ? { username: request.username } : {}),
969
+ },
970
+ timestamp: Date.now(),
971
+ });
972
+ }
973
+
974
+ /** Soft-delete one of my DM messages. The relay fans out DM_MESSAGE_DELETED. */
975
+ export function deleteRoomDmOnClient(
976
+ client: RoomWsClient,
977
+ request: { conversationId?: string; username?: string; messageId: string },
978
+ ): void {
979
+ client.send({
980
+ type: RoomClientMessageType.DM_DELETE,
981
+ payload: {
982
+ messageId: request.messageId,
983
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
984
+ ...(request.username ? { username: request.username } : {}),
985
+ },
986
+ timestamp: Date.now(),
987
+ });
988
+ }
989
+
990
+ /** Toggle a reaction on a DM message. The relay fans out DM_REACTION_UPDATED. */
991
+ export function reactRoomDmOnClient(
992
+ client: RoomWsClient,
993
+ request: {
994
+ conversationId?: string;
995
+ username?: string;
996
+ messageId: string;
997
+ emoji: string;
998
+ op: "add" | "remove";
999
+ },
1000
+ ): void {
1001
+ client.send({
1002
+ type: RoomClientMessageType.DM_REACT,
1003
+ payload: {
1004
+ messageId: request.messageId,
1005
+ emoji: request.emoji,
1006
+ op: request.op,
1007
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
1008
+ ...(request.username ? { username: request.username } : {}),
1009
+ },
1010
+ timestamp: Date.now(),
1011
+ });
1012
+ }
1013
+
949
1014
  export async function peekRoom(
950
1015
  config: RoomClientConfig,
951
1016
  options: RoomPeekOptions,
@@ -1087,7 +1152,7 @@ export function postRoomMessage(
1087
1152
  room: string,
1088
1153
  text: string,
1089
1154
  inReplyTo?: number,
1090
- attachments?: RoomAttachment[],
1155
+ attachments?: RoomAttachmentInput[],
1091
1156
  topicId?: string,
1092
1157
  ): string {
1093
1158
  const clientMsgId = randomUUID();
@@ -38,6 +38,9 @@ export const RoomClientMessageType = {
38
38
  DM_HISTORY: "DM_HISTORY",
39
39
  DM_SEND: "DM_SEND",
40
40
  DM_READ: "DM_READ",
41
+ DM_EDIT: "DM_EDIT",
42
+ DM_DELETE: "DM_DELETE",
43
+ DM_REACT: "DM_REACT",
41
44
  TOPIC_CREATE: "TOPIC_CREATE",
42
45
  TOPIC_RENAME: "TOPIC_RENAME",
43
46
  TOPIC_MOVE: "TOPIC_MOVE",
@@ -90,6 +93,9 @@ export const RoomServerMessageType = {
90
93
  DM_HISTORY: "DM_HISTORY",
91
94
  DM_MESSAGE: "DM_MESSAGE",
92
95
  DM_INBOX_UPDATED: "DM_INBOX_UPDATED",
96
+ DM_MESSAGE_EDITED: "DM_MESSAGE_EDITED",
97
+ DM_MESSAGE_DELETED: "DM_MESSAGE_DELETED",
98
+ DM_REACTION_UPDATED: "DM_REACTION_UPDATED",
93
99
  TOPIC: "TOPIC",
94
100
  TOPICS: "TOPICS",
95
101
  TOPIC_UPDATED: "TOPIC_UPDATED",
@@ -240,14 +246,27 @@ export interface RoomLedgerEntry {
240
246
  }
241
247
 
242
248
  export interface RoomAttachment {
243
- /** Object key under room-attachments/; clients sign it at render time. */
244
- key: string;
249
+ /**
250
+ * Object key under room-attachments/; clients sign it at render time.
251
+ * Absent while scanStatus is "pending" on entries served to members: the
252
+ * store withholds the signable key until the scan clears, so render the
253
+ * metadata as a placeholder and do not attempt signing without a key.
254
+ */
255
+ key?: string | undefined;
245
256
  name: string;
246
257
  size: number;
247
258
  mime: string;
248
259
  scanStatus?: "pending" | "clean" | "held" | undefined;
249
260
  }
250
261
 
262
+ /**
263
+ * A postable attachment reference. Outbound frames must always carry the
264
+ * stored key (senders reference their own uploads, which include it); only
265
+ * read views of pending entries may be keyless, and those must never be
266
+ * forwarded back into a post.
267
+ */
268
+ export type RoomAttachmentInput = RoomAttachment & { key: string };
269
+
251
270
  /**
252
271
  * A registry-backed topic inside a channel. The id is permanent: rename
253
272
  * changes `name` only, and links (`om://topic/<topicId>`) keep resolving.
@@ -527,6 +546,11 @@ export interface RoomDmMessage {
527
546
  /** Sealed DM: content is '' and the opaque envelope rides here. */
528
547
  secret?: RoomLedgerSecretPayload | null | undefined;
529
548
  metadata?: Record<string, unknown> | undefined;
549
+ /** Legacy public chat-image URL (renders as-is, no signing). */
550
+ imageUrl?: string | null | undefined;
551
+ /** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
552
+ * Additive: old messages carry none and render unchanged. */
553
+ attachments?: RoomAttachment[] | undefined;
530
554
  }
531
555
 
532
556
  export interface RoomDmOpenPayload {
@@ -551,6 +575,61 @@ export interface RoomDmSendPayload {
551
575
  conversationId?: string;
552
576
  content: string;
553
577
  clientMsgId?: string;
578
+ /** DM-attachment key or legacy public image URL; stored on the message's
579
+ * imageUrl. Additive — text-only sends omit it. */
580
+ imageUrl?: string;
581
+ }
582
+
583
+ /** Client → server: edit one of my DM messages (sealed messages are rejected). */
584
+ export interface RoomDmEditPayload {
585
+ requestId?: string;
586
+ conversationId?: string;
587
+ username?: string;
588
+ messageId: string;
589
+ content: string;
590
+ }
591
+
592
+ /** Client → server: soft-delete one of my DM messages (tombstone in place). */
593
+ export interface RoomDmDeletePayload {
594
+ requestId?: string;
595
+ conversationId?: string;
596
+ username?: string;
597
+ messageId: string;
598
+ }
599
+
600
+ /** Server → both participants: a DM message was edited. */
601
+ export interface RoomDmMessageEditedPayload {
602
+ conversationId: string;
603
+ messageId: string;
604
+ content: string;
605
+ editedAt: string | number;
606
+ participant: RoomDmParticipant;
607
+ }
608
+
609
+ /** Server → both participants: a DM message was soft-deleted (tombstone). */
610
+ export interface RoomDmMessageDeletedPayload {
611
+ conversationId: string;
612
+ messageId: string;
613
+ deletedAt: string | number;
614
+ participant: RoomDmParticipant;
615
+ }
616
+
617
+ /** Client → server: toggle a reaction on a DM message. */
618
+ export interface RoomDmReactPayload {
619
+ requestId?: string;
620
+ conversationId?: string;
621
+ username?: string;
622
+ messageId: string;
623
+ emoji: string;
624
+ op: "add" | "remove";
625
+ }
626
+
627
+ /** Server → both participants: a DM message's aggregated reactions changed. */
628
+ export interface RoomDmReactionUpdatedPayload {
629
+ conversationId: string;
630
+ messageId: string;
631
+ reactions: Array<{ emoji: string; users: string[] }>;
632
+ participant: RoomDmParticipant;
554
633
  }
555
634
 
556
635
  export interface RoomDmReadPayload {
@@ -902,7 +981,7 @@ export type RoomClientMessage =
902
981
  payload: {
903
982
  room: string;
904
983
  text?: string;
905
- attachments?: RoomAttachment[];
984
+ attachments?: RoomAttachmentInput[];
906
985
  inReplyTo?: number;
907
986
  /** Post directly into a topic; the room's linear stream is unaffected. */
908
987
  topicId?: string;
@@ -1085,6 +1164,21 @@ export type RoomClientMessage =
1085
1164
  payload: RoomDmReadPayload;
1086
1165
  timestamp?: number;
1087
1166
  }
1167
+ | {
1168
+ type: typeof RoomClientMessageType.DM_EDIT;
1169
+ payload: RoomDmEditPayload;
1170
+ timestamp?: number;
1171
+ }
1172
+ | {
1173
+ type: typeof RoomClientMessageType.DM_DELETE;
1174
+ payload: RoomDmDeletePayload;
1175
+ timestamp?: number;
1176
+ }
1177
+ | {
1178
+ type: typeof RoomClientMessageType.DM_REACT;
1179
+ payload: RoomDmReactPayload;
1180
+ timestamp?: number;
1181
+ }
1088
1182
  | {
1089
1183
  type: typeof RoomClientMessageType.TOPIC_CREATE;
1090
1184
  payload: RoomTopicCreatePayload;
@@ -1317,6 +1411,21 @@ export type RoomServerMessage =
1317
1411
  payload: RoomDmInboxUpdatedPayload;
1318
1412
  timestamp: number;
1319
1413
  }
1414
+ | {
1415
+ type: typeof RoomServerMessageType.DM_MESSAGE_EDITED;
1416
+ payload: RoomDmMessageEditedPayload;
1417
+ timestamp: number;
1418
+ }
1419
+ | {
1420
+ type: typeof RoomServerMessageType.DM_MESSAGE_DELETED;
1421
+ payload: RoomDmMessageDeletedPayload;
1422
+ timestamp: number;
1423
+ }
1424
+ | {
1425
+ type: typeof RoomServerMessageType.DM_REACTION_UPDATED;
1426
+ payload: RoomDmReactionUpdatedPayload;
1427
+ timestamp: number;
1428
+ }
1320
1429
  | {
1321
1430
  type: typeof RoomServerMessageType.TOPIC;
1322
1431
  payload: RoomTopicResultPayload;
@@ -62,7 +62,10 @@ export type {
62
62
  RoomSuggestionSource,
63
63
  } from "./social-types.js";
64
64
 
65
- export const ROOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
65
+ /** Largest per-file size any tier accepts (plus tier). The server enforces
66
+ * the per-tier caps (free 10MB/file, plus 25MB/file) and daily quotas; this
67
+ * constant only pre-flights uploads no tier could ever accept. */
68
+ export const ROOM_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
66
69
 
67
70
  export class RoomSocialError extends Error {
68
71
  override readonly name = "RoomSocialError";
@@ -658,6 +661,12 @@ export interface RoomSpaceMember {
658
661
  /** Named identity roles (RoomSpaceRole.roleId). */
659
662
  roleIds?: string[];
660
663
  joinedAt?: string;
664
+ /** The member's handle/username, resolved server-side from their room
665
+ * memberships. Absent when the store couldn't resolve a real name (never
666
+ * the raw userId). */
667
+ handle?: string;
668
+ /** Per-space display name; null or absent means the account username. */
669
+ nickname?: string | null;
661
670
  }
662
671
 
663
672
  export async function getUserPrefs(config: RoomSocialConfig): Promise<Record<string, unknown>> {
@@ -1048,6 +1057,21 @@ export async function changeSpaceMemberRole(
1048
1057
  return result.member;
1049
1058
  }
1050
1059
 
1060
+ /** Set (string) or clear (null) a member's per-space nickname; the store validates and enforces uniqueness. */
1061
+ export async function setSpaceMemberNickname(
1062
+ config: RoomSocialConfig,
1063
+ spaceId: string,
1064
+ targetUserId: string,
1065
+ nickname: string | null,
1066
+ ): Promise<RoomSpaceMember> {
1067
+ const result = await socialRequest<{ member: RoomSpaceMember }>(
1068
+ config,
1069
+ `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/nickname`,
1070
+ { method: "PATCH", body: { nickname } },
1071
+ );
1072
+ return result.member;
1073
+ }
1074
+
1051
1075
  export async function createRoomSpace(
1052
1076
  config: RoomSocialConfig,
1053
1077
  input: { name: string; access?: "open" | "invite" },
@@ -1076,12 +1100,24 @@ export async function getRoomAttachmentUrl(
1076
1100
  config: RoomSocialConfig,
1077
1101
  room: string,
1078
1102
  key: string,
1103
+ opts: { name?: string; download?: boolean } = {},
1079
1104
  ): Promise<{ url: string; expiresAt: string | null }> {
1105
+ // The object key identifies the object and stays in the query. The
1106
+ // download filename and force-download flag ride REQUEST HEADERS, never
1107
+ // the URL: a private-room filename in the query string leaked into the
1108
+ // relay's access logs and error telemetry (Morgan, Sentry).
1080
1109
  const params = new URLSearchParams({ key });
1110
+ const headers: Record<string, string> = {};
1111
+ // Percent-encode the name: Fetch/HTTP header values are byte strings, so a
1112
+ // raw Unicode filename (报告.py, an emoji) throws a TypeError before the
1113
+ // request is sent. The store decodes with decodeURIComponent.
1114
+ if (opts.name) headers["X-Om-Download-Name"] = encodeURIComponent(opts.name);
1115
+ // Forces a Content-Disposition attachment even for inline mimes.
1116
+ if (opts.download) headers["X-Om-Download"] = "1";
1081
1117
  const result = await socialRequest<{ url?: string; expiresAt?: string | null }>(
1082
1118
  config,
1083
1119
  `/rooms/${encodeURIComponent(cleanRoomId(room))}/attachment-url?${params.toString()}`,
1084
- { method: "GET" },
1120
+ { method: "GET", headers },
1085
1121
  );
1086
1122
  if (!result.url) {
1087
1123
  throw new RoomSocialError("Attachment sign failed", 502, result);
@@ -1174,6 +1210,7 @@ export async function socialRequest<T>(
1174
1210
  body?: unknown;
1175
1211
  signal?: AbortSignal;
1176
1212
  formData?: FormData;
1213
+ headers?: Record<string, string>;
1177
1214
  } = {},
1178
1215
  ): Promise<T> {
1179
1216
  const url = `${trimTrailingSlash(config.apiUrl)}${path}`;
@@ -1184,6 +1221,7 @@ export async function socialRequest<T>(
1184
1221
  Authorization: `Bearer ${config.apiKey}`,
1185
1222
  Accept: "application/json",
1186
1223
  ...(hasJsonBody ? { "Content-Type": "application/json" } : {}),
1224
+ ...(init.headers ?? {}),
1187
1225
  },
1188
1226
  ...(hasJsonBody
1189
1227
  ? { body: JSON.stringify(init.body) }
@@ -1263,12 +1301,72 @@ function trimTrailingSlash(value: string): string {
1263
1301
  return value.replace(/\/+$/, "");
1264
1302
  }
1265
1303
 
1304
+ /** Source/config/text extensions declared as text/plain so the server's
1305
+ * UTF-8 code-file lane accepts them. Markup extensions the server
1306
+ * blocklists (html, svg, xml, ...) are deliberately absent: they fall
1307
+ * through to application/octet-stream and the server rejects them. */
1308
+ const CODE_TEXT_EXTENSIONS = new Set([
1309
+ "js",
1310
+ "jsx",
1311
+ "ts",
1312
+ "tsx",
1313
+ "py",
1314
+ "rb",
1315
+ "go",
1316
+ "rs",
1317
+ "java",
1318
+ "c",
1319
+ "h",
1320
+ "cc",
1321
+ "cpp",
1322
+ "hpp",
1323
+ "cs",
1324
+ "php",
1325
+ "sh",
1326
+ "bash",
1327
+ "zsh",
1328
+ "fish",
1329
+ "ps1",
1330
+ "sql",
1331
+ "yaml",
1332
+ "yml",
1333
+ "toml",
1334
+ "ini",
1335
+ "cfg",
1336
+ "conf",
1337
+ "log",
1338
+ "diff",
1339
+ "patch",
1340
+ "tex",
1341
+ "r",
1342
+ "jl",
1343
+ "kt",
1344
+ "kts",
1345
+ "swift",
1346
+ "scala",
1347
+ "clj",
1348
+ "cljs",
1349
+ "ex",
1350
+ "exs",
1351
+ "erl",
1352
+ "lua",
1353
+ "pl",
1354
+ "pm",
1355
+ "dart",
1356
+ "gradle",
1357
+ "properties",
1358
+ "bat",
1359
+ "cmd",
1360
+ ]);
1361
+
1266
1362
  export function mimeFromFilename(filename: string): string {
1267
1363
  const lower = filename.toLowerCase();
1268
1364
  if (lower.endsWith(".png")) return "image/png";
1269
1365
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
1270
1366
  if (lower.endsWith(".webp")) return "image/webp";
1271
1367
  if (lower.endsWith(".gif")) return "image/gif";
1368
+ if (lower.endsWith(".mp4")) return "video/mp4";
1369
+ if (lower.endsWith(".webm")) return "video/webm";
1272
1370
  if (lower.endsWith(".pdf")) return "application/pdf";
1273
1371
  if (lower.endsWith(".txt")) return "text/plain";
1274
1372
  if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "text/markdown";
@@ -1283,5 +1381,7 @@ export function mimeFromFilename(filename: string): string {
1283
1381
  if (lower.endsWith(".pptx")) {
1284
1382
  return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
1285
1383
  }
1384
+ const dot = lower.lastIndexOf(".");
1385
+ if (dot !== -1 && CODE_TEXT_EXTENSIONS.has(lower.slice(dot + 1))) return "text/plain";
1286
1386
  return "application/octet-stream";
1287
1387
  }
@@ -53,8 +53,13 @@ export interface RoomFollowResult {
53
53
  }
54
54
 
55
55
  export interface RoomAttachment {
56
- /** Object key under room-attachments/; clients sign it at render time. */
57
- key: string;
56
+ /**
57
+ * Object key under room-attachments/; clients sign it at render time.
58
+ * Absent while scanStatus is "pending" on entries served to members: the
59
+ * store withholds the signable key until the scan clears, so render the
60
+ * metadata as a placeholder and do not attempt signing without a key.
61
+ */
62
+ key?: string | undefined;
58
63
  name: string;
59
64
  size: number;
60
65
  mime: string;
@@ -62,8 +67,16 @@ export interface RoomAttachment {
62
67
  }
63
68
 
64
69
  export interface RoomAttachmentUpload {
65
- /** The stored attachment plus its first signed URL, for immediate render. */
66
- attachment: RoomAttachment & { url: string; expiresAt: string | null };
70
+ /**
71
+ * The stored attachment plus its first signed URL, for immediate render.
72
+ * The uploader always receives the key; only member views of PENDING
73
+ * entries withhold it.
74
+ */
75
+ attachment: RoomAttachment & {
76
+ key: string;
77
+ url: string;
78
+ expiresAt: string | null;
79
+ };
67
80
  }
68
81
 
69
82
  export interface RoomPublicProfile {
@@ -126,6 +139,11 @@ export interface RoomDmMessage {
126
139
  /** Sealed DM: content is '' and the opaque envelope rides here. */
127
140
  secret?: RoomLedgerSecretPayload | null;
128
141
  metadata?: Record<string, unknown>;
142
+ /** DM image: a private dm-attachments/ key (signed on render) or a legacy
143
+ * public URL. Additive — old messages carry none. */
144
+ imageUrl?: string | null;
145
+ /** Aggregated reactions (per-emoji userId lists). Additive. */
146
+ reactions?: Array<{ emoji: string; users: string[] }>;
129
147
  }
130
148
 
131
149
  export interface RoomDmHistory {
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type {
2
2
  RoomAccessMode,
3
3
  RoomAttachment,
4
+ RoomAttachmentInput,
4
5
  RoomAuditEntry,
5
6
  RoomAuditLogPayload,
6
7
  RoomAuditLogRequestPayload,
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.5.0";
2
- export const RUNNER_VERSION = "0.5.0";
1
+ export const VERSION = "0.5.1";
2
+ export const RUNNER_VERSION = "0.5.1";