@openmarket/rooms-client 0.6.0 → 0.7.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.
@@ -419,10 +419,29 @@ export async function addSpaceMember(config, spaceId, input) {
419
419
  const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members`, { method: "POST", body: input });
420
420
  return result.member;
421
421
  }
422
- /** Create (or refresh) a pending invitation. Membership needs their accept. */
422
+ /** Create (or refresh) a pending invitation. Membership needs their accept.
423
+ * `expiresAt` (ISO date, future, at most 30 days out) puts a deadline on
424
+ * it; re-inviting without one clears any previous deadline. Fail-closed on
425
+ * servers older than 0.6.0: they persist the invite but drop the deadline,
426
+ * so a deadline the echo omits revokes the invite and throws. */
423
427
  export async function createSpaceInvite(config, spaceId, input) {
424
428
  const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/invites`, { method: "POST", body: input });
425
- return result.invite;
429
+ const invite = result.invite;
430
+ // Presence check, not equality: servers may normalize the ISO string.
431
+ // declineSpaceInvite doubles as the manager-side revoke (the store
432
+ // deletes the row for invitee OR manager), so the inviter can retract
433
+ // the invite it just created.
434
+ if (input.expiresAt !== undefined && invite && typeof invite.expiresAt !== "string") {
435
+ const details = { spaceId, inviteId: invite.inviteId, requestedExpiresAt: input.expiresAt };
436
+ try {
437
+ await declineSpaceInvite(config, invite.inviteId);
438
+ }
439
+ catch (revokeError) {
440
+ throw new RoomSocialError("Server does not support invite expiry, and revoking the invite failed: a standing (never-expiring) invite may be live", 502, { ...details, revokeError: revokeError.message });
441
+ }
442
+ throw new RoomSocialError("Server does not support invite expiry; the standing invite was revoked", 502, details);
443
+ }
444
+ return invite;
426
445
  }
427
446
  /** The caller's own pending invitations. */
428
447
  export async function listMySpaceInvites(config) {
@@ -443,11 +462,46 @@ export async function declineSpaceInvite(config, inviteId) {
443
462
  method: "POST",
444
463
  });
445
464
  }
446
- /** Mint (or rotate) the shareable join code; plaintext comes back once. */
447
- export async function setSpaceJoinCode(config, spaceId) {
448
- return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/join-code`, {
465
+ /** Mint (or rotate) the shareable join code; plaintext comes back once.
466
+ * Optional caps: `expiresAt` (ISO date, future, at most 30 days out) and
467
+ * `maxUses` (1..10000). Rotation is a fresh grant: the new code starts at
468
+ * zero uses and takes exactly the caps given here (omitted caps clear the
469
+ * previous ones). Servers older than 0.6.0 echo neither cap field; they
470
+ * mint the code but drop the caps, so a requested cap the echo omits
471
+ * fail-closes: the fresh code is revoked and the call throws rather than
472
+ * hand back a live UNLIMITED code the caller believes capped. */
473
+ export async function setSpaceJoinCode(config, spaceId, options = {}) {
474
+ const body = {};
475
+ if (options.expiresAt !== undefined)
476
+ body.expiresAt = options.expiresAt;
477
+ if (options.maxUses !== undefined)
478
+ body.maxUses = options.maxUses;
479
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/join-code`, {
449
480
  method: "PUT",
481
+ // Capless mints keep the historical bodiless PUT on the wire.
482
+ ...(Object.keys(body).length > 0 ? { body } : {}),
450
483
  });
484
+ // Presence check for expiresAt (servers may normalize the ISO string),
485
+ // numeric equality for maxUses.
486
+ const droppedExpiry = body.expiresAt !== undefined && typeof result.expiresAt !== "string";
487
+ const droppedMaxUses = body.maxUses !== undefined && result.maxUses !== body.maxUses;
488
+ if (droppedExpiry || droppedMaxUses) {
489
+ // Details deliberately omit the plaintext code: RoomSocialError.details
490
+ // ends up in logs.
491
+ const details = {
492
+ spaceId,
493
+ requested: body,
494
+ echoed: { expiresAt: result.expiresAt ?? null, maxUses: result.maxUses ?? null },
495
+ };
496
+ try {
497
+ await clearSpaceJoinCode(config, spaceId);
498
+ }
499
+ catch (revokeError) {
500
+ throw new RoomSocialError("Server does not support join-code caps, and revoking the code failed: an uncapped join code may be live", 502, { ...details, revokeError: revokeError.message });
501
+ }
502
+ throw new RoomSocialError("Server does not support join-code caps; the uncapped code was revoked", 502, details);
503
+ }
504
+ return result;
451
505
  }
452
506
  export async function clearSpaceJoinCode(config, spaceId) {
453
507
  return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/join-code`, {
@@ -651,6 +705,288 @@ export async function listRoomNotifications(config, options = {}) {
651
705
  lastCursorId: result.lastCursorId ?? null,
652
706
  };
653
707
  }
708
+ /**
709
+ * Mark a room unread from `seq` (Discord-style): the room-wide read cursor
710
+ * drops to seq - 1 (server-clamped at 0) so the unread divider and pill
711
+ * return. The one sanctioned cursor-regression path; mention rows an
712
+ * earlier ack swept stay swept, and nothing is re-notified.
713
+ */
714
+ export async function markRoomUnread(config, room, seq) {
715
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/read/unread`, { method: "POST", body: { seq } });
716
+ return { success: result.success === true };
717
+ }
718
+ /**
719
+ * Set the caller's room-level attention over REST, the path that carries
720
+ * mute-with-duration: `untilMs` (epoch ms, valid only with 'mute') stamps
721
+ * the expiry that read state later surfaces as `muteUntil`; the mute lapses
722
+ * server-side and reads as 'default' afterwards. Every write settles the
723
+ * expiry, so follow/default (and a plain permanent mute) clear a previous
724
+ * one. The pinned ROOM_SET_ATTENTION frame (setRoomAttentionOnClient)
725
+ * predates `untilMs` and stays the live-session path for permanent
726
+ * postures; servers older than mute-with-duration strip the field and land
727
+ * the mute permanent, so a requested `untilMs` the echo omits fail-closes:
728
+ * the attention is reset and the call throws rather than leave an
729
+ * unbounded mute the caller believes timed. Echoes the persisted
730
+ * attention; `muteUntil` is the ISO expiry, null when none stands.
731
+ */
732
+ export async function setRoomAttentionRest(config, room, attention, options = {}) {
733
+ const body = { attention };
734
+ if (options.untilMs !== undefined)
735
+ body.untilMs = options.untilMs;
736
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/attention`, {
737
+ method: "POST",
738
+ body,
739
+ });
740
+ const muteUntil = typeof result.muteUntil === "string" ? result.muteUntil : null;
741
+ if (options.untilMs !== undefined && muteUntil === null) {
742
+ // The undo cannot know the caller's prior attention, so it deliberately
743
+ // lands 'default' (the same neutral a lapsed timed mute reads as, see
744
+ // resolveTopicAttention) rather than leave the unbounded mute standing.
745
+ try {
746
+ await setRoomAttentionRest(config, room, "default");
747
+ }
748
+ catch (undoError) {
749
+ throw new RoomSocialError("Server does not support timed mutes, and undoing the mute failed: a permanent mute may be standing", 502, { room, undoError: undoError.message });
750
+ }
751
+ throw new RoomSocialError("Server does not support timed mutes; the permanent mute was undone (attention is now 'default')", 502, { room });
752
+ }
753
+ return { attention: result.attention ?? attention, muteUntil };
754
+ }
755
+ /**
756
+ * Save a message for later (personal, per-user; nothing broadcasts).
757
+ * Idempotent: re-saving an already bookmarked event echoes the standing
758
+ * row with its original createdAt.
759
+ */
760
+ export async function addRoomBookmark(config, room, seq) {
761
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/bookmarks`, { method: "POST", body: { seq } });
762
+ if (!result.bookmark) {
763
+ throw new RoomSocialError("Bookmark save returned no row", 502, result);
764
+ }
765
+ return result.bookmark;
766
+ }
767
+ /** Remove a bookmark. Idempotent, and deliberately NOT gated on room
768
+ * readability: cleanup keeps working after lost membership or a deleted
769
+ * target (`removed` is false when no row stood). */
770
+ /** Sequence numbers ride URL paths; a JavaScript caller can pass any value
771
+ * despite the type annotation, and fetch normalizes dot segments, so a seq
772
+ * of ".." would rewrite a bookmark DELETE onto the ROOM route (archive).
773
+ * Runtime-require a nonnegative safe integer before interpolating. */
774
+ function requireSeq(seq) {
775
+ if (!Number.isSafeInteger(seq) || seq < 0) {
776
+ throw new RoomSocialError("Invalid sequence number", 400, { seq: String(seq) });
777
+ }
778
+ return seq;
779
+ }
780
+ export async function removeRoomBookmark(config, room, seq) {
781
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/bookmarks/${requireSeq(seq)}`, { method: "DELETE" });
782
+ return { removed: result.removed === true };
783
+ }
784
+ /**
785
+ * The caller's bookmarks across every room (GET /users/bookmarks), newest
786
+ * first with the notification inbox's composite cursor: pass a page's
787
+ * lastCursor and lastCursorId back TOGETHER for the next page. Rows in
788
+ * rooms the caller can no longer read are subtracted server-side.
789
+ */
790
+ export async function listUserBookmarks(config, options = {}) {
791
+ const params = new URLSearchParams();
792
+ if (options.limit !== undefined)
793
+ params.set("limit", String(options.limit));
794
+ if (options.cursor !== undefined)
795
+ params.set("cursor", options.cursor);
796
+ if (options.cursorId !== undefined)
797
+ params.set("cursorId", options.cursorId);
798
+ const init = {};
799
+ if (options.signal)
800
+ init.signal = options.signal;
801
+ const result = await socialRequest(config, `/users/bookmarks${params.size ? `?${params.toString()}` : ""}`, init);
802
+ return {
803
+ bookmarks: result.bookmarks ?? [],
804
+ hasMore: result.hasMore ?? false,
805
+ lastCursor: result.lastCursor ?? null,
806
+ lastCursorId: result.lastCursorId ?? null,
807
+ total: result.total ?? 0,
808
+ };
809
+ }
810
+ /**
811
+ * The caller's user-block list (GET /users/blocks), newest first with the
812
+ * same composite-cursor paging as the notification inbox. Blocks are
813
+ * directional and the blocker is always the verified caller.
814
+ */
815
+ export async function getUserBlocks(config, options = {}) {
816
+ const params = new URLSearchParams();
817
+ if (options.limit !== undefined)
818
+ params.set("limit", String(options.limit));
819
+ if (options.cursor !== undefined)
820
+ params.set("cursor", options.cursor);
821
+ if (options.cursorId !== undefined)
822
+ params.set("cursorId", options.cursorId);
823
+ const init = {};
824
+ if (options.signal)
825
+ init.signal = options.signal;
826
+ const result = await socialRequest(config, `/users/blocks${params.size ? `?${params.toString()}` : ""}`, init);
827
+ return {
828
+ blocks: result.blocks ?? [],
829
+ hasMore: result.hasMore ?? false,
830
+ lastCursor: result.lastCursor ?? null,
831
+ lastCursorId: result.lastCursorId ?? null,
832
+ };
833
+ }
834
+ /** Path-safety guard for block-target ids, NOT a format guard: the server
835
+ * accepts any trimmed non-empty id up to 128 chars (legacy and agent
836
+ * identities are not ObjectIds), so only the traversal class is refused
837
+ * here: empty ids and pure dot segments, which encodeURIComponent leaves
838
+ * alone and fetch would normalize onto /users/ itself. Everything else
839
+ * rides percent-encoded. */
840
+ function requireBlockUserId(userId) {
841
+ const trimmed = String(userId).trim();
842
+ if (trimmed === "" || trimmed === "." || trimmed === ".." || trimmed.length > 128) {
843
+ throw new RoomSocialError("Invalid user id", 400, { userId: String(userId) });
844
+ }
845
+ return trimmed;
846
+ }
847
+ /** Block a user by userId (PUT, idempotent: re-blocking echoes the standing
848
+ * row with its original createdAt). Server-enforced effects: DM doors shut
849
+ * both directions, friend requests refuse, and the blocked sender produces
850
+ * no notifications for the caller. Room visibility is unchanged. */
851
+ export async function blockUser(config, userId) {
852
+ const result = await socialRequest(config, `/users/blocks/${encodeURIComponent(requireBlockUserId(userId))}`, { method: "PUT" });
853
+ if (!result.block) {
854
+ throw new RoomSocialError("Block returned no row", 502, result);
855
+ }
856
+ return result.block;
857
+ }
858
+ /** Unblock a user (idempotent: unblocking a non-blocked user is a no-op). */
859
+ export async function unblockUser(config, userId) {
860
+ const result = await socialRequest(config, `/users/blocks/${encodeURIComponent(requireBlockUserId(userId))}`, { method: "DELETE" });
861
+ return { success: result.success === true };
862
+ }
863
+ /**
864
+ * Post a poll message. There is no separate create route and no WS input
865
+ * path (the relay's post frame carries text only): a poll rides the normal
866
+ * REST append as the strict body's `poll` block. `options` are option TEXT
867
+ * (2..10 entries; ids are minted server-side) and `closesAt` is an ISO
868
+ * deadline that must sit in the future. `text` is the optional caption;
869
+ * absent is fine, the poll IS the content. Echoes the created store event
870
+ * row with its counts-only summary; the room hears the same message
871
+ * through the ordinary live echo. Additive (0.6.0 servers): older stores
872
+ * reject the unknown block.
873
+ */
874
+ export async function createRoomPoll(config, room, input) {
875
+ const poll = {
876
+ question: input.question,
877
+ options: input.options,
878
+ };
879
+ if (input.multi !== undefined)
880
+ poll.multi = input.multi;
881
+ if (input.closesAt !== undefined)
882
+ poll.closesAt = input.closesAt;
883
+ const body = {
884
+ roomId: cleanRoomId(room),
885
+ poll,
886
+ };
887
+ if (input.text !== undefined)
888
+ body.text = input.text;
889
+ if (input.workspaceId !== undefined)
890
+ body.workspaceId = input.workspaceId;
891
+ if (input.topicId !== undefined)
892
+ body.topicId = input.topicId;
893
+ if (input.replyToSeq !== undefined)
894
+ body.replyToSeq = input.replyToSeq;
895
+ if (input.clientMsgId !== undefined)
896
+ body.clientMsgId = input.clientMsgId;
897
+ const result = await socialRequest(config, "/rooms/events", { method: "POST", body });
898
+ if (!result.event) {
899
+ throw new RoomSocialError("Poll create returned no event", 502, result);
900
+ }
901
+ return result.event;
902
+ }
903
+ /**
904
+ * Vote on a poll (the poll is keyed by its carrying message's seq). Echoes
905
+ * the store's public event row (RoomRestEvent, not the relay's mapped
906
+ * ledger entry) with the refreshed counts-only summary; the room hears the
907
+ * same update over ENTRY_UPDATED. Single-choice polls replace the caller's
908
+ * previous ballot; multi-choice polls union into it.
909
+ */
910
+ export async function voteRoomPoll(config, room, seq, optionIds) {
911
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/vote`, { method: "POST", body: { optionIds } });
912
+ if (!result.event) {
913
+ throw new RoomSocialError("Poll vote returned no event", 502, result);
914
+ }
915
+ return result.event;
916
+ }
917
+ /** Clear the caller's vote. Echoes the refreshed store event row. */
918
+ export async function unvoteRoomPoll(config, room, seq) {
919
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/vote`, { method: "DELETE" });
920
+ if (!result.event) {
921
+ throw new RoomSocialError("Poll unvote returned no event", 502, result);
922
+ }
923
+ return result.event;
924
+ }
925
+ /** Close a poll (idempotent on an already-closed poll). Echoes the store
926
+ * event row with `poll.closed` set. */
927
+ export async function closeRoomPoll(config, room, seq) {
928
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/close`, { method: "POST" });
929
+ if (!result.event) {
930
+ throw new RoomSocialError("Poll close returned no event", 502, result);
931
+ }
932
+ return result.event;
933
+ }
934
+ /** Edit a poll's question/options (author-only, and locked once any vote
935
+ * exists or the poll closes; at least one field required). Options are
936
+ * full replacement TEXT, like creation. */
937
+ /** Purge an event's frozen unfurl cards without editing or deleting the
938
+ * message. Author or a delete-any moderation power; the store unsets the
939
+ * cards, schedules thumbnail cleanup, and returns the mutated event for
940
+ * ENTRY_UPDATED fan-out. An edit that removes a card's URL from the text
941
+ * drops that card automatically; this route is the explicit purge for
942
+ * everything else (a capability URL that must go without rewording the
943
+ * message, a moderator acting on someone else's post). */
944
+ export async function unsetRoomEventUnfurls(config, room, seq) {
945
+ await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/events/${requireSeq(seq)}/unfurls`, { method: "DELETE" });
946
+ }
947
+ /** Edit an event's text over REST: the cold-replica-safe mirror of the WS
948
+ * EDIT frame, and the ONE shape the WS path cannot express: an empty text
949
+ * on a poll-bearing message legally clears the optional caption (the poll
950
+ * stays the content; the store rejects empty edits on plain messages).
951
+ * Viewers reconcile through the store's ENTRY_UPDATED fan-out exactly
952
+ * like the REST poll verbs. */
953
+ export async function editRoomEventRest(config, room, seq, text) {
954
+ await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/events/${requireSeq(seq)}`, { method: "PATCH", body: { text } });
955
+ }
956
+ export async function editRoomPoll(config, room, seq, patch) {
957
+ const body = {};
958
+ if (patch.question !== undefined)
959
+ body.question = patch.question;
960
+ if (patch.options !== undefined)
961
+ body.options = patch.options;
962
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}`, { method: "PATCH", body });
963
+ if (!result.event) {
964
+ throw new RoomSocialError("Poll edit returned no event", 502, result);
965
+ }
966
+ return result.event;
967
+ }
968
+ /**
969
+ * Voter identities, on demand (the entry's summary carries counts only).
970
+ * Grouped per option; `optionId` narrows to one option and `limit` caps the
971
+ * identities per option (counts stay exact).
972
+ */
973
+ export async function listRoomPollVoters(config, room, seq, options = {}) {
974
+ const params = new URLSearchParams();
975
+ if (options.optionId !== undefined)
976
+ params.set("optionId", options.optionId);
977
+ if (options.limit !== undefined)
978
+ params.set("limit", String(options.limit));
979
+ const init = {};
980
+ if (options.signal)
981
+ init.signal = options.signal;
982
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/voters${params.size ? `?${params.toString()}` : ""}`, init);
983
+ return {
984
+ room: result.room ?? cleanRoomId(room),
985
+ seq: result.seq ?? seq,
986
+ voters: result.voters ?? [],
987
+ viewerOptionIds: result.viewerOptionIds ?? [],
988
+ };
989
+ }
654
990
  export async function suggestRooms(config, request, options = {}) {
655
991
  const init = {
656
992
  method: "POST",
@@ -1,4 +1,4 @@
1
- import type { RoomLedgerSecretPayload } from "./shared/rooms-protocol.js";
1
+ import type { RoomLedgerSecretPayload, RoomPollSummary, RoomTopicAttention, RoomUnfurl } from "./shared/rooms-protocol.js";
2
2
  export interface RoomSocialConfig {
3
3
  apiUrl: string;
4
4
  apiKey: string;
@@ -209,6 +209,169 @@ export interface RoomNotificationInboxResult {
209
209
  lastCursor: string | null;
210
210
  lastCursorId: string | null;
211
211
  }
212
+ /** One of the caller's user blocks, wire names verbatim from the store's
213
+ * block rows: the row id, the BLOCKED user's id, and when the block was
214
+ * created (ISO string). Blocks are directional; the blocker is always the
215
+ * verified caller and never appears on the row. */
216
+ export interface RoomUserBlock {
217
+ id: string;
218
+ blockedId: string;
219
+ createdAt: string;
220
+ }
221
+ /** A newest-first page of the caller's blocks plus the composite cursor
222
+ * (same paging contract as the notification inbox: feed lastCursor and
223
+ * lastCursorId back together; both null on an empty page). */
224
+ export interface RoomUserBlocksResult {
225
+ blocks: RoomUserBlock[];
226
+ hasMore: boolean;
227
+ lastCursor: string | null;
228
+ lastCursorId: string | null;
229
+ }
230
+ /** The row echoed by a bookmark save. `createdAt` is null only when a
231
+ * concurrent remove raced the save (the save still happened). */
232
+ export interface RoomBookmarkSaved {
233
+ roomId: string;
234
+ seq: number;
235
+ createdAt: string | null;
236
+ }
237
+ /** The store's REST poll summary: the ledger-entry wire shape plus the
238
+ * store's monotonic ordering revision (create = 0, bumped per mutation).
239
+ * The relay strips revision before entries reach the WS surface, so only
240
+ * REST consumers see it; use it to refuse applying a stale mutation echo
241
+ * over newer state. Optional because rows written before the field read
242
+ * without it. */
243
+ export type RoomRestPollSummary = RoomPollSummary & {
244
+ revision?: number | undefined;
245
+ };
246
+ /**
247
+ * A persisted room event as the store's REST surfaces return it: the stored
248
+ * row with its wire names verbatim (roomId/senderId/sender/createdAt, store
249
+ * event kinds like 'message'), minus the server-internal fields the store's
250
+ * publicRoomEvent projection strips. This is NOT the relay's mapped
251
+ * RoomLedgerEntry (room/handle/ts, type 'post'); the two shapes never mix.
252
+ * Only the identity core is guaranteed: non-visible bookmark targets arrive
253
+ * as content-free tombstones, and older rows predate newer columns.
254
+ */
255
+ export interface RoomRestEvent {
256
+ roomId: string;
257
+ seq: number;
258
+ /** The store's event kinds, not the relay's mapped entry types. */
259
+ type: "message" | "system_market" | "moderation" | "secret";
260
+ senderId: string;
261
+ /** Write-time sender snapshot (denormalized; never re-resolved). */
262
+ sender: {
263
+ userId: string;
264
+ username: string;
265
+ handle: string;
266
+ isAgent: boolean;
267
+ role?: string | null | undefined;
268
+ };
269
+ createdAt: string;
270
+ updatedAt: string;
271
+ /** Absent on content-free tombstones. */
272
+ text?: string | undefined;
273
+ /** The store's body blob. Room attachments live at body.attachments
274
+ * (sanitized for the viewer), never top-level like the ledger entry's. */
275
+ body?: Record<string, unknown> | undefined;
276
+ secret?: RoomLedgerSecretPayload | null | undefined;
277
+ cashtags?: string[] | undefined;
278
+ entities?: string[] | undefined;
279
+ reactions?: Array<{
280
+ emoji: string;
281
+ count: number;
282
+ }> | undefined;
283
+ /** Counts-only poll summary (additive; the same wire shape the ledger
284
+ * entry carries plus the store's ordering revision). */
285
+ poll?: RoomRestPollSummary | null | undefined;
286
+ /** Server-rendered link unfurl cards (additive). */
287
+ unfurls?: RoomUnfurl[] | undefined;
288
+ replyToSeq?: number | null | undefined;
289
+ topicId?: string | null | undefined;
290
+ replyCount?: number | undefined;
291
+ lastReplyAt?: string | null | undefined;
292
+ pinned?: boolean | undefined;
293
+ visibility?: "visible" | "soft_deleted" | "hidden" | "held" | undefined;
294
+ /** Present only when the viewer holds view-audit on the room. */
295
+ moderationState?: "clean" | "flagged" | "reported" | "actioned" | undefined;
296
+ clientMsgId?: string | null | undefined;
297
+ editedAt?: string | null | undefined;
298
+ deletedAt?: string | null | undefined;
299
+ deletedBy?: string | null | undefined;
300
+ }
301
+ /** One saved message on the cross-room bookmark list, with the bookmarked
302
+ * event hydrated server-side in the store's public row shape (non-visible
303
+ * targets arrive as content-free tombstones, exactly like the room's own
304
+ * read surfaces). */
305
+ export interface RoomBookmark {
306
+ roomId: string;
307
+ seq: number;
308
+ createdAt: string;
309
+ event: RoomRestEvent;
310
+ }
311
+ /** A newest-first bookmark page plus the composite cursor. `total` is the
312
+ * raw row count capped server-side: a bounded hint, not an exact readable
313
+ * total (rows in rooms the caller can no longer read are subtracted from
314
+ * pages but still count). */
315
+ export interface RoomBookmarksResult {
316
+ bookmarks: RoomBookmark[];
317
+ hasMore: boolean;
318
+ lastCursor: string | null;
319
+ lastCursorId: string | null;
320
+ total: number;
321
+ }
322
+ /** Voter identities for one poll option, fetched on demand (the entry's
323
+ * poll summary carries counts only). */
324
+ export interface RoomPollOptionVoters {
325
+ optionId: string;
326
+ users: Array<{
327
+ userId: string;
328
+ handle: string;
329
+ }>;
330
+ count: number;
331
+ }
332
+ export interface RoomPollVotersResult {
333
+ room: string;
334
+ seq: number;
335
+ voters: RoomPollOptionVoters[];
336
+ /** The authenticated caller's own current ballot, from an exact point
337
+ * read: independent of the bounded voter-identity window and of any
338
+ * optionId filter. Empty when the caller holds no ballot. This is the
339
+ * reload/recovery source of truth for rendering one's own vote. */
340
+ viewerOptionIds: string[];
341
+ }
342
+ /** Recipient-local daily push-suppression window (notify settings). */
343
+ export interface RoomNotifyQuietHours {
344
+ enabled: boolean;
345
+ /** Minutes after local midnight, 0..1439. */
346
+ startMinutes: number;
347
+ /** Minutes after local midnight, 0..1439. May wrap past midnight. */
348
+ endMinutes: number;
349
+ /** IANA time zone name, 1..64 chars (server-probed against Intl). */
350
+ tz: string;
351
+ }
352
+ /** Per-space override entry on the rooms notification settings. */
353
+ export interface RoomSpaceNotificationOverride {
354
+ suppressMassMentions?: boolean | undefined;
355
+ attention?: RoomTopicAttention | undefined;
356
+ /** Timed-mute expiry (ISO date). Meaningful only beside attention 'mute';
357
+ * normalization drops it otherwise. The settings surface echoes the
358
+ * CONFIGURED expiry, lapsed or not (a configuration read, not an
359
+ * effective-state read). */
360
+ muteUntil?: string | undefined;
361
+ }
362
+ /** The store-owned `user_settings.notificationSettings.rooms` shape behind
363
+ * GET/PUT /users/notification-settings. PUT is always the FULL object;
364
+ * absent fields take their contract defaults. */
365
+ export interface RoomsNotificationSettings {
366
+ dm: boolean;
367
+ mention: boolean;
368
+ reply: boolean;
369
+ /** Case-insensitive word-boundary keywords, capped at 50. */
370
+ keywords: string[];
371
+ spaceOverrides: Record<string, RoomSpaceNotificationOverride>;
372
+ /** Absent means no quiet-hours window is configured. */
373
+ quietHours?: RoomNotifyQuietHours | undefined;
374
+ }
212
375
  /** A living markdown doc scoped to a space (head state; content on demand). */
213
376
  export interface RoomDoc {
214
377
  docId: string;
package/dist/types.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export type { RoomAccessMode, RoomAttachment, RoomAttachmentInput, RoomAuditEntry, RoomAuditLogPayload, RoomAuditLogRequestPayload, RoomAuthSuccessPayload, RoomBackfillPayload, RoomClientMessage, RoomClientPlatform, RoomCreatedPayload, RoomCreatePayload, RoomDmHistoryPayload, RoomDmHistoryResultPayload, RoomDmInboxUpdatedPayload, RoomDmMessage, RoomDmMessageDeletedPayload, RoomDmMessageEditedPayload, RoomDmMessagePayload, RoomDmOpenedPayload, RoomDmOpenPayload, RoomDmParticipant, RoomDmPinsResultPayload, RoomDmPinUpdatedPayload, RoomDmReactionUpdatedPayload, RoomDmReadPayload, RoomDmReplyPayload, RoomDmSendPayload, RoomEntryUpdatedPayload, RoomErrorPayload, RoomForwardedFrom, RoomForwardMetadata, RoomFriendPresence, RoomInvitedPayload, RoomInvitePayload, RoomLedgerEntry, RoomMember, RoomMemberRemovedPayload, RoomMemberRoleChangedPayload, RoomMemberTier, RoomMessagePayload, RoomMeta, RoomModerateMemberPayload, RoomMutedPayload, RoomPinsPayload, RoomPresenceDeltaPayload, RoomPresencePayload, RoomReactorsPayload, RoomReadStatePayload, RoomRemoveMemberPayload, RoomReportedPayload, RoomReportPayload, RoomResyncPayload, RoomRole, RoomRoleChangePayload, RoomSearchPayload, RoomSearchResultsPayload, RoomSearchScope, RoomServerMessage, RoomServerMessageOf, RoomSetAttentionPayload, RoomSetReadOnlyPayload, RoomSpace, RoomSpaceJoinedPayload, RoomSpacesPayload, RoomThreadPayload, RoomTopic, RoomTopicAttention, RoomTopicBucketState, RoomTopicCreatePayload, RoomTopicListPayload, RoomTopicMergePayload, RoomTopicMovePayload, RoomTopicRenamePayload, RoomTopicResolvePayload, RoomTopicResultPayload, RoomTopicSetAttentionPayload, RoomTopicsPolicy, RoomTopicsResultPayload, RoomTopicUpdatedPayload, RoomTypingPayload, RoomUserStatus, RoomWhoPayload, } from "./shared/rooms-protocol.js";
1
+ export type { RoomAccessMode, RoomAttachment, RoomAttachmentInput, RoomAuditEntry, RoomAuditLogPayload, RoomAuditLogRequestPayload, RoomAuthSuccessPayload, RoomBackfillPayload, RoomClientMessage, RoomClientPlatform, RoomCreatedPayload, RoomCreatePayload, RoomDmHistoryPayload, RoomDmHistoryResultPayload, RoomDmInboxUpdatedPayload, RoomDmMessage, RoomDmMessageDeletedPayload, RoomDmMessageEditedPayload, RoomDmMessagePayload, RoomDmOpenedPayload, RoomDmOpenPayload, RoomDmParticipant, RoomDmPinsResultPayload, RoomDmPinUpdatedPayload, RoomDmReactionUpdatedPayload, RoomDmReadPayload, RoomDmReplyPayload, RoomDmSendPayload, RoomEntryUpdatedPayload, RoomErrorPayload, RoomForwardedFrom, RoomForwardMetadata, RoomFriendPresence, RoomInvitedPayload, RoomInvitePayload, RoomLedgerEntry, RoomMember, RoomMemberRemovedPayload, RoomMemberRoleChangedPayload, RoomMemberTier, RoomMessagePayload, RoomMeta, RoomModerateMemberPayload, RoomMutedPayload, RoomPinsPayload, RoomPollOption, RoomPollSummary, 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, RoomUnfurl, 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.6.0",
3
+ "version": "0.7.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",
@@ -184,7 +184,9 @@ export function isTriggeringEntry(
184
184
  if (entry.type !== RoomLedgerEntryType.POST) return false;
185
185
  if (entry.isAgent) return false;
186
186
  if (isWebhookEntry(entry)) return false;
187
- if (!entry.text?.trim()) return false;
187
+ // A poll is substantive content even with no caption, and a captionless
188
+ // poll REPLY is addressed at us like any other reply.
189
+ if (!entry.text?.trim() && entry.poll == null) return false;
188
190
  // Never trigger on our own message (we post as this handle/userId).
189
191
  const fromHandle = normalizeHandle(entry.handle).toLowerCase();
190
192
  const me = normalizeHandle(ownHandle).toLowerCase();
@@ -197,7 +199,9 @@ export function isTriggeringEntry(
197
199
  // old mentionsAgent) fired on ordinary words for short handles like "may" /
198
200
  // "btc"; armed posting has no human review, so it triggers ONLY on an `@handle`
199
201
  // token. Empty handle never matches (mentionsAgentExplicit guards it).
200
- return mentionsAgentExplicit(entry.text, ownHandle);
202
+ // Mentions can live in the poll question too (member-authored content).
203
+ const mentionSource = [entry.text ?? "", entry.poll?.question ?? ""].filter(Boolean).join(" ");
204
+ return mentionsAgentExplicit(mentionSource, ownHandle);
201
205
  }
202
206
 
203
207
  /**
@@ -396,7 +400,7 @@ export function isHumanResetEntry(
396
400
  const fromHandle = normalizeHandle(entry.handle).toLowerCase();
397
401
  const me = normalizeHandle(ownHandle).toLowerCase();
398
402
  if (me.length > 0 && fromHandle === me) return false;
399
- return Boolean(entry.text?.trim());
403
+ return Boolean(entry.text?.trim()) || entry.poll != null;
400
404
  }
401
405
 
402
406
  /** Note a turn/post error against the kill switch; returns true once the room
@@ -24,6 +24,11 @@ export type AgentMessage =
24
24
  export interface ToolDispatchResult {
25
25
  isError?: true;
26
26
  content: string;
27
+ /** Full-fidelity render payload for in-process SURFACES (e.g. the TUI's
28
+ * backtest card). Present only on the streamed copy channels consume —
29
+ * the agent runtime strips it before the result reaches the model's
30
+ * messages or the conversation store, so it never costs context. */
31
+ artifact?: unknown;
27
32
  }
28
33
 
29
34
  export interface LlmTool {