@openmarket/rooms-client 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type RoomAccessMode, type 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 RoomClientPlatform, type RoomDmHistoryResultPayload, type RoomDmMessagePayload, type RoomDmOpenedPayload, type RoomFriendPresence, type RoomInvitedPayload, type RoomLedgerEntry, type RoomMemberRemovedPayload, type RoomMemberRoleChangedPayload, type RoomMeta, type RoomPinsPayload, type RoomPresencePayload, type RoomReactorsPayload, type RoomReadStatePayload, type RoomReportedPayload, type RoomRole, type RoomSearchResultsPayload, type RoomSearchScope, type RoomSpace, type RoomThreadPayload, type RoomTopic, type RoomTopicAttention, type RoomTopicResultPayload, type RoomTopicsResultPayload, type RoomUserStatus, type RoomWhoPayload } from "./types.js";
2
2
  import { type RoomWebSocketCtor, RoomWsClient } from "./ws-client.js";
3
3
  export interface RoomClientConfig {
4
4
  url: string;
@@ -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;
@@ -283,9 +283,18 @@ export declare function joinRoom(config: RoomClientConfig, options: RoomJoinOpti
283
283
  /** Re-join a room on an already-connected client (used after a reconnect). */
284
284
  export declare function rejoinRoomOnClient(client: RoomWsClient, options: RoomJoinOptions): Promise<RoomBackfillPayload>;
285
285
  export declare function attachRejoinOnReconnect(client: RoomWsClient, options: RoomJoinOptions, latestSeq: () => number | undefined, onRejoined?: (backfill: RoomBackfillPayload) => void, onError?: (err: unknown) => void): () => void;
286
- export declare function postRoomMessage(client: RoomWsClient, room: string, text: string, inReplyTo?: number, attachments?: RoomAttachment[], topicId?: string): string;
286
+ /**
287
+ * Post into a joined room and return the frame's clientMsgId (what MESSAGE
288
+ * echoes carry back for optimistic-send reconciliation). `clientMsgId` is
289
+ * caller-suppliable so an offline send queue can retry one logical message
290
+ * under one stable id (same contract as sayRoom / sendRoomDmOnClient);
291
+ * omitted, it mints a UUID exactly as before. The relay validates the id
292
+ * (1..128 chars after trim); dedupe on (userId, clientMsgId) is server-side
293
+ * work and NOT implied here.
294
+ */
295
+ export declare function postRoomMessage(client: RoomWsClient, room: string, text: string, inReplyTo?: number, attachments?: RoomAttachmentInput[], topicId?: string, clientMsgId?: string): string;
287
296
  export declare function sendRoomTyping(client: RoomWsClient, room: string, state: "start" | "stop"): void;
288
- export declare function sendRoomStatus(client: RoomWsClient, status: RoomUserStatus, statusText?: string | null): void;
297
+ export declare function sendRoomStatus(client: RoomWsClient, status: RoomUserStatus, statusText?: string | null, platform?: RoomClientPlatform): void;
289
298
  /**
290
299
  * Fetch a joined room's meta (title, access, postingRoles, viewerRole) over
291
300
  * the live socket via a scoped SEARCH. Returns null on timeout/no match so
@@ -310,7 +319,18 @@ export declare function requestRoomReactors(client: RoomWsClient, request: {
310
319
  * persists it durably; a one-shot socket has no joined rooms, so live
311
320
  * rosters pick the change up from the store (the TUI/GUI send STATUS on
312
321
  * their live connection instead, which also broadcasts PRESENCE_DELTA). */
313
- export declare function setRoomStatusOnce(config: RoomClientConfig, status: RoomUserStatus, statusText?: string | null): Promise<void>;
322
+ export declare function setRoomStatusOnce(config: RoomClientConfig, status: RoomUserStatus, statusText?: string | null, platform?: RoomClientPlatform): Promise<void>;
323
+ /**
324
+ * Set the caller's room-level attention (notification posture) on a live
325
+ * client. The relay validates room participation, persists through the
326
+ * store, and acks with a READ_STATE frame scoped to this room whose
327
+ * attention map carries the persisted value (readState stays empty: cursors
328
+ * are untouched). Private per-user state; nothing broadcasts.
329
+ */
330
+ export declare function setRoomAttentionOnClient(client: RoomWsClient, request: {
331
+ room: string;
332
+ attention: RoomTopicAttention;
333
+ }): Promise<RoomReadStatePayload>;
314
334
  /** Add/remove one reaction (one-shot). Returns the updated entry. */
315
335
  export declare function reactRoom(config: RoomClientConfig, options: {
316
336
  room: string;
package/dist/client.js CHANGED
@@ -618,21 +618,30 @@ export function attachRejoinOnReconnect(client, options, latestSeq, onRejoined,
618
618
  .catch((err) => onError?.(err));
619
619
  });
620
620
  }
621
- export function postRoomMessage(client, room, text, inReplyTo, attachments, topicId) {
622
- const clientMsgId = randomUUID();
621
+ /**
622
+ * Post into a joined room and return the frame's clientMsgId (what MESSAGE
623
+ * echoes carry back for optimistic-send reconciliation). `clientMsgId` is
624
+ * caller-suppliable so an offline send queue can retry one logical message
625
+ * under one stable id (same contract as sayRoom / sendRoomDmOnClient);
626
+ * omitted, it mints a UUID exactly as before. The relay validates the id
627
+ * (1..128 chars after trim); dedupe on (userId, clientMsgId) is server-side
628
+ * work and NOT implied here.
629
+ */
630
+ export function postRoomMessage(client, room, text, inReplyTo, attachments, topicId, clientMsgId) {
631
+ const msgId = clientMsgId ?? randomUUID();
623
632
  client.send({
624
633
  type: RoomClientMessageType.POST,
625
634
  payload: {
626
635
  room,
627
636
  ...(text ? { text } : {}),
628
637
  ...(attachments?.length ? { attachments: toRoomAttachmentPayloads(attachments) } : {}),
629
- clientMsgId,
638
+ clientMsgId: msgId,
630
639
  ...(inReplyTo !== undefined ? { inReplyTo } : {}),
631
640
  ...(topicId ? { topicId } : {}),
632
641
  },
633
642
  timestamp: Date.now(),
634
643
  });
635
- return clientMsgId;
644
+ return msgId;
636
645
  }
637
646
  export function sendRoomTyping(client, room, state) {
638
647
  if (client.isGuest())
@@ -643,10 +652,16 @@ export function sendRoomTyping(client, room, state) {
643
652
  timestamp: Date.now(),
644
653
  });
645
654
  }
646
- export function sendRoomStatus(client, status, statusText) {
655
+ export function sendRoomStatus(client, status, statusText, platform) {
647
656
  client.send({
648
657
  type: RoomClientMessageType.STATUS,
649
- payload: { status, ...(statusText !== undefined ? { statusText } : {}) },
658
+ payload: {
659
+ status,
660
+ ...(statusText !== undefined ? { statusText } : {}),
661
+ // Session identity, declared once per connection (or on change): the
662
+ // relay carries it onto presence member entries.
663
+ ...(platform ? { platform } : {}),
664
+ },
650
665
  timestamp: Date.now(),
651
666
  });
652
667
  }
@@ -769,11 +784,11 @@ export async function requestRoomReactors(client, request) {
769
784
  * persists it durably; a one-shot socket has no joined rooms, so live
770
785
  * rosters pick the change up from the store (the TUI/GUI send STATUS on
771
786
  * their live connection instead, which also broadcasts PRESENCE_DELTA). */
772
- export async function setRoomStatusOnce(config, status, statusText) {
787
+ export async function setRoomStatusOnce(config, status, statusText, platform) {
773
788
  const client = new RoomWsClient(config);
774
789
  try {
775
790
  await client.connect();
776
- sendRoomStatus(client, status, statusText);
791
+ sendRoomStatus(client, status, statusText, platform);
777
792
  // STATUS has no ack frame: give the socket a beat to flush before close.
778
793
  await new Promise((resolve) => setTimeout(resolve, 200));
779
794
  }
@@ -781,6 +796,24 @@ export async function setRoomStatusOnce(config, status, statusText) {
781
796
  client.close();
782
797
  }
783
798
  }
799
+ /**
800
+ * Set the caller's room-level attention (notification posture) on a live
801
+ * client. The relay validates room participation, persists through the
802
+ * store, and acks with a READ_STATE frame scoped to this room whose
803
+ * attention map carries the persisted value (readState stays empty: cursors
804
+ * are untouched). Private per-user state; nothing broadcasts.
805
+ */
806
+ export async function setRoomAttentionOnClient(client, request) {
807
+ const requestId = randomUUID();
808
+ const room = normalizeRoomName(request.room);
809
+ client.send({
810
+ type: RoomClientMessageType.ROOM_SET_ATTENTION,
811
+ payload: { requestId, room, attention: request.attention },
812
+ timestamp: Date.now(),
813
+ });
814
+ const result = await client.waitFor(RoomServerMessageType.READ_STATE, (msg) => msg.payload.requestId === requestId, undefined, requestId);
815
+ return result.payload;
816
+ }
784
817
  /** One-shot session helper for the config-based mutation verbs below (the
785
818
  * agent tools' transport: connect, join, act, wait for the echo, close). */
786
819
  async function withRoomEcho(config, room, seq, send) {
@@ -48,6 +48,7 @@ export declare const RoomClientMessageType: {
48
48
  readonly TOPIC_RESOLVE: "TOPIC_RESOLVE";
49
49
  readonly TOPIC_SET_ATTENTION: "TOPIC_SET_ATTENTION";
50
50
  readonly TOPIC_LIST: "TOPIC_LIST";
51
+ readonly ROOM_SET_ATTENTION: "ROOM_SET_ATTENTION";
51
52
  };
52
53
  export type RoomClientMessageType = (typeof RoomClientMessageType)[keyof typeof RoomClientMessageType];
53
54
  export declare const RoomServerMessageType: {
@@ -127,6 +128,9 @@ export type RoomRole = "owner" | "admin" | "mod" | "member";
127
128
  * semantics of an absent value). */
128
129
  export type RoomTopicsPolicy = "off" | "allowed" | "required";
129
130
  export type RoomTopicAttention = "follow" | "default" | "mute";
131
+ /** Self-declared client class on STATUS frames; presence member entries
132
+ * carry it through so rosters can render where someone is connected from. */
133
+ export type RoomClientPlatform = "web" | "desktop" | "mobile" | "tui";
130
134
  export interface RoomMember {
131
135
  userId: string;
132
136
  username: string;
@@ -140,6 +144,8 @@ export interface RoomMember {
140
144
  joinedAt: number;
141
145
  avatarUrl?: string | null;
142
146
  statusText?: string | null;
147
+ /** Present when the member's session declared one via STATUS. */
148
+ platform?: RoomClientPlatform | undefined;
143
149
  }
144
150
  export interface RoomMeta {
145
151
  name: string;
@@ -164,6 +170,10 @@ export interface RoomMeta {
164
170
  * 2026-07-12 dial-default flip (post-flip relays always advertise the
165
171
  * stored value, "off" included); explicit "off" means topics are inert. */
166
172
  topicsPolicy?: RoomTopicsPolicy | undefined;
173
+ /** The viewer's room attention (notification posture), a per-viewer field
174
+ * like viewerRole: surfaced on rooms listings for the caller. Absent
175
+ * reads as "default". */
176
+ attention?: RoomTopicAttention | undefined;
167
177
  tags?: string[] | undefined;
168
178
  entities?: string[] | undefined;
169
179
  sourceRefs?: Array<Record<string, unknown>> | undefined;
@@ -217,13 +227,27 @@ export interface RoomLedgerEntry {
217
227
  roomRole?: RoomRole | null | undefined;
218
228
  }
219
229
  export interface RoomAttachment {
220
- /** Object key under room-attachments/; clients sign it at render time. */
221
- key: string;
230
+ /**
231
+ * Object key under room-attachments/; clients sign it at render time.
232
+ * Absent while scanStatus is "pending" on entries served to members: the
233
+ * store withholds the signable key until the scan clears, so render the
234
+ * metadata as a placeholder and do not attempt signing without a key.
235
+ */
236
+ key?: string | undefined;
222
237
  name: string;
223
238
  size: number;
224
239
  mime: string;
225
240
  scanStatus?: "pending" | "clean" | "held" | undefined;
226
241
  }
242
+ /**
243
+ * A postable attachment reference. Outbound frames must always carry the
244
+ * stored key (senders reference their own uploads, which include it); only
245
+ * read views of pending entries may be keyless, and those must never be
246
+ * forwarded back into a post.
247
+ */
248
+ export type RoomAttachmentInput = RoomAttachment & {
249
+ key: string;
250
+ };
227
251
  /**
228
252
  * A registry-backed topic inside a channel. The id is permanent: rename
229
253
  * changes `name` only, and links (`om://topic/<topicId>`) keep resolving.
@@ -281,7 +305,12 @@ export interface RoomPinsPayload {
281
305
  requestId?: string | undefined;
282
306
  }
283
307
  export interface RoomReadStatePayload {
308
+ /** Partial map: rooms absent from a reply are unchanged. */
284
309
  readState: Record<string, number>;
310
+ /** Per-room attention for the requesting user, keyed like readState.
311
+ * Present when the store surfaces it and on ROOM_SET_ATTENTION acks
312
+ * (which reply READ_STATE scoped to the one room, readState empty). */
313
+ attention?: Record<string, RoomTopicAttention> | undefined;
285
314
  requestId?: string | undefined;
286
315
  }
287
316
  export interface RoomSearchResultsPayload {
@@ -729,6 +758,15 @@ export interface RoomTopicSetAttentionPayload {
729
758
  topicId: string;
730
759
  attention: RoomTopicAttention;
731
760
  }
761
+ /** Room-level attention (notification posture). Private per-user state on
762
+ * the caller's read row; topic attention wins for topic-scoped messages
763
+ * and room attention wins over space attention. The relay acks with a
764
+ * READ_STATE frame scoped to the room (see RoomReadStatePayload). */
765
+ export interface RoomSetAttentionPayload {
766
+ requestId?: string;
767
+ room: string;
768
+ attention: RoomTopicAttention;
769
+ }
732
770
  export interface RoomTopicListPayload {
733
771
  requestId?: string;
734
772
  room: string;
@@ -843,7 +881,7 @@ export type RoomClientMessage = {
843
881
  payload: {
844
882
  room: string;
845
883
  text?: string;
846
- attachments?: RoomAttachment[];
884
+ attachments?: RoomAttachmentInput[];
847
885
  inReplyTo?: number;
848
886
  /** Post directly into a topic; the room's linear stream is unaffected. */
849
887
  topicId?: string;
@@ -1003,6 +1041,7 @@ export type RoomClientMessage = {
1003
1041
  payload: {
1004
1042
  status: RoomUserStatus;
1005
1043
  statusText?: string | null;
1044
+ platform?: RoomClientPlatform;
1006
1045
  };
1007
1046
  timestamp?: number;
1008
1047
  } | {
@@ -1078,6 +1117,10 @@ export type RoomClientMessage = {
1078
1117
  type: typeof RoomClientMessageType.TOPIC_LIST;
1079
1118
  payload: RoomTopicListPayload;
1080
1119
  timestamp?: number;
1120
+ } | {
1121
+ type: typeof RoomClientMessageType.ROOM_SET_ATTENTION;
1122
+ payload: RoomSetAttentionPayload;
1123
+ timestamp?: number;
1081
1124
  };
1082
1125
  export type RoomServerMessage = {
1083
1126
  type: typeof RoomServerMessageType.AUTH_SUCCESS;
@@ -48,6 +48,7 @@ export const RoomClientMessageType = {
48
48
  TOPIC_RESOLVE: "TOPIC_RESOLVE",
49
49
  TOPIC_SET_ATTENTION: "TOPIC_SET_ATTENTION",
50
50
  TOPIC_LIST: "TOPIC_LIST",
51
+ ROOM_SET_ATTENTION: "ROOM_SET_ATTENTION",
51
52
  };
52
53
  export const RoomServerMessageType = {
53
54
  AUTH_SUCCESS: "AUTH_SUCCESS",
@@ -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";
@@ -337,7 +340,10 @@ export declare function restoreRoomDoc(config: RoomSocialConfig, docId: string,
337
340
  note?: string;
338
341
  asAgent?: boolean;
339
342
  }): Promise<RoomDoc>;
340
- 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<{
341
347
  url: string;
342
348
  expiresAt: string | null;
343
349
  }>;
@@ -363,6 +369,7 @@ export declare function socialRequest<T>(config: RoomSocialConfig, path: string,
363
369
  body?: unknown;
364
370
  signal?: AbortSignal;
365
371
  formData?: FormData;
372
+ headers?: Record<string, string>;
366
373
  }): Promise<T>;
367
374
  export declare function cleanUsername(username: string): string;
368
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;
@@ -517,9 +520,22 @@ export async function restoreRoomDoc(config, docId, input = {}) {
517
520
  const result = await socialRequest(config, `/docs/${encodeURIComponent(docId)}/restore`, { method: "POST", body: input });
518
521
  return result.doc;
519
522
  }
520
- 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).
521
528
  const params = new URLSearchParams({ key });
522
- 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 });
523
539
  if (!result.url) {
524
540
  throw new RoomSocialError("Attachment sign failed", 502, result);
525
541
  }
@@ -587,6 +603,7 @@ export async function socialRequest(config, path, init = {}) {
587
603
  Authorization: `Bearer ${config.apiKey}`,
588
604
  Accept: "application/json",
589
605
  ...(hasJsonBody ? { "Content-Type": "application/json" } : {}),
606
+ ...(init.headers ?? {}),
590
607
  },
591
608
  ...(hasJsonBody
592
609
  ? { body: JSON.stringify(init.body) }
@@ -658,6 +675,63 @@ function requestPath(url) {
658
675
  function trimTrailingSlash(value) {
659
676
  return value.replace(/\/+$/, "");
660
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
+ ]);
661
735
  export function mimeFromFilename(filename) {
662
736
  const lower = filename.toLowerCase();
663
737
  if (lower.endsWith(".png"))
@@ -668,6 +742,10 @@ export function mimeFromFilename(filename) {
668
742
  return "image/webp";
669
743
  if (lower.endsWith(".gif"))
670
744
  return "image/gif";
745
+ if (lower.endsWith(".mp4"))
746
+ return "video/mp4";
747
+ if (lower.endsWith(".webm"))
748
+ return "video/webm";
671
749
  if (lower.endsWith(".pdf"))
672
750
  return "application/pdf";
673
751
  if (lower.endsWith(".txt"))
@@ -687,5 +765,8 @@ export function mimeFromFilename(filename) {
687
765
  if (lower.endsWith(".pptx")) {
688
766
  return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
689
767
  }
768
+ const dot = lower.lastIndexOf(".");
769
+ if (dot !== -1 && CODE_TEXT_EXTENSIONS.has(lower.slice(dot + 1)))
770
+ return "text/plain";
690
771
  return "application/octet-stream";
691
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
  };
@@ -120,6 +130,9 @@ export interface RoomDmMessage {
120
130
  emoji: string;
121
131
  users: string[];
122
132
  }>;
133
+ /** Client-minted idempotency tag echoed back on DM sends, so a sender's
134
+ * durable queue can settle its optimistic row. Additive (0.4.0). */
135
+ clientMsgId?: string;
123
136
  }
124
137
  export interface RoomDmHistory {
125
138
  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, RoomClientPlatform, RoomCreatedPayload, RoomCreatePayload, RoomDmHistoryPayload, RoomDmHistoryResultPayload, RoomDmInboxUpdatedPayload, RoomDmMessage, RoomDmMessagePayload, RoomDmOpenedPayload, RoomDmOpenPayload, RoomDmParticipant, RoomDmReadPayload, RoomDmSendPayload, RoomEntryUpdatedPayload, RoomErrorPayload, RoomFriendPresence, RoomInvitedPayload, RoomInvitePayload, RoomLedgerEntry, RoomMember, RoomMemberRemovedPayload, RoomMemberRoleChangedPayload, RoomMemberTier, RoomMessagePayload, RoomMeta, RoomModerateMemberPayload, RoomMutedPayload, RoomPinsPayload, RoomPresenceDeltaPayload, RoomPresencePayload, RoomReactorsPayload, RoomReadStatePayload, RoomRemoveMemberPayload, RoomReportedPayload, RoomReportPayload, RoomResyncPayload, RoomRole, RoomRoleChangePayload, RoomSearchPayload, RoomSearchResultsPayload, RoomSearchScope, RoomServerMessage, RoomServerMessageOf, RoomSetAttentionPayload, RoomSetReadOnlyPayload, RoomSpace, RoomSpaceJoinedPayload, RoomSpacesPayload, RoomThreadPayload, RoomTopic, RoomTopicAttention, RoomTopicBucketState, RoomTopicCreatePayload, RoomTopicListPayload, RoomTopicMergePayload, RoomTopicMovePayload, RoomTopicRenamePayload, RoomTopicResolvePayload, RoomTopicResultPayload, RoomTopicSetAttentionPayload, RoomTopicsPolicy, RoomTopicsResultPayload, RoomTopicUpdatedPayload, RoomTypingPayload, RoomUserStatus, RoomWhoPayload, } from "./shared/rooms-protocol.js";
2
2
  export { applyRoomPresenceDelta, isSystemRoomLedgerEntryType, ROOM_PRESENCE_SAMPLE_LIMIT, RoomClientMessageType, RoomLedgerEntryType, RoomServerMessageType, SUPPORTED_ROOM_PROTOCOL_VERSION, } from "./shared/rooms-protocol.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmarket/rooms-client",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "OM Rooms protocol client: wire types, WebSocket + REST clients, and chat view-models. Browser-safe (no node:/bun: imports).",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/client.ts CHANGED
@@ -7,12 +7,13 @@ 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,
14
14
  type RoomClientMessage,
15
15
  RoomClientMessageType,
16
+ type RoomClientPlatform,
16
17
  type RoomDmHistoryResultPayload,
17
18
  type RoomDmMessagePayload,
18
19
  type RoomDmOpenedPayload,
@@ -82,7 +83,7 @@ export interface RoomJoinOptions {
82
83
 
83
84
  export interface RoomSayOptions extends RoomJoinOptions {
84
85
  text?: string;
85
- attachments?: RoomAttachment[];
86
+ attachments?: RoomAttachmentInput[];
86
87
  inReplyTo?: number;
87
88
  /** Post directly into a topic; the room's linear stream is unaffected. */
88
89
  topicId?: string;
@@ -261,7 +262,9 @@ export interface RoomJoinedSession {
261
262
  client: RoomWsClient;
262
263
  }
263
264
 
264
- function toRoomAttachmentPayloads(attachments: readonly RoomAttachment[]): RoomAttachment[] {
265
+ function toRoomAttachmentPayloads(
266
+ attachments: readonly RoomAttachmentInput[],
267
+ ): RoomAttachmentInput[] {
265
268
  return attachments.map(({ key, name, size, mime }) => ({ key, name, size, mime }));
266
269
  }
267
270
 
@@ -1145,28 +1148,38 @@ export function attachRejoinOnReconnect(
1145
1148
  });
1146
1149
  }
1147
1150
 
1151
+ /**
1152
+ * Post into a joined room and return the frame's clientMsgId (what MESSAGE
1153
+ * echoes carry back for optimistic-send reconciliation). `clientMsgId` is
1154
+ * caller-suppliable so an offline send queue can retry one logical message
1155
+ * under one stable id (same contract as sayRoom / sendRoomDmOnClient);
1156
+ * omitted, it mints a UUID exactly as before. The relay validates the id
1157
+ * (1..128 chars after trim); dedupe on (userId, clientMsgId) is server-side
1158
+ * work and NOT implied here.
1159
+ */
1148
1160
  export function postRoomMessage(
1149
1161
  client: RoomWsClient,
1150
1162
  room: string,
1151
1163
  text: string,
1152
1164
  inReplyTo?: number,
1153
- attachments?: RoomAttachment[],
1165
+ attachments?: RoomAttachmentInput[],
1154
1166
  topicId?: string,
1167
+ clientMsgId?: string,
1155
1168
  ): string {
1156
- const clientMsgId = randomUUID();
1169
+ const msgId = clientMsgId ?? randomUUID();
1157
1170
  client.send({
1158
1171
  type: RoomClientMessageType.POST,
1159
1172
  payload: {
1160
1173
  room,
1161
1174
  ...(text ? { text } : {}),
1162
1175
  ...(attachments?.length ? { attachments: toRoomAttachmentPayloads(attachments) } : {}),
1163
- clientMsgId,
1176
+ clientMsgId: msgId,
1164
1177
  ...(inReplyTo !== undefined ? { inReplyTo } : {}),
1165
1178
  ...(topicId ? { topicId } : {}),
1166
1179
  },
1167
1180
  timestamp: Date.now(),
1168
1181
  });
1169
- return clientMsgId;
1182
+ return msgId;
1170
1183
  }
1171
1184
 
1172
1185
  export function sendRoomTyping(client: RoomWsClient, room: string, state: "start" | "stop"): void {
@@ -1182,10 +1195,17 @@ export function sendRoomStatus(
1182
1195
  client: RoomWsClient,
1183
1196
  status: RoomUserStatus,
1184
1197
  statusText?: string | null,
1198
+ platform?: RoomClientPlatform,
1185
1199
  ): void {
1186
1200
  client.send({
1187
1201
  type: RoomClientMessageType.STATUS,
1188
- payload: { status, ...(statusText !== undefined ? { statusText } : {}) },
1202
+ payload: {
1203
+ status,
1204
+ ...(statusText !== undefined ? { statusText } : {}),
1205
+ // Session identity, declared once per connection (or on change): the
1206
+ // relay carries it onto presence member entries.
1207
+ ...(platform ? { platform } : {}),
1208
+ },
1189
1209
  timestamp: Date.now(),
1190
1210
  });
1191
1211
  }
@@ -1391,11 +1411,12 @@ export async function setRoomStatusOnce(
1391
1411
  config: RoomClientConfig,
1392
1412
  status: RoomUserStatus,
1393
1413
  statusText?: string | null,
1414
+ platform?: RoomClientPlatform,
1394
1415
  ): Promise<void> {
1395
1416
  const client = new RoomWsClient(config);
1396
1417
  try {
1397
1418
  await client.connect();
1398
- sendRoomStatus(client, status, statusText);
1419
+ sendRoomStatus(client, status, statusText, platform);
1399
1420
  // STATUS has no ack frame: give the socket a beat to flush before close.
1400
1421
  await new Promise((resolve) => setTimeout(resolve, 200));
1401
1422
  } finally {
@@ -1403,6 +1424,33 @@ export async function setRoomStatusOnce(
1403
1424
  }
1404
1425
  }
1405
1426
 
1427
+ /**
1428
+ * Set the caller's room-level attention (notification posture) on a live
1429
+ * client. The relay validates room participation, persists through the
1430
+ * store, and acks with a READ_STATE frame scoped to this room whose
1431
+ * attention map carries the persisted value (readState stays empty: cursors
1432
+ * are untouched). Private per-user state; nothing broadcasts.
1433
+ */
1434
+ export async function setRoomAttentionOnClient(
1435
+ client: RoomWsClient,
1436
+ request: { room: string; attention: RoomTopicAttention },
1437
+ ): Promise<RoomReadStatePayload> {
1438
+ const requestId = randomUUID();
1439
+ const room = normalizeRoomName(request.room);
1440
+ client.send({
1441
+ type: RoomClientMessageType.ROOM_SET_ATTENTION,
1442
+ payload: { requestId, room, attention: request.attention },
1443
+ timestamp: Date.now(),
1444
+ });
1445
+ const result = await client.waitFor(
1446
+ RoomServerMessageType.READ_STATE,
1447
+ (msg) => msg.payload.requestId === requestId,
1448
+ undefined,
1449
+ requestId,
1450
+ );
1451
+ return result.payload;
1452
+ }
1453
+
1406
1454
  /** One-shot session helper for the config-based mutation verbs below (the
1407
1455
  * agent tools' transport: connect, join, act, wait for the echo, close). */
1408
1456
  async function withRoomEcho(
@@ -48,6 +48,7 @@ export const RoomClientMessageType = {
48
48
  TOPIC_RESOLVE: "TOPIC_RESOLVE",
49
49
  TOPIC_SET_ATTENTION: "TOPIC_SET_ATTENTION",
50
50
  TOPIC_LIST: "TOPIC_LIST",
51
+ ROOM_SET_ATTENTION: "ROOM_SET_ATTENTION",
51
52
  } as const;
52
53
 
53
54
  export type RoomClientMessageType =
@@ -152,6 +153,9 @@ export type RoomRole = "owner" | "admin" | "mod" | "member";
152
153
  * semantics of an absent value). */
153
154
  export type RoomTopicsPolicy = "off" | "allowed" | "required";
154
155
  export type RoomTopicAttention = "follow" | "default" | "mute";
156
+ /** Self-declared client class on STATUS frames; presence member entries
157
+ * carry it through so rosters can render where someone is connected from. */
158
+ export type RoomClientPlatform = "web" | "desktop" | "mobile" | "tui";
155
159
 
156
160
  export interface RoomMember {
157
161
  userId: string;
@@ -166,6 +170,8 @@ export interface RoomMember {
166
170
  joinedAt: number;
167
171
  avatarUrl?: string | null;
168
172
  statusText?: string | null;
173
+ /** Present when the member's session declared one via STATUS. */
174
+ platform?: RoomClientPlatform | undefined;
169
175
  }
170
176
 
171
177
  export interface RoomMeta {
@@ -191,6 +197,10 @@ export interface RoomMeta {
191
197
  * 2026-07-12 dial-default flip (post-flip relays always advertise the
192
198
  * stored value, "off" included); explicit "off" means topics are inert. */
193
199
  topicsPolicy?: RoomTopicsPolicy | undefined;
200
+ /** The viewer's room attention (notification posture), a per-viewer field
201
+ * like viewerRole: surfaced on rooms listings for the caller. Absent
202
+ * reads as "default". */
203
+ attention?: RoomTopicAttention | undefined;
194
204
  tags?: string[] | undefined;
195
205
  entities?: string[] | undefined;
196
206
  sourceRefs?: Array<Record<string, unknown>> | undefined;
@@ -246,14 +256,27 @@ export interface RoomLedgerEntry {
246
256
  }
247
257
 
248
258
  export interface RoomAttachment {
249
- /** Object key under room-attachments/; clients sign it at render time. */
250
- key: string;
259
+ /**
260
+ * Object key under room-attachments/; clients sign it at render time.
261
+ * Absent while scanStatus is "pending" on entries served to members: the
262
+ * store withholds the signable key until the scan clears, so render the
263
+ * metadata as a placeholder and do not attempt signing without a key.
264
+ */
265
+ key?: string | undefined;
251
266
  name: string;
252
267
  size: number;
253
268
  mime: string;
254
269
  scanStatus?: "pending" | "clean" | "held" | undefined;
255
270
  }
256
271
 
272
+ /**
273
+ * A postable attachment reference. Outbound frames must always carry the
274
+ * stored key (senders reference their own uploads, which include it); only
275
+ * read views of pending entries may be keyless, and those must never be
276
+ * forwarded back into a post.
277
+ */
278
+ export type RoomAttachmentInput = RoomAttachment & { key: string };
279
+
257
280
  /**
258
281
  * A registry-backed topic inside a channel. The id is permanent: rename
259
282
  * changes `name` only, and links (`om://topic/<topicId>`) keep resolving.
@@ -318,7 +341,12 @@ export interface RoomPinsPayload {
318
341
  }
319
342
 
320
343
  export interface RoomReadStatePayload {
344
+ /** Partial map: rooms absent from a reply are unchanged. */
321
345
  readState: Record<string, number>;
346
+ /** Per-room attention for the requesting user, keyed like readState.
347
+ * Present when the store surfaces it and on ROOM_SET_ATTENTION acks
348
+ * (which reply READ_STATE scoped to the one room, readState empty). */
349
+ attention?: Record<string, RoomTopicAttention> | undefined;
322
350
  requestId?: string | undefined;
323
351
  }
324
352
 
@@ -849,6 +877,16 @@ export interface RoomTopicSetAttentionPayload {
849
877
  attention: RoomTopicAttention;
850
878
  }
851
879
 
880
+ /** Room-level attention (notification posture). Private per-user state on
881
+ * the caller's read row; topic attention wins for topic-scoped messages
882
+ * and room attention wins over space attention. The relay acks with a
883
+ * READ_STATE frame scoped to the room (see RoomReadStatePayload). */
884
+ export interface RoomSetAttentionPayload {
885
+ requestId?: string;
886
+ room: string;
887
+ attention: RoomTopicAttention;
888
+ }
889
+
852
890
  export interface RoomTopicListPayload {
853
891
  requestId?: string;
854
892
  room: string;
@@ -968,7 +1006,7 @@ export type RoomClientMessage =
968
1006
  payload: {
969
1007
  room: string;
970
1008
  text?: string;
971
- attachments?: RoomAttachment[];
1009
+ attachments?: RoomAttachmentInput[];
972
1010
  inReplyTo?: number;
973
1011
  /** Post directly into a topic; the room's linear stream is unaffected. */
974
1012
  topicId?: string;
@@ -1116,7 +1154,11 @@ export type RoomClientMessage =
1116
1154
  }
1117
1155
  | {
1118
1156
  type: typeof RoomClientMessageType.STATUS;
1119
- payload: { status: RoomUserStatus; statusText?: string | null };
1157
+ payload: {
1158
+ status: RoomUserStatus;
1159
+ statusText?: string | null;
1160
+ platform?: RoomClientPlatform;
1161
+ };
1120
1162
  timestamp?: number;
1121
1163
  }
1122
1164
  | {
@@ -1200,6 +1242,11 @@ export type RoomClientMessage =
1200
1242
  type: typeof RoomClientMessageType.TOPIC_LIST;
1201
1243
  payload: RoomTopicListPayload;
1202
1244
  timestamp?: number;
1245
+ }
1246
+ | {
1247
+ type: typeof RoomClientMessageType.ROOM_SET_ATTENTION;
1248
+ payload: RoomSetAttentionPayload;
1249
+ timestamp?: number;
1203
1250
  };
1204
1251
 
1205
1252
  export type RoomServerMessage =
@@ -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";
@@ -1097,12 +1100,24 @@ export async function getRoomAttachmentUrl(
1097
1100
  config: RoomSocialConfig,
1098
1101
  room: string,
1099
1102
  key: string,
1103
+ opts: { name?: string; download?: boolean } = {},
1100
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).
1101
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";
1102
1117
  const result = await socialRequest<{ url?: string; expiresAt?: string | null }>(
1103
1118
  config,
1104
1119
  `/rooms/${encodeURIComponent(cleanRoomId(room))}/attachment-url?${params.toString()}`,
1105
- { method: "GET" },
1120
+ { method: "GET", headers },
1106
1121
  );
1107
1122
  if (!result.url) {
1108
1123
  throw new RoomSocialError("Attachment sign failed", 502, result);
@@ -1195,6 +1210,7 @@ export async function socialRequest<T>(
1195
1210
  body?: unknown;
1196
1211
  signal?: AbortSignal;
1197
1212
  formData?: FormData;
1213
+ headers?: Record<string, string>;
1198
1214
  } = {},
1199
1215
  ): Promise<T> {
1200
1216
  const url = `${trimTrailingSlash(config.apiUrl)}${path}`;
@@ -1205,6 +1221,7 @@ export async function socialRequest<T>(
1205
1221
  Authorization: `Bearer ${config.apiKey}`,
1206
1222
  Accept: "application/json",
1207
1223
  ...(hasJsonBody ? { "Content-Type": "application/json" } : {}),
1224
+ ...(init.headers ?? {}),
1208
1225
  },
1209
1226
  ...(hasJsonBody
1210
1227
  ? { body: JSON.stringify(init.body) }
@@ -1284,12 +1301,72 @@ function trimTrailingSlash(value: string): string {
1284
1301
  return value.replace(/\/+$/, "");
1285
1302
  }
1286
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
+
1287
1362
  export function mimeFromFilename(filename: string): string {
1288
1363
  const lower = filename.toLowerCase();
1289
1364
  if (lower.endsWith(".png")) return "image/png";
1290
1365
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
1291
1366
  if (lower.endsWith(".webp")) return "image/webp";
1292
1367
  if (lower.endsWith(".gif")) return "image/gif";
1368
+ if (lower.endsWith(".mp4")) return "video/mp4";
1369
+ if (lower.endsWith(".webm")) return "video/webm";
1293
1370
  if (lower.endsWith(".pdf")) return "application/pdf";
1294
1371
  if (lower.endsWith(".txt")) return "text/plain";
1295
1372
  if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "text/markdown";
@@ -1304,5 +1381,7 @@ export function mimeFromFilename(filename: string): string {
1304
1381
  if (lower.endsWith(".pptx")) {
1305
1382
  return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
1306
1383
  }
1384
+ const dot = lower.lastIndexOf(".");
1385
+ if (dot !== -1 && CODE_TEXT_EXTENSIONS.has(lower.slice(dot + 1))) return "text/plain";
1307
1386
  return "application/octet-stream";
1308
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 {
@@ -131,6 +144,9 @@ export interface RoomDmMessage {
131
144
  imageUrl?: string | null;
132
145
  /** Aggregated reactions (per-emoji userId lists). Additive. */
133
146
  reactions?: Array<{ emoji: string; users: string[] }>;
147
+ /** Client-minted idempotency tag echoed back on DM sends, so a sender's
148
+ * durable queue can settle its optimistic row. Additive (0.4.0). */
149
+ clientMsgId?: string;
134
150
  }
135
151
 
136
152
  export interface RoomDmHistory {
package/src/types.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  export type {
2
2
  RoomAccessMode,
3
3
  RoomAttachment,
4
+ RoomAttachmentInput,
4
5
  RoomAuditEntry,
5
6
  RoomAuditLogPayload,
6
7
  RoomAuditLogRequestPayload,
7
8
  RoomAuthSuccessPayload,
8
9
  RoomBackfillPayload,
9
10
  RoomClientMessage,
11
+ RoomClientPlatform,
10
12
  RoomCreatedPayload,
11
13
  RoomCreatePayload,
12
14
  RoomDmHistoryPayload,
@@ -49,6 +51,7 @@ export type {
49
51
  RoomSearchScope,
50
52
  RoomServerMessage,
51
53
  RoomServerMessageOf,
54
+ RoomSetAttentionPayload,
52
55
  RoomSetReadOnlyPayload,
53
56
  RoomSpace,
54
57
  RoomSpaceJoinedPayload,