letagents 0.12.34 → 0.12.36

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.
Files changed (68) hide show
  1. package/README.md +0 -2
  2. package/dist/mcp/server/runtime/messages.js +18 -1
  3. package/dist/mcp/server/runtime/workspace-capture.js +16 -5
  4. package/dist/mcp/server/tools/supervised-room-turn.js +1 -1
  5. package/package.json +5 -3
  6. package/shared/activation-routing.d.mts +21 -3
  7. package/shared/activation-routing.mjs +37 -0
  8. package/shared/code-block.css +193 -0
  9. package/shared/code-highlighting.d.mts +109 -0
  10. package/shared/code-highlighting.mjs +345 -0
  11. package/shared/codex-owner-isolation.mjs +5 -3
  12. package/shared/codex-read-only-room-tools.d.mts +2 -0
  13. package/shared/codex-read-only-room-tools.mjs +24 -0
  14. package/shared/mcp-workspace-capture-worker.mjs +2 -1
  15. package/shared/message-link-preview-store.d.mts +10 -0
  16. package/shared/message-link-preview-store.mjs +77 -0
  17. package/shared/message-link-previews.d.mts +17 -0
  18. package/shared/message-link-previews.mjs +76 -0
  19. package/shared/message-pin-store.d.mts +20 -0
  20. package/shared/message-pin-store.mjs +102 -0
  21. package/shared/message-pins.d.mts +25 -0
  22. package/shared/message-pins.mjs +16 -0
  23. package/shared/message-reaction-store.d.mts +51 -0
  24. package/shared/message-reaction-store.mjs +290 -0
  25. package/shared/message-reactions.d.mts +73 -0
  26. package/shared/message-reactions.mjs +130 -0
  27. package/shared/message-search-controller.d.mts +38 -0
  28. package/shared/message-search-controller.mjs +99 -0
  29. package/shared/message-search.d.mts +46 -0
  30. package/shared/message-search.mjs +114 -0
  31. package/shared/room-message-urls.d.mts +25 -0
  32. package/shared/room-message-urls.mjs +119 -0
  33. package/shared/room-notification-client.d.mts +18 -0
  34. package/shared/room-notification-client.mjs +140 -0
  35. package/shared/room-notification-preferences.d.mts +17 -0
  36. package/shared/room-notification-preferences.mjs +34 -0
  37. package/shared/room-resource-invalidation.d.mts +5 -0
  38. package/shared/room-resource-invalidation.mjs +6 -0
  39. package/shared/room-settings.d.mts +16 -0
  40. package/shared/room-settings.mjs +14 -0
  41. package/shared/room-typing.d.mts +18 -0
  42. package/shared/room-typing.mjs +129 -0
  43. package/shared/room-unread-client.ts +128 -0
  44. package/shared/room-unread.d.mts +25 -0
  45. package/shared/room-unread.mjs +57 -0
  46. package/shared/room-unread.test.mjs +91 -0
  47. package/shared/rooms/RoomsDirectory.vue +1 -1
  48. package/shared/rooms/directory.ts +1 -0
  49. package/shared/slash-commands.d.mts +31 -0
  50. package/shared/slash-commands.mjs +48 -0
  51. package/shared/slash-commands.test.mjs +26 -0
  52. package/shared/ui/GitHubEventCard.vue +229 -0
  53. package/shared/ui/MessageReactionBar.vue +65 -0
  54. package/shared/ui/MessageReactionPicker.vue +291 -0
  55. package/shared/ui/MessageSearchResults.vue +106 -0
  56. package/shared/ui/PinnedMessages.vue +129 -0
  57. package/shared/ui/TypingIndicator.vue +134 -0
  58. package/shared/ui/message-reactions.css +254 -0
  59. package/shared/ui/message-search.css +186 -0
  60. package/shared/ui/room-agent-work.css +178 -0
  61. package/shared/ui/room-message-motion.ts +143 -0
  62. package/shared/ui/useComposerSlashCommands.ts +92 -0
  63. package/shared/ui/usePinnedMessageVisibility.ts +20 -0
  64. package/shared/ui/useRoomMessageMotion.ts +195 -0
  65. package/shared/ui/useSlidingTabIndicator.ts +72 -0
  66. package/shared/workspace-change-capture.mjs +3 -1
  67. package/shared/workspace-turn-capture.d.mts +4 -1
  68. package/shared/workspace-turn-capture.mjs +203 -6
@@ -0,0 +1,102 @@
1
+ import { isPinMessageId, MESSAGE_PIN_LIMIT } from "./message-pins.mjs";
2
+
3
+ /** One authoritative, bounded room list. All async work is fenced by context. */
4
+ export function createMessagePinStore(options) {
5
+ let epoch = 0;
6
+ let generation = 0;
7
+ let context = null;
8
+ let disposed = false;
9
+ let reading = null;
10
+ let queued = false;
11
+ let mutation = null;
12
+ let state = { pins: [], available: false, loading: false, pending: null, error: null };
13
+ const changed = () => options.onChange?.(state);
14
+ function reset(next = null) {
15
+ epoch++;
16
+ generation++;
17
+ context = next;
18
+ reading = null;
19
+ mutation = null;
20
+ queued = false;
21
+ state = { pins: [], available: false, loading: false, pending: null, error: null };
22
+ changed();
23
+ if (next && !disposed) void refresh();
24
+ }
25
+ async function refresh() {
26
+ if (!context || disposed) return;
27
+ if (reading || mutation) { queued = true; return reading ?? mutation; }
28
+ const ownEpoch = epoch;
29
+ const ownGeneration = generation;
30
+ const ownContext = context;
31
+ state = { ...state, loading: true };
32
+ changed();
33
+ const job = Promise.resolve().then(async () => {
34
+ if (ownEpoch !== epoch || disposed) return;
35
+ try {
36
+ const response = await options.load(ownContext);
37
+ if (ownEpoch !== epoch || ownGeneration !== generation || disposed) return;
38
+ const seen = new Set();
39
+ const pins = (response.pins ?? []).filter((pin) => {
40
+ if (!isPinMessageId(pin.message_id) || seen.has(pin.message_id)) return false;
41
+ seen.add(pin.message_id);
42
+ return true;
43
+ }).slice(0, MESSAGE_PIN_LIMIT);
44
+ state = { ...state, pins, available: response.available !== false, error: null, pending: null };
45
+ } catch (error) {
46
+ if (ownEpoch === epoch && ownGeneration === generation && !disposed) {
47
+ state = { ...state, pending: null, error: error instanceof Error ? error.message : "Pins could not be loaded." };
48
+ }
49
+ } finally {
50
+ if (ownEpoch === epoch && !disposed) {
51
+ reading = null;
52
+ state = { ...state, loading: false };
53
+ changed();
54
+ if (queued && !mutation) { queued = false; void refresh(); }
55
+ }
56
+ }
57
+ });
58
+ reading = job;
59
+ return job;
60
+ }
61
+ async function setPinned(messageId, pinned) {
62
+ if (disposed || !context || !state.available || mutation || state.pending || !isPinMessageId(messageId)) return;
63
+ const ownEpoch = epoch;
64
+ const ownContext = context;
65
+ // A read started before this write is no longer authoritative.
66
+ generation++;
67
+ state = { ...state, pending: messageId, error: null };
68
+ changed();
69
+ const job = Promise.resolve().then(async () => {
70
+ if (ownEpoch !== epoch || disposed) return;
71
+ let succeeded = false;
72
+ try {
73
+ await options.mutate(ownContext, messageId, pinned);
74
+ succeeded = true;
75
+ } catch (error) {
76
+ if (ownEpoch === epoch && !disposed) {
77
+ const message = error instanceof Error ? error.message : "Pins could not be saved.";
78
+ state = { ...state, error: message };
79
+ options.onError?.(message);
80
+ }
81
+ } finally {
82
+ if (ownEpoch === epoch && !disposed) {
83
+ mutation = null;
84
+ state = { ...state, pending: succeeded ? messageId : null };
85
+ changed();
86
+ }
87
+ }
88
+ if (ownEpoch === epoch && !disposed && (succeeded || queued)) {
89
+ // If an older read is still running it must finish before the repair.
90
+ queued = false;
91
+ await refresh();
92
+ }
93
+ });
94
+ mutation = job;
95
+ return job;
96
+ }
97
+ return {
98
+ get state() { return state; },
99
+ reset, refresh, setPinned,
100
+ dispose() { disposed = true; reset(); },
101
+ };
102
+ }
@@ -0,0 +1,25 @@
1
+ export const MESSAGE_PIN_LIMIT: 50;
2
+ export const MESSAGE_PIN_SNIPPET_LENGTH: 240;
3
+ export const MESSAGE_PIN_LIMIT_NOTICE: string;
4
+ export function isPinMessageId(value: unknown): value is string;
5
+ export function messagePinSnippet(text: string, displayText?: string | null): string;
6
+ export interface MessagePin {
7
+ message_id: string;
8
+ sender: string;
9
+ source: string | null;
10
+ timestamp: string;
11
+ thread_root_id: string | null;
12
+ snippet: string;
13
+ pinned_at: string;
14
+ pinned_by: { login: string; name: string; avatar_url: string | null };
15
+ }
16
+ export interface MessagePinsResponse {
17
+ room_id: string;
18
+ pins: MessagePin[];
19
+ available?: boolean;
20
+ }
21
+ export interface MessagePinMutationResponse {
22
+ room_id: string;
23
+ message_id: string;
24
+ changed: boolean;
25
+ }
@@ -0,0 +1,16 @@
1
+ export const MESSAGE_PIN_LIMIT = 50;
2
+ export const MESSAGE_PIN_SNIPPET_LENGTH = 240;
3
+ export const MESSAGE_PIN_LIMIT_NOTICE = "This room already has 50 pinned messages. Unpin a message first.";
4
+
5
+ export function isPinMessageId(value) {
6
+ return typeof value === "string" && /^msg_[1-9]\d*$/.test(value)
7
+ && Number(value.slice(4)) <= 2147483647;
8
+ }
9
+
10
+ /** Match the clients' displayed-text fallback; return text, never HTML. */
11
+ export function messagePinSnippet(text, displayText) {
12
+ const chars = Array.from((displayText || text || "").replace(/\s+/gu, " ").trim());
13
+ return chars.length > MESSAGE_PIN_SNIPPET_LENGTH
14
+ ? chars.slice(0, MESSAGE_PIN_SNIPPET_LENGTH - 1).join("") + "…"
15
+ : chars.join("");
16
+ }
@@ -0,0 +1,51 @@
1
+ import type { MessageReaction, MessageReactor } from "./message-reactions.mjs";
2
+
3
+ export interface MessageReactionStoreOptions {
4
+ /**
5
+ * Read the reactions of the messages from `firstMessageId` to `lastMessageId`
6
+ * (at most MESSAGE_REACTION_RANGE_MAX_SPAN apart). The answer may stop early
7
+ * and name the message to continue from.
8
+ */
9
+ load(firstMessageId: string, lastMessageId: string): Promise<{
10
+ reactions: Record<string, unknown>;
11
+ viewer_reactions?: Record<string, unknown>;
12
+ next_first_message_id?: string | null;
13
+ }>;
14
+ /** Add (`reacted: true`) or remove the viewer's reaction; resolves with the message's reactions. */
15
+ mutate(messageId: string, emoji: string, reacted: boolean): Promise<{ reactions: unknown }>;
16
+ /** The signed-in person, or null when nobody can react. */
17
+ viewer(): MessageReactor | null;
18
+ onChange?(): void;
19
+ onError?(error: unknown, during: "load" | "mutate"): void;
20
+ /** How long newly tracked messages are gathered before one read confirms them. */
21
+ refreshDelayMs?: number;
22
+ }
23
+
24
+ export interface MessageReactionStore {
25
+ /** A stable empty list when the message has no reactions. */
26
+ get(messageId: string): readonly MessageReaction[];
27
+ viewerReacted(messageId: string, emoji: string): boolean;
28
+ /**
29
+ * A rendered message registers itself; the returned function unregisters it.
30
+ * Its reactions start from what it carried and are confirmed by a read a
31
+ * moment after it is first tracked.
32
+ */
33
+ track(message: { id: string; reactions?: unknown }): () => void;
34
+ /** Re-read the reactions of every message on screen. */
35
+ refresh(): Promise<void>;
36
+ /** Add the viewer's reaction, or remove it if it is already there. */
37
+ toggle(messageId: string, emoji: string): Promise<void>;
38
+ /**
39
+ * Forget everything. With `keepTracked` the messages on screen stay
40
+ * registered (the signed-in person changed); without it they are dropped
41
+ * too (the room changed).
42
+ */
43
+ reset(options?: { keepTracked?: boolean }): void;
44
+ }
45
+
46
+ /**
47
+ * The client-side state of a room's reactions, shared by the desktop and web
48
+ * apps: seeded from the messages on screen, confirmed and kept current by
49
+ * reading the server.
50
+ */
51
+ export function createMessageReactionStore(options: MessageReactionStoreOptions): MessageReactionStore;
@@ -0,0 +1,290 @@
1
+ // The client-side state of a room's reactions, shared by the desktop and web
2
+ // apps. It is framework-free: the app supplies the two API calls and is told
3
+ // when something changed.
4
+ //
5
+ // A message on screen registers itself with `track`. Its reactions start from
6
+ // what the message carried when it was read, so nothing shifts as the room
7
+ // opens, and are then confirmed by a read of the server: shortly after a
8
+ // message is first tracked, and whenever the app calls `refresh` because the
9
+ // room reported a `message_reactions` invalidation or its stream reconnected.
10
+ import {
11
+ MESSAGE_REACTION_RANGE_MAX_SPAN,
12
+ normalizeMessageReactionEmoji,
13
+ normalizeMessageReactions,
14
+ toggleViewerMessageReaction,
15
+ viewerReactedWith,
16
+ } from "./message-reactions.mjs";
17
+
18
+ const NONE = Object.freeze([]);
19
+
20
+ function messageNumber(id) {
21
+ const match = /^msg_([1-9]\d*)$/.exec(typeof id === "string" ? id : "");
22
+ return match ? Number(match[1]) : null;
23
+ }
24
+
25
+ function sameReactions(left, right) {
26
+ return JSON.stringify(left) === JSON.stringify(right);
27
+ }
28
+
29
+ /** Split sorted message numbers into runs the server will read in one request. */
30
+ function spans(numbers) {
31
+ const runs = [];
32
+ for (const number of numbers) {
33
+ const run = runs.at(-1);
34
+ if (run && number - run.first < MESSAGE_REACTION_RANGE_MAX_SPAN) run.last = number;
35
+ else runs.push({ first: number, last: number });
36
+ }
37
+ return runs;
38
+ }
39
+
40
+ export function createMessageReactionStore({ load, mutate, viewer, onChange, onError, refreshDelayMs = 50 }) {
41
+ let reactions = new Map();
42
+ // Which emoji the viewer reacted with, per message, once the server said so.
43
+ // Until then the listed reactors answer, which can miss a viewer past the cap.
44
+ let viewerEmoji = new Map();
45
+ // Messages whose own `reactions` were already taken; a later copy of the
46
+ // same message may be older than what a read has since learned.
47
+ let seeded = new Set();
48
+ // One chain of toggles per message, so rapid clicks reach the server in order.
49
+ let pending = new Map();
50
+ // Messages on screen: id -> the message and one token per registration.
51
+ let tracked = new Map();
52
+ // Orders the viewer's own changes against reads that began before them.
53
+ let mutationSeq = 0;
54
+ let lastMutation = new Map();
55
+ // Answers on their way are dropped when `epoch` moves; registrations made
56
+ // before `trackingEpoch` moved no longer own anything.
57
+ let epoch = 0;
58
+ let trackingEpoch = 0;
59
+ let refreshing = null;
60
+ let refreshTimer = null;
61
+
62
+ function store(messageId, next) {
63
+ if (sameReactions(reactions.get(messageId) ?? NONE, next)) return false;
64
+ if (next.length) reactions.set(messageId, next);
65
+ else reactions.delete(messageId);
66
+ return true;
67
+ }
68
+
69
+ function get(messageId) {
70
+ return reactions.get(messageId) ?? NONE;
71
+ }
72
+
73
+ function viewerReacted(messageId, emoji) {
74
+ const known = viewerEmoji.get(messageId);
75
+ if (known) return known.has(emoji);
76
+ return viewerReactedWith(get(messageId).find((reaction) => reaction.emoji === emoji), viewer()?.login);
77
+ }
78
+
79
+ function seed(message) {
80
+ const id = message?.id;
81
+ if (messageNumber(id) === null || seeded.has(id)) return false;
82
+ seeded.add(id);
83
+ if (pending.has(id)) return false;
84
+ return store(id, normalizeMessageReactions(message.reactions));
85
+ }
86
+
87
+ function scheduleRefresh() {
88
+ if (refreshTimer !== null) return;
89
+ refreshTimer = setTimeout(() => {
90
+ refreshTimer = null;
91
+ void refresh();
92
+ }, refreshDelayMs);
93
+ }
94
+
95
+ /**
96
+ * A rendered message registers itself; the returned function unregisters it.
97
+ * The first registration of a message also asks the server, a moment later
98
+ * and together with every other message registered meanwhile: the copy on
99
+ * screen may predate a change that happened while the message was not shown.
100
+ */
101
+ function track(message) {
102
+ const id = message?.id;
103
+ if (messageNumber(id) === null) return () => {};
104
+ const registeredIn = trackingEpoch;
105
+ const token = Symbol("tracked");
106
+ const entry = tracked.get(id);
107
+ if (entry) {
108
+ entry.tokens.add(token);
109
+ entry.message = message;
110
+ } else {
111
+ tracked.set(id, { message, tokens: new Set([token]) });
112
+ if (seed(message)) onChange?.();
113
+ scheduleRefresh();
114
+ }
115
+ return () => {
116
+ if (registeredIn !== trackingEpoch) return;
117
+ const current = tracked.get(id);
118
+ if (!current?.tokens.delete(token) || current.tokens.size > 0) return;
119
+ tracked.delete(id);
120
+ };
121
+ }
122
+
123
+ async function readSpan(first, last, startedIn, startedAtMutation) {
124
+ let from = first;
125
+ // Every continuation advances within a span of at most 1,000 messages.
126
+ // An arbitrary page cap would permanently starve dense ranges' tails.
127
+ while (from <= last) {
128
+ let changed = false;
129
+ const read = await load(`msg_${from}`, `msg_${last}`);
130
+ // The room changed while this read was in flight.
131
+ if (startedIn !== epoch) return;
132
+ const byMessage = read?.reactions && typeof read.reactions === "object" ? read.reactions : {};
133
+ const viewerByMessage = read?.viewer_reactions && typeof read.viewer_reactions === "object"
134
+ ? read.viewer_reactions
135
+ : null;
136
+ // A read the server cut short is complete only below the message it names.
137
+ const next = messageNumber(read?.next_first_message_id);
138
+ const completeBelow = next !== null && next > from ? next : last + 1;
139
+ for (const [id] of tracked) {
140
+ const number = messageNumber(id);
141
+ if (number < from || number >= completeBelow) continue;
142
+ // The viewer changed this message after the read began: the read is older.
143
+ if (pending.has(id) || (lastMutation.get(id) ?? 0) > startedAtMutation) continue;
144
+ seeded.add(id);
145
+ if (store(id, normalizeMessageReactions(byMessage[id]))) changed = true;
146
+ if (viewerByMessage) {
147
+ const mine = new Set((Array.isArray(viewerByMessage[id]) ? viewerByMessage[id] : [])
148
+ .map(normalizeMessageReactionEmoji)
149
+ .filter(Boolean));
150
+ const known = viewerEmoji.get(id);
151
+ if (!known || known.size !== mine.size || [...mine].some((emoji) => !known.has(emoji))) changed = true;
152
+ viewerEmoji.set(id, mine);
153
+ }
154
+ }
155
+ // Publish completed pages even if a later page fails or is slow.
156
+ if (changed) onChange?.();
157
+ if (next === null || next <= from) break;
158
+ from = next;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Re-read the reactions of every message on screen. Calls made while a read
164
+ * is in flight collapse into one follow-up read.
165
+ */
166
+ function refresh() {
167
+ if (refreshTimer !== null) {
168
+ clearTimeout(refreshTimer);
169
+ refreshTimer = null;
170
+ }
171
+ if (refreshing) {
172
+ refreshing.again = true;
173
+ return refreshing.promise;
174
+ }
175
+ const startedIn = epoch;
176
+ const cycle = { again: false, promise: null };
177
+ refreshing = cycle;
178
+ cycle.promise = (async () => {
179
+ do {
180
+ cycle.again = false;
181
+ const startedAtMutation = mutationSeq;
182
+ const numbers = Array.from(tracked.keys(), messageNumber).sort((left, right) => left - right);
183
+ try {
184
+ for (const span of spans(numbers)) {
185
+ if (startedIn !== epoch) break;
186
+ await readSpan(span.first, span.last, startedIn, startedAtMutation);
187
+ }
188
+ } catch (error) {
189
+ // Keep what is shown; the next invalidation reads again.
190
+ if (startedIn === epoch) onError?.(error, "load");
191
+ }
192
+ } while (cycle.again && startedIn === epoch);
193
+ })().finally(() => {
194
+ if (refreshing === cycle) refreshing = null;
195
+ });
196
+ return cycle.promise;
197
+ }
198
+
199
+ /** Add the viewer's reaction, or remove it if it is already there. */
200
+ function toggle(messageId, emoji) {
201
+ const person = viewer();
202
+ if (!person || messageNumber(messageId) === null) return Promise.resolve();
203
+ const startedIn = epoch;
204
+
205
+ const run = async () => {
206
+ if (startedIn !== epoch) return;
207
+ const before = get(messageId);
208
+ const mineBefore = viewerEmoji.get(messageId);
209
+ const listed = viewerReactedWith(before.find((reaction) => reaction.emoji === emoji), person.login);
210
+ const reacted = mineBefore ? mineBefore.has(emoji) : listed;
211
+ // A viewer the list does not name can only lower or raise the count.
212
+ const optimistic = reacted === listed
213
+ ? toggleViewerMessageReaction(before, emoji, person)
214
+ : before
215
+ .map((reaction) => (reaction.emoji === emoji ? { ...reaction, count: reaction.count + (reacted ? -1 : 1) } : reaction))
216
+ .filter((reaction) => reaction.count > 0);
217
+ const mineOptimistic = new Set(mineBefore ?? before
218
+ .filter((reaction) => viewerReactedWith(reaction, person.login))
219
+ .map((reaction) => reaction.emoji));
220
+ if (reacted) mineOptimistic.delete(emoji);
221
+ else mineOptimistic.add(emoji);
222
+
223
+ lastMutation.set(messageId, ++mutationSeq);
224
+ store(messageId, optimistic);
225
+ viewerEmoji.set(messageId, mineOptimistic);
226
+ onChange?.();
227
+ try {
228
+ const result = await mutate(messageId, emoji, !reacted);
229
+ if (startedIn !== epoch) return;
230
+ lastMutation.set(messageId, ++mutationSeq);
231
+ seeded.add(messageId);
232
+ // The server's list may hold what others did meanwhile; whether the
233
+ // viewer reacted is exactly what was just asked for.
234
+ store(messageId, normalizeMessageReactions(result?.reactions));
235
+ onChange?.();
236
+ // An invalidation read may have been skipped while this write was
237
+ // pending. Confirm all viewer emoji too (another tab may change them).
238
+ scheduleRefresh();
239
+ } catch (error) {
240
+ if (startedIn !== epoch) return;
241
+ lastMutation.set(messageId, ++mutationSeq);
242
+ store(messageId, before);
243
+ if (mineBefore) viewerEmoji.set(messageId, mineBefore);
244
+ else viewerEmoji.delete(messageId);
245
+ onChange?.();
246
+ onError?.(error, "mutate");
247
+ // A read that landed during the attempt was skipped for this message.
248
+ scheduleRefresh();
249
+ }
250
+ };
251
+
252
+ const queued = (pending.get(messageId) ?? Promise.resolve()).then(run);
253
+ const settled = queued.finally(() => {
254
+ if (pending.get(messageId) === settled) pending.delete(messageId);
255
+ });
256
+ pending.set(messageId, settled);
257
+ return settled;
258
+ }
259
+
260
+ /**
261
+ * Forget everything. With `keepTracked`, the messages on screen stay
262
+ * registered and start again from what they carried (the signed-in person
263
+ * changed); without it they are dropped too (the room changed).
264
+ */
265
+ function reset({ keepTracked = false } = {}) {
266
+ const hadAny = reactions.size > 0 || viewerEmoji.size > 0;
267
+ // Answers on their way belong to the previous room or person.
268
+ epoch += 1;
269
+ // A stalled old-room read must not delay the next room or viewer. Its
270
+ // eventual cleanup owns only its own cycle, never the new refresh.
271
+ refreshing = null;
272
+ reactions = new Map();
273
+ viewerEmoji = new Map();
274
+ seeded = new Set();
275
+ lastMutation = new Map();
276
+ pending = new Map();
277
+ if (refreshTimer !== null) clearTimeout(refreshTimer);
278
+ refreshTimer = null;
279
+ if (keepTracked) {
280
+ for (const entry of tracked.values()) seed(entry.message);
281
+ if (tracked.size) scheduleRefresh();
282
+ } else {
283
+ trackingEpoch += 1;
284
+ tracked = new Map();
285
+ }
286
+ if (hadAny || tracked.size) onChange?.();
287
+ }
288
+
289
+ return { get, viewerReacted, track, refresh, toggle, reset };
290
+ }
@@ -0,0 +1,73 @@
1
+ export const MESSAGE_REACTION_EMOJI_MAX_BYTES: 64;
2
+ export const MESSAGE_REACTION_MAX_DISTINCT_PER_MESSAGE: 20;
3
+ /** A reaction always reports its exact count; only the named reactors are capped. */
4
+ export const MESSAGE_REACTION_MAX_REACTORS_LISTED: 50;
5
+ /** One range read covers at most this many message numbers. */
6
+ export const MESSAGE_REACTION_RANGE_MAX_SPAN: 1000;
7
+ /** One range read returns at most this many reactor rows before it stops and names where to continue. */
8
+ export const MESSAGE_REACTION_MAX_PER_READ: 2000;
9
+ export const MESSAGE_REACTION_QUICK_EMOJI: readonly string[];
10
+
11
+ /** Public identity only; account ids stay internal. */
12
+ export interface MessageReactor {
13
+ login: string;
14
+ name: string;
15
+ avatar_url: string | null;
16
+ }
17
+
18
+ export interface MessageReaction {
19
+ emoji: string;
20
+ /** Exact number of people who reacted with this emoji. */
21
+ count: number;
22
+ /** Earliest first, at most MESSAGE_REACTION_MAX_REACTORS_LISTED. */
23
+ reactors: MessageReactor[];
24
+ }
25
+
26
+ /** `GET /rooms/:room/messages/reactions` */
27
+ export interface MessageReactionsRangeResponse {
28
+ room_id: string;
29
+ first_message_id: string;
30
+ last_message_id: string;
31
+ /** Only messages that have at least one reaction appear. */
32
+ reactions: Record<string, MessageReaction[]>;
33
+ /**
34
+ * The emoji the signed-in caller reacted with, per message. Present only
35
+ * for a person's session; exact even where `reactors` is capped.
36
+ */
37
+ viewer_reactions?: Record<string, string[]>;
38
+ /**
39
+ * Null when the whole range was read. Otherwise the read stopped early:
40
+ * it is complete below this message, and continues from it.
41
+ */
42
+ next_first_message_id: string | null;
43
+ }
44
+
45
+ /** `PUT` and `DELETE /rooms/:room/messages/:message/reactions/:emoji` */
46
+ export interface MessageReactionMutationResponse {
47
+ room_id: string;
48
+ message_id: string;
49
+ emoji: string;
50
+ /** False when the request repeated a reaction the viewer already had (or had already removed). */
51
+ changed: boolean;
52
+ reactions: MessageReaction[];
53
+ }
54
+
55
+ /** The canonical form of one emoji, or null when the value is anything else. */
56
+ export function normalizeMessageReactionEmoji(value: unknown): string | null;
57
+ /** Read a server reaction list defensively; malformed entries are dropped. */
58
+ export function normalizeMessageReactions(value: unknown): MessageReaction[];
59
+ export function viewerReactedWith(
60
+ reaction: Pick<MessageReaction, "reactors"> | null | undefined,
61
+ viewerLogin: string | null | undefined,
62
+ ): boolean;
63
+ /** "You, Ada and 3 others reacted with 👍" */
64
+ export function describeMessageReaction(
65
+ reaction: MessageReaction,
66
+ viewerLogin: string | null | undefined,
67
+ ): string;
68
+ /** The reaction list after the viewer toggles one emoji, for an optimistic update. */
69
+ export function toggleViewerMessageReaction(
70
+ reactions: readonly MessageReaction[] | null | undefined,
71
+ emoji: string,
72
+ viewer: MessageReactor,
73
+ ): MessageReaction[];
@@ -0,0 +1,130 @@
1
+ // Emoji reactions on room messages: the limits, the quick palette and the one
2
+ // validation rule that the server and both clients share.
3
+
4
+ export const MESSAGE_REACTION_EMOJI_MAX_BYTES = 64;
5
+ export const MESSAGE_REACTION_MAX_DISTINCT_PER_MESSAGE = 20;
6
+ // A reaction always reports its exact count; only the named reactors are capped.
7
+ export const MESSAGE_REACTION_MAX_REACTORS_LISTED = 50;
8
+ // One range read covers at most this many message numbers, and returns at most
9
+ // this many reactor rows before it stops and names where to continue.
10
+ export const MESSAGE_REACTION_RANGE_MAX_SPAN = 1000;
11
+ export const MESSAGE_REACTION_MAX_PER_READ = 2000;
12
+
13
+ export const MESSAGE_REACTION_QUICK_EMOJI = Object.freeze([
14
+ "👍", "👎", "✅", "❌", "👀", "🙏", "🎉", "🚀",
15
+ "❤️", "😂", "🤔", "😅", "🔥", "💯", "👏", "🙌",
16
+ "🤝", "⚠️", "🐛", "🔧", "📌", "⏳", "💡", "❓",
17
+ ]);
18
+
19
+ const VARIATION_SELECTOR_16 = "️";
20
+ const encoder = new TextEncoder();
21
+
22
+ // `\p{RGI_Emoji}` needs the `v` flag, which every runtime we ship has. The
23
+ // fallback keeps an older browser usable; the server stays the authority.
24
+ const emojiPattern = (() => {
25
+ try {
26
+ return new RegExp("^\\p{RGI_Emoji}$", "v");
27
+ } catch {
28
+ return /^(?:\p{Regional_Indicator}{2}|[#*0-9]️?⃣|\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?(?:‍\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?)*)$/u;
29
+ }
30
+ })();
31
+
32
+ /**
33
+ * Return the canonical form of one emoji, or null when the value is anything
34
+ * else (text, several emoji, an oversized sequence).
35
+ */
36
+ export function normalizeMessageReactionEmoji(value) {
37
+ if (typeof value !== "string") return null;
38
+ const trimmed = value.trim();
39
+ if (!trimmed || encoder.encode(trimmed).length > MESSAGE_REACTION_EMOJI_MAX_BYTES) return null;
40
+ if (emojiPattern.test(trimmed)) return trimmed;
41
+ // A bare text-style symbol such as "❤" is the same reaction as "❤️".
42
+ const qualified = `${trimmed}${VARIATION_SELECTOR_16}`;
43
+ return emojiPattern.test(qualified) ? qualified : null;
44
+ }
45
+
46
+ function normalizeReactor(value) {
47
+ if (!value || typeof value !== "object") return null;
48
+ const login = typeof value.login === "string" ? value.login.trim() : "";
49
+ if (!login || login.length > 256) return null;
50
+ const name = typeof value.name === "string" && value.name.trim() ? value.name.trim().slice(0, 256) : login;
51
+ const avatarUrl = typeof value.avatar_url === "string" && /^https:\/\//.test(value.avatar_url)
52
+ ? value.avatar_url.slice(0, 2048)
53
+ : null;
54
+ return { login, name, avatar_url: avatarUrl };
55
+ }
56
+
57
+ /**
58
+ * Read a server reaction list defensively. Anything malformed is dropped, so a
59
+ * client never renders a reaction it could not also send.
60
+ */
61
+ export function normalizeMessageReactions(value) {
62
+ if (!Array.isArray(value)) return [];
63
+ const reactions = [];
64
+ const seen = new Set();
65
+ for (const item of value) {
66
+ if (reactions.length >= MESSAGE_REACTION_MAX_DISTINCT_PER_MESSAGE) break;
67
+ if (!item || typeof item !== "object") continue;
68
+ const emoji = normalizeMessageReactionEmoji(item.emoji);
69
+ if (!emoji || seen.has(emoji)) continue;
70
+ const reactors = (Array.isArray(item.reactors) ? item.reactors : [])
71
+ .slice(0, MESSAGE_REACTION_MAX_REACTORS_LISTED)
72
+ .map(normalizeReactor)
73
+ .filter(Boolean);
74
+ const count = Number.isSafeInteger(item.count) && item.count > 0 ? item.count : reactors.length;
75
+ if (count <= 0) continue;
76
+ seen.add(emoji);
77
+ reactions.push({ emoji, count: Math.max(count, reactors.length), reactors });
78
+ }
79
+ return reactions;
80
+ }
81
+
82
+ function sameLogin(left, right) {
83
+ return typeof left === "string" && typeof right === "string"
84
+ && left.length > 0 && left.toLowerCase() === right.toLowerCase();
85
+ }
86
+
87
+ /** Whether the listed reactors include the viewer. */
88
+ export function viewerReactedWith(reaction, viewerLogin) {
89
+ return Boolean(reaction?.reactors?.some((reactor) => sameLogin(reactor.login, viewerLogin)));
90
+ }
91
+
92
+ /** "You, Ada and 3 others reacted with 👍" — the tooltip and the accessible name. */
93
+ export function describeMessageReaction(reaction, viewerLogin) {
94
+ const reactors = Array.isArray(reaction?.reactors) ? reaction.reactors : [];
95
+ const viewerReacted = viewerReactedWith(reaction, viewerLogin);
96
+ const names = [
97
+ ...(viewerReacted ? ["You"] : []),
98
+ ...reactors.filter((reactor) => !sameLogin(reactor.login, viewerLogin)).map((reactor) => reactor.name),
99
+ ];
100
+ const count = Math.max(Number(reaction?.count) || 0, names.length);
101
+ const shown = names.slice(0, 3);
102
+ const others = count - shown.length;
103
+ if (others > 0) shown.push(others === 1 ? "1 other" : `${others} others`);
104
+ const who = shown.length <= 1
105
+ ? shown[0] ?? "Someone"
106
+ : `${shown.slice(0, -1).join(", ")} and ${shown.at(-1)}`;
107
+ return `${who} reacted with ${reaction?.emoji ?? ""}`.trim();
108
+ }
109
+
110
+ /**
111
+ * The reaction list after the viewer toggles one emoji, for an optimistic
112
+ * update. The server's answer replaces it either way.
113
+ */
114
+ export function toggleViewerMessageReaction(reactions, emoji, viewer) {
115
+ const current = Array.isArray(reactions) ? reactions : [];
116
+ const existing = current.find((reaction) => reaction.emoji === emoji);
117
+ if (!existing) {
118
+ return [...current, { emoji, count: 1, reactors: [{ ...viewer }] }];
119
+ }
120
+ if (viewerReactedWith(existing, viewer.login)) {
121
+ const reactors = existing.reactors.filter((reactor) => !sameLogin(reactor.login, viewer.login));
122
+ const count = existing.count - 1;
123
+ return count <= 0
124
+ ? current.filter((reaction) => reaction !== existing)
125
+ : current.map((reaction) => (reaction === existing ? { ...existing, count, reactors } : reaction));
126
+ }
127
+ return current.map((reaction) => (reaction === existing
128
+ ? { ...existing, count: existing.count + 1, reactors: [...existing.reactors, { ...viewer }] }
129
+ : reaction));
130
+ }