@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.
@@ -1,6 +1,9 @@
1
1
  import { resolveRoomsWsUrl } from "./client.js";
2
2
  import { envString, ROOM_CHAT_API_URL } from "./endpoints.js";
3
+ import type { RoomTopicAttention } from "./shared/rooms-protocol.js";
3
4
  import type {
5
+ RoomBookmarkSaved,
6
+ RoomBookmarksResult,
4
7
  RoomDmConversation,
5
8
  RoomDmHistory,
6
9
  RoomDmSendResult,
@@ -19,18 +22,25 @@ import type {
19
22
  RoomFriend,
20
23
  RoomFriendRequests,
21
24
  RoomNotificationInboxResult,
25
+ RoomPollVotersResult,
22
26
  RoomPublicProfile,
23
27
  RoomRepliesReadResult,
24
28
  RoomRepliesResult,
29
+ RoomRestEvent,
25
30
  RoomSocialConfig,
26
31
  RoomSpaceRestorePlan,
27
32
  RoomSpaceRestoreReceipt,
28
33
  RoomSuggestion,
29
34
  RoomSuggestionRequest,
35
+ RoomUserBlock,
36
+ RoomUserBlocksResult,
30
37
  } from "./social-types.js";
31
38
 
32
39
  export type {
33
40
  RoomAttachment,
41
+ RoomBookmark,
42
+ RoomBookmarkSaved,
43
+ RoomBookmarksResult,
34
44
  RoomDmConversation,
35
45
  RoomDmHistory,
36
46
  RoomDmMessage,
@@ -51,11 +61,16 @@ export type {
51
61
  RoomNotificationInboxResult,
52
62
  RoomNotificationKind,
53
63
  RoomNotificationMessageData,
64
+ RoomNotifyQuietHours,
65
+ RoomPollOptionVoters,
66
+ RoomPollVotersResult,
54
67
  RoomPublicProfile,
55
68
  RoomRepliesReadResult,
56
69
  RoomRepliesResult,
57
70
  RoomReplyInboxItem,
71
+ RoomRestEvent,
58
72
  RoomSocialConfig,
73
+ RoomSpaceNotificationOverride,
59
74
  RoomSpaceRestoreAction,
60
75
  RoomSpaceRestoreActionKind,
61
76
  RoomSpaceRestorePlan,
@@ -65,6 +80,9 @@ export type {
65
80
  RoomSuggestionIntent,
66
81
  RoomSuggestionRequest,
67
82
  RoomSuggestionSource,
83
+ RoomsNotificationSettings,
84
+ RoomUserBlock,
85
+ RoomUserBlocksResult,
68
86
  } from "./social-types.js";
69
87
 
70
88
  /** Largest per-file size any tier accepts (plus tier). The server enforces
@@ -876,20 +894,54 @@ export interface RoomSpaceInvite {
876
894
  invitedBy?: string;
877
895
  role: "admin" | "member";
878
896
  createdAt?: string;
897
+ /** Expiry deadline (ISO date); null or absent means the invite stands
898
+ * until accepted or declined. Additive (0.6.0 servers). */
899
+ expiresAt?: string | null;
879
900
  }
880
901
 
881
- /** Create (or refresh) a pending invitation. Membership needs their accept. */
902
+ /** Create (or refresh) a pending invitation. Membership needs their accept.
903
+ * `expiresAt` (ISO date, future, at most 30 days out) puts a deadline on
904
+ * it; re-inviting without one clears any previous deadline. Fail-closed on
905
+ * servers older than 0.6.0: they persist the invite but drop the deadline,
906
+ * so a deadline the echo omits revokes the invite and throws. */
882
907
  export async function createSpaceInvite(
883
908
  config: RoomSocialConfig,
884
909
  spaceId: string,
885
- input: { targetUserId?: string; targetUsername?: string; role?: "admin" | "member" },
910
+ input: {
911
+ targetUserId?: string;
912
+ targetUsername?: string;
913
+ role?: "admin" | "member";
914
+ expiresAt?: string;
915
+ },
886
916
  ): Promise<RoomSpaceInvite> {
887
917
  const result = await socialRequest<{ invite: RoomSpaceInvite }>(
888
918
  config,
889
919
  `/spaces/${encodeURIComponent(spaceId)}/invites`,
890
920
  { method: "POST", body: input },
891
921
  );
892
- return result.invite;
922
+ const invite = result.invite;
923
+ // Presence check, not equality: servers may normalize the ISO string.
924
+ // declineSpaceInvite doubles as the manager-side revoke (the store
925
+ // deletes the row for invitee OR manager), so the inviter can retract
926
+ // the invite it just created.
927
+ if (input.expiresAt !== undefined && invite && typeof invite.expiresAt !== "string") {
928
+ const details = { spaceId, inviteId: invite.inviteId, requestedExpiresAt: input.expiresAt };
929
+ try {
930
+ await declineSpaceInvite(config, invite.inviteId);
931
+ } catch (revokeError) {
932
+ throw new RoomSocialError(
933
+ "Server does not support invite expiry, and revoking the invite failed: a standing (never-expiring) invite may be live",
934
+ 502,
935
+ { ...details, revokeError: (revokeError as Error).message },
936
+ );
937
+ }
938
+ throw new RoomSocialError(
939
+ "Server does not support invite expiry; the standing invite was revoked",
940
+ 502,
941
+ details,
942
+ );
943
+ }
944
+ return invite;
893
945
  }
894
946
 
895
947
  /** The caller's own pending invitations. */
@@ -929,14 +981,65 @@ export async function declineSpaceInvite(
929
981
  });
930
982
  }
931
983
 
932
- /** Mint (or rotate) the shareable join code; plaintext comes back once. */
984
+ /** Mint (or rotate) the shareable join code; plaintext comes back once.
985
+ * Optional caps: `expiresAt` (ISO date, future, at most 30 days out) and
986
+ * `maxUses` (1..10000). Rotation is a fresh grant: the new code starts at
987
+ * zero uses and takes exactly the caps given here (omitted caps clear the
988
+ * previous ones). Servers older than 0.6.0 echo neither cap field; they
989
+ * mint the code but drop the caps, so a requested cap the echo omits
990
+ * fail-closes: the fresh code is revoked and the call throws rather than
991
+ * hand back a live UNLIMITED code the caller believes capped. */
933
992
  export async function setSpaceJoinCode(
934
993
  config: RoomSocialConfig,
935
994
  spaceId: string,
936
- ): Promise<{ spaceId: string; code: string }> {
937
- return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/join-code`, {
995
+ options: { expiresAt?: string | undefined; maxUses?: number | undefined } = {},
996
+ ): Promise<{
997
+ spaceId: string;
998
+ code: string;
999
+ expiresAt?: string | null;
1000
+ maxUses?: number | null;
1001
+ }> {
1002
+ const body: { expiresAt?: string; maxUses?: number } = {};
1003
+ if (options.expiresAt !== undefined) body.expiresAt = options.expiresAt;
1004
+ if (options.maxUses !== undefined) body.maxUses = options.maxUses;
1005
+ const result = await socialRequest<{
1006
+ spaceId: string;
1007
+ code: string;
1008
+ expiresAt?: string | null;
1009
+ maxUses?: number | null;
1010
+ }>(config, `/spaces/${encodeURIComponent(spaceId)}/join-code`, {
938
1011
  method: "PUT",
1012
+ // Capless mints keep the historical bodiless PUT on the wire.
1013
+ ...(Object.keys(body).length > 0 ? { body } : {}),
939
1014
  });
1015
+ // Presence check for expiresAt (servers may normalize the ISO string),
1016
+ // numeric equality for maxUses.
1017
+ const droppedExpiry = body.expiresAt !== undefined && typeof result.expiresAt !== "string";
1018
+ const droppedMaxUses = body.maxUses !== undefined && result.maxUses !== body.maxUses;
1019
+ if (droppedExpiry || droppedMaxUses) {
1020
+ // Details deliberately omit the plaintext code: RoomSocialError.details
1021
+ // ends up in logs.
1022
+ const details = {
1023
+ spaceId,
1024
+ requested: body,
1025
+ echoed: { expiresAt: result.expiresAt ?? null, maxUses: result.maxUses ?? null },
1026
+ };
1027
+ try {
1028
+ await clearSpaceJoinCode(config, spaceId);
1029
+ } catch (revokeError) {
1030
+ throw new RoomSocialError(
1031
+ "Server does not support join-code caps, and revoking the code failed: an uncapped join code may be live",
1032
+ 502,
1033
+ { ...details, revokeError: (revokeError as Error).message },
1034
+ );
1035
+ }
1036
+ throw new RoomSocialError(
1037
+ "Server does not support join-code caps; the uncapped code was revoked",
1038
+ 502,
1039
+ details,
1040
+ );
1041
+ }
1042
+ return result;
940
1043
  }
941
1044
 
942
1045
  export async function clearSpaceJoinCode(
@@ -1325,6 +1428,464 @@ export async function listRoomNotifications(
1325
1428
  };
1326
1429
  }
1327
1430
 
1431
+ /**
1432
+ * Mark a room unread from `seq` (Discord-style): the room-wide read cursor
1433
+ * drops to seq - 1 (server-clamped at 0) so the unread divider and pill
1434
+ * return. The one sanctioned cursor-regression path; mention rows an
1435
+ * earlier ack swept stay swept, and nothing is re-notified.
1436
+ */
1437
+ export async function markRoomUnread(
1438
+ config: RoomSocialConfig,
1439
+ room: string,
1440
+ seq: number,
1441
+ ): Promise<{ success: boolean }> {
1442
+ const result = await socialRequest<{ success?: boolean }>(
1443
+ config,
1444
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/read/unread`,
1445
+ { method: "POST", body: { seq } },
1446
+ );
1447
+ return { success: result.success === true };
1448
+ }
1449
+
1450
+ /**
1451
+ * Set the caller's room-level attention over REST, the path that carries
1452
+ * mute-with-duration: `untilMs` (epoch ms, valid only with 'mute') stamps
1453
+ * the expiry that read state later surfaces as `muteUntil`; the mute lapses
1454
+ * server-side and reads as 'default' afterwards. Every write settles the
1455
+ * expiry, so follow/default (and a plain permanent mute) clear a previous
1456
+ * one. The pinned ROOM_SET_ATTENTION frame (setRoomAttentionOnClient)
1457
+ * predates `untilMs` and stays the live-session path for permanent
1458
+ * postures; servers older than mute-with-duration strip the field and land
1459
+ * the mute permanent, so a requested `untilMs` the echo omits fail-closes:
1460
+ * the attention is reset and the call throws rather than leave an
1461
+ * unbounded mute the caller believes timed. Echoes the persisted
1462
+ * attention; `muteUntil` is the ISO expiry, null when none stands.
1463
+ */
1464
+ export async function setRoomAttentionRest(
1465
+ config: RoomSocialConfig,
1466
+ room: string,
1467
+ attention: RoomTopicAttention,
1468
+ options: { untilMs?: number | undefined } = {},
1469
+ ): Promise<{ attention: RoomTopicAttention; muteUntil: string | null }> {
1470
+ const body: { attention: RoomTopicAttention; untilMs?: number } = { attention };
1471
+ if (options.untilMs !== undefined) body.untilMs = options.untilMs;
1472
+ const result = await socialRequest<{
1473
+ success?: boolean;
1474
+ roomId?: string;
1475
+ attention?: RoomTopicAttention;
1476
+ muteUntil?: string;
1477
+ }>(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/attention`, {
1478
+ method: "POST",
1479
+ body,
1480
+ });
1481
+ const muteUntil = typeof result.muteUntil === "string" ? result.muteUntil : null;
1482
+ if (options.untilMs !== undefined && muteUntil === null) {
1483
+ // The undo cannot know the caller's prior attention, so it deliberately
1484
+ // lands 'default' (the same neutral a lapsed timed mute reads as, see
1485
+ // resolveTopicAttention) rather than leave the unbounded mute standing.
1486
+ try {
1487
+ await setRoomAttentionRest(config, room, "default");
1488
+ } catch (undoError) {
1489
+ throw new RoomSocialError(
1490
+ "Server does not support timed mutes, and undoing the mute failed: a permanent mute may be standing",
1491
+ 502,
1492
+ { room, undoError: (undoError as Error).message },
1493
+ );
1494
+ }
1495
+ throw new RoomSocialError(
1496
+ "Server does not support timed mutes; the permanent mute was undone (attention is now 'default')",
1497
+ 502,
1498
+ { room },
1499
+ );
1500
+ }
1501
+ return { attention: result.attention ?? attention, muteUntil };
1502
+ }
1503
+
1504
+ /**
1505
+ * Save a message for later (personal, per-user; nothing broadcasts).
1506
+ * Idempotent: re-saving an already bookmarked event echoes the standing
1507
+ * row with its original createdAt.
1508
+ */
1509
+ export async function addRoomBookmark(
1510
+ config: RoomSocialConfig,
1511
+ room: string,
1512
+ seq: number,
1513
+ ): Promise<RoomBookmarkSaved> {
1514
+ const result = await socialRequest<{ success?: boolean; bookmark?: RoomBookmarkSaved }>(
1515
+ config,
1516
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/bookmarks`,
1517
+ { method: "POST", body: { seq } },
1518
+ );
1519
+ if (!result.bookmark) {
1520
+ throw new RoomSocialError("Bookmark save returned no row", 502, result);
1521
+ }
1522
+ return result.bookmark;
1523
+ }
1524
+
1525
+ /** Remove a bookmark. Idempotent, and deliberately NOT gated on room
1526
+ * readability: cleanup keeps working after lost membership or a deleted
1527
+ * target (`removed` is false when no row stood). */
1528
+ /** Sequence numbers ride URL paths; a JavaScript caller can pass any value
1529
+ * despite the type annotation, and fetch normalizes dot segments, so a seq
1530
+ * of ".." would rewrite a bookmark DELETE onto the ROOM route (archive).
1531
+ * Runtime-require a nonnegative safe integer before interpolating. */
1532
+ function requireSeq(seq: number): number {
1533
+ if (!Number.isSafeInteger(seq) || seq < 0) {
1534
+ throw new RoomSocialError("Invalid sequence number", 400, { seq: String(seq) });
1535
+ }
1536
+ return seq;
1537
+ }
1538
+
1539
+ export async function removeRoomBookmark(
1540
+ config: RoomSocialConfig,
1541
+ room: string,
1542
+ seq: number,
1543
+ ): Promise<{ removed: boolean }> {
1544
+ const result = await socialRequest<{ success?: boolean; removed?: boolean }>(
1545
+ config,
1546
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/bookmarks/${requireSeq(seq)}`,
1547
+ { method: "DELETE" },
1548
+ );
1549
+ return { removed: result.removed === true };
1550
+ }
1551
+
1552
+ /**
1553
+ * The caller's bookmarks across every room (GET /users/bookmarks), newest
1554
+ * first with the notification inbox's composite cursor: pass a page's
1555
+ * lastCursor and lastCursorId back TOGETHER for the next page. Rows in
1556
+ * rooms the caller can no longer read are subtracted server-side.
1557
+ */
1558
+ export async function listUserBookmarks(
1559
+ config: RoomSocialConfig,
1560
+ options: {
1561
+ limit?: number | undefined;
1562
+ /** Feed a page's lastCursor back here (explicit undefined reads as
1563
+ * "first page", so `page.lastCursor ?? undefined` composes under
1564
+ * exactOptionalPropertyTypes). */
1565
+ cursor?: string | undefined;
1566
+ cursorId?: string | undefined;
1567
+ signal?: AbortSignal | undefined;
1568
+ } = {},
1569
+ ): Promise<RoomBookmarksResult> {
1570
+ const params = new URLSearchParams();
1571
+ if (options.limit !== undefined) params.set("limit", String(options.limit));
1572
+ if (options.cursor !== undefined) params.set("cursor", options.cursor);
1573
+ if (options.cursorId !== undefined) params.set("cursorId", options.cursorId);
1574
+ const init: { signal?: AbortSignal } = {};
1575
+ if (options.signal) init.signal = options.signal;
1576
+ const result = await socialRequest<Partial<RoomBookmarksResult>>(
1577
+ config,
1578
+ `/users/bookmarks${params.size ? `?${params.toString()}` : ""}`,
1579
+ init,
1580
+ );
1581
+ return {
1582
+ bookmarks: result.bookmarks ?? [],
1583
+ hasMore: result.hasMore ?? false,
1584
+ lastCursor: result.lastCursor ?? null,
1585
+ lastCursorId: result.lastCursorId ?? null,
1586
+ total: result.total ?? 0,
1587
+ };
1588
+ }
1589
+
1590
+ /**
1591
+ * The caller's user-block list (GET /users/blocks), newest first with the
1592
+ * same composite-cursor paging as the notification inbox. Blocks are
1593
+ * directional and the blocker is always the verified caller.
1594
+ */
1595
+ export async function getUserBlocks(
1596
+ config: RoomSocialConfig,
1597
+ options: {
1598
+ limit?: number | undefined;
1599
+ cursor?: string | undefined;
1600
+ cursorId?: string | undefined;
1601
+ signal?: AbortSignal | undefined;
1602
+ } = {},
1603
+ ): Promise<RoomUserBlocksResult> {
1604
+ const params = new URLSearchParams();
1605
+ if (options.limit !== undefined) params.set("limit", String(options.limit));
1606
+ if (options.cursor !== undefined) params.set("cursor", options.cursor);
1607
+ if (options.cursorId !== undefined) params.set("cursorId", options.cursorId);
1608
+ const init: { signal?: AbortSignal } = {};
1609
+ if (options.signal) init.signal = options.signal;
1610
+ const result = await socialRequest<Partial<RoomUserBlocksResult>>(
1611
+ config,
1612
+ `/users/blocks${params.size ? `?${params.toString()}` : ""}`,
1613
+ init,
1614
+ );
1615
+ return {
1616
+ blocks: result.blocks ?? [],
1617
+ hasMore: result.hasMore ?? false,
1618
+ lastCursor: result.lastCursor ?? null,
1619
+ lastCursorId: result.lastCursorId ?? null,
1620
+ };
1621
+ }
1622
+
1623
+ /** Path-safety guard for block-target ids, NOT a format guard: the server
1624
+ * accepts any trimmed non-empty id up to 128 chars (legacy and agent
1625
+ * identities are not ObjectIds), so only the traversal class is refused
1626
+ * here: empty ids and pure dot segments, which encodeURIComponent leaves
1627
+ * alone and fetch would normalize onto /users/ itself. Everything else
1628
+ * rides percent-encoded. */
1629
+ function requireBlockUserId(userId: string): string {
1630
+ const trimmed = String(userId).trim();
1631
+ if (trimmed === "" || trimmed === "." || trimmed === ".." || trimmed.length > 128) {
1632
+ throw new RoomSocialError("Invalid user id", 400, { userId: String(userId) });
1633
+ }
1634
+ return trimmed;
1635
+ }
1636
+
1637
+ /** Block a user by userId (PUT, idempotent: re-blocking echoes the standing
1638
+ * row with its original createdAt). Server-enforced effects: DM doors shut
1639
+ * both directions, friend requests refuse, and the blocked sender produces
1640
+ * no notifications for the caller. Room visibility is unchanged. */
1641
+ export async function blockUser(config: RoomSocialConfig, userId: string): Promise<RoomUserBlock> {
1642
+ const result = await socialRequest<{ success?: boolean; block?: RoomUserBlock }>(
1643
+ config,
1644
+ `/users/blocks/${encodeURIComponent(requireBlockUserId(userId))}`,
1645
+ { method: "PUT" },
1646
+ );
1647
+ if (!result.block) {
1648
+ throw new RoomSocialError("Block returned no row", 502, result);
1649
+ }
1650
+ return result.block;
1651
+ }
1652
+
1653
+ /** Unblock a user (idempotent: unblocking a non-blocked user is a no-op). */
1654
+ export async function unblockUser(
1655
+ config: RoomSocialConfig,
1656
+ userId: string,
1657
+ ): Promise<{ success: boolean }> {
1658
+ const result = await socialRequest<{ success?: boolean }>(
1659
+ config,
1660
+ `/users/blocks/${encodeURIComponent(requireBlockUserId(userId))}`,
1661
+ { method: "DELETE" },
1662
+ );
1663
+ return { success: result.success === true };
1664
+ }
1665
+
1666
+ /**
1667
+ * Post a poll message. There is no separate create route and no WS input
1668
+ * path (the relay's post frame carries text only): a poll rides the normal
1669
+ * REST append as the strict body's `poll` block. `options` are option TEXT
1670
+ * (2..10 entries; ids are minted server-side) and `closesAt` is an ISO
1671
+ * deadline that must sit in the future. `text` is the optional caption;
1672
+ * absent is fine, the poll IS the content. Echoes the created store event
1673
+ * row with its counts-only summary; the room hears the same message
1674
+ * through the ordinary live echo. Additive (0.6.0 servers): older stores
1675
+ * reject the unknown block.
1676
+ */
1677
+ export async function createRoomPoll(
1678
+ config: RoomSocialConfig,
1679
+ room: string,
1680
+ input: {
1681
+ question: string;
1682
+ options: string[];
1683
+ multi?: boolean | undefined;
1684
+ closesAt?: string | undefined;
1685
+ text?: string | undefined;
1686
+ /** Exact-case workspace id: lets a COLD relay replica materialize a
1687
+ * virtual workspace room without guessing the id's casing from the
1688
+ * lowercased room name (owners resolve server-side; non-owner first
1689
+ * writers need this). */
1690
+ workspaceId?: string | undefined;
1691
+ /** Route into a topic lane like any other append. */
1692
+ topicId?: string | undefined;
1693
+ /** Post the poll as a threaded reply. */
1694
+ replyToSeq?: number | undefined;
1695
+ /** Server-side idempotency: retries after an ambiguous failure reuse
1696
+ * the key instead of minting a duplicate poll. */
1697
+ clientMsgId?: string | undefined;
1698
+ },
1699
+ ): Promise<RoomRestEvent> {
1700
+ const poll: { question: string; options: string[]; multi?: boolean; closesAt?: string } = {
1701
+ question: input.question,
1702
+ options: input.options,
1703
+ };
1704
+ if (input.multi !== undefined) poll.multi = input.multi;
1705
+ if (input.closesAt !== undefined) poll.closesAt = input.closesAt;
1706
+ const body: {
1707
+ roomId: string;
1708
+ poll: typeof poll;
1709
+ text?: string;
1710
+ workspaceId?: string;
1711
+ topicId?: string;
1712
+ replyToSeq?: number;
1713
+ clientMsgId?: string;
1714
+ } = {
1715
+ roomId: cleanRoomId(room),
1716
+ poll,
1717
+ };
1718
+ if (input.text !== undefined) body.text = input.text;
1719
+ if (input.workspaceId !== undefined) body.workspaceId = input.workspaceId;
1720
+ if (input.topicId !== undefined) body.topicId = input.topicId;
1721
+ if (input.replyToSeq !== undefined) body.replyToSeq = input.replyToSeq;
1722
+ if (input.clientMsgId !== undefined) body.clientMsgId = input.clientMsgId;
1723
+ const result = await socialRequest<{ success?: boolean; event?: RoomRestEvent }>(
1724
+ config,
1725
+ "/rooms/events",
1726
+ { method: "POST", body },
1727
+ );
1728
+ if (!result.event) {
1729
+ throw new RoomSocialError("Poll create returned no event", 502, result);
1730
+ }
1731
+ return result.event;
1732
+ }
1733
+
1734
+ /**
1735
+ * Vote on a poll (the poll is keyed by its carrying message's seq). Echoes
1736
+ * the store's public event row (RoomRestEvent, not the relay's mapped
1737
+ * ledger entry) with the refreshed counts-only summary; the room hears the
1738
+ * same update over ENTRY_UPDATED. Single-choice polls replace the caller's
1739
+ * previous ballot; multi-choice polls union into it.
1740
+ */
1741
+ export async function voteRoomPoll(
1742
+ config: RoomSocialConfig,
1743
+ room: string,
1744
+ seq: number,
1745
+ optionIds: string[],
1746
+ ): Promise<RoomRestEvent> {
1747
+ const result = await socialRequest<{ success?: boolean; event?: RoomRestEvent }>(
1748
+ config,
1749
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/vote`,
1750
+ { method: "POST", body: { optionIds } },
1751
+ );
1752
+ if (!result.event) {
1753
+ throw new RoomSocialError("Poll vote returned no event", 502, result);
1754
+ }
1755
+ return result.event;
1756
+ }
1757
+
1758
+ /** Clear the caller's vote. Echoes the refreshed store event row. */
1759
+ export async function unvoteRoomPoll(
1760
+ config: RoomSocialConfig,
1761
+ room: string,
1762
+ seq: number,
1763
+ ): Promise<RoomRestEvent> {
1764
+ const result = await socialRequest<{ success?: boolean; event?: RoomRestEvent }>(
1765
+ config,
1766
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/vote`,
1767
+ { method: "DELETE" },
1768
+ );
1769
+ if (!result.event) {
1770
+ throw new RoomSocialError("Poll unvote returned no event", 502, result);
1771
+ }
1772
+ return result.event;
1773
+ }
1774
+
1775
+ /** Close a poll (idempotent on an already-closed poll). Echoes the store
1776
+ * event row with `poll.closed` set. */
1777
+ export async function closeRoomPoll(
1778
+ config: RoomSocialConfig,
1779
+ room: string,
1780
+ seq: number,
1781
+ ): Promise<RoomRestEvent> {
1782
+ const result = await socialRequest<{ success?: boolean; event?: RoomRestEvent }>(
1783
+ config,
1784
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/close`,
1785
+ { method: "POST" },
1786
+ );
1787
+ if (!result.event) {
1788
+ throw new RoomSocialError("Poll close returned no event", 502, result);
1789
+ }
1790
+ return result.event;
1791
+ }
1792
+
1793
+ /** Edit a poll's question/options (author-only, and locked once any vote
1794
+ * exists or the poll closes; at least one field required). Options are
1795
+ * full replacement TEXT, like creation. */
1796
+ /** Purge an event's frozen unfurl cards without editing or deleting the
1797
+ * message. Author or a delete-any moderation power; the store unsets the
1798
+ * cards, schedules thumbnail cleanup, and returns the mutated event for
1799
+ * ENTRY_UPDATED fan-out. An edit that removes a card's URL from the text
1800
+ * drops that card automatically; this route is the explicit purge for
1801
+ * everything else (a capability URL that must go without rewording the
1802
+ * message, a moderator acting on someone else's post). */
1803
+ export async function unsetRoomEventUnfurls(
1804
+ config: RoomSocialConfig,
1805
+ room: string,
1806
+ seq: number,
1807
+ ): Promise<void> {
1808
+ await socialRequest(
1809
+ config,
1810
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/events/${requireSeq(seq)}/unfurls`,
1811
+ { method: "DELETE" },
1812
+ );
1813
+ }
1814
+
1815
+ /** Edit an event's text over REST: the cold-replica-safe mirror of the WS
1816
+ * EDIT frame, and the ONE shape the WS path cannot express: an empty text
1817
+ * on a poll-bearing message legally clears the optional caption (the poll
1818
+ * stays the content; the store rejects empty edits on plain messages).
1819
+ * Viewers reconcile through the store's ENTRY_UPDATED fan-out exactly
1820
+ * like the REST poll verbs. */
1821
+ export async function editRoomEventRest(
1822
+ config: RoomSocialConfig,
1823
+ room: string,
1824
+ seq: number,
1825
+ text: string,
1826
+ ): Promise<void> {
1827
+ await socialRequest(
1828
+ config,
1829
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/events/${requireSeq(seq)}`,
1830
+ { method: "PATCH", body: { text } },
1831
+ );
1832
+ }
1833
+
1834
+ export async function editRoomPoll(
1835
+ config: RoomSocialConfig,
1836
+ room: string,
1837
+ seq: number,
1838
+ patch: { question?: string | undefined; options?: string[] | undefined },
1839
+ ): Promise<RoomRestEvent> {
1840
+ const body: { question?: string; options?: string[] } = {};
1841
+ if (patch.question !== undefined) body.question = patch.question;
1842
+ if (patch.options !== undefined) body.options = patch.options;
1843
+ const result = await socialRequest<{ success?: boolean; event?: RoomRestEvent }>(
1844
+ config,
1845
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}`,
1846
+ { method: "PATCH", body },
1847
+ );
1848
+ if (!result.event) {
1849
+ throw new RoomSocialError("Poll edit returned no event", 502, result);
1850
+ }
1851
+ return result.event;
1852
+ }
1853
+
1854
+ /**
1855
+ * Voter identities, on demand (the entry's summary carries counts only).
1856
+ * Grouped per option; `optionId` narrows to one option and `limit` caps the
1857
+ * identities per option (counts stay exact).
1858
+ */
1859
+ export async function listRoomPollVoters(
1860
+ config: RoomSocialConfig,
1861
+ room: string,
1862
+ seq: number,
1863
+ options: {
1864
+ optionId?: string | undefined;
1865
+ limit?: number | undefined;
1866
+ signal?: AbortSignal | undefined;
1867
+ } = {},
1868
+ ): Promise<RoomPollVotersResult> {
1869
+ const params = new URLSearchParams();
1870
+ if (options.optionId !== undefined) params.set("optionId", options.optionId);
1871
+ if (options.limit !== undefined) params.set("limit", String(options.limit));
1872
+ const init: { signal?: AbortSignal } = {};
1873
+ if (options.signal) init.signal = options.signal;
1874
+ const result = await socialRequest<Partial<RoomPollVotersResult>>(
1875
+ config,
1876
+ `/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/voters${
1877
+ params.size ? `?${params.toString()}` : ""
1878
+ }`,
1879
+ init,
1880
+ );
1881
+ return {
1882
+ room: result.room ?? cleanRoomId(room),
1883
+ seq: result.seq ?? seq,
1884
+ voters: result.voters ?? [],
1885
+ viewerOptionIds: result.viewerOptionIds ?? [],
1886
+ };
1887
+ }
1888
+
1328
1889
  export async function suggestRooms(
1329
1890
  config: RoomSocialConfig,
1330
1891
  request: RoomSuggestionRequest,