letagents 0.12.34 → 0.12.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -2
- package/dist/mcp/server/runtime/messages.js +18 -1
- package/package.json +1 -1
- package/shared/code-block.css +193 -0
- package/shared/code-highlighting.d.mts +109 -0
- package/shared/code-highlighting.mjs +345 -0
- package/shared/codex-owner-isolation.mjs +1 -1
- package/shared/message-link-preview-store.d.mts +10 -0
- package/shared/message-link-preview-store.mjs +77 -0
- package/shared/message-link-previews.d.mts +16 -0
- package/shared/message-link-previews.mjs +70 -0
- package/shared/message-pin-store.d.mts +20 -0
- package/shared/message-pin-store.mjs +102 -0
- package/shared/message-pins.d.mts +25 -0
- package/shared/message-pins.mjs +16 -0
- package/shared/message-reaction-store.d.mts +51 -0
- package/shared/message-reaction-store.mjs +290 -0
- package/shared/message-reactions.d.mts +73 -0
- package/shared/message-reactions.mjs +130 -0
- package/shared/message-search-controller.d.mts +38 -0
- package/shared/message-search-controller.mjs +99 -0
- package/shared/message-search.d.mts +46 -0
- package/shared/message-search.mjs +114 -0
- package/shared/room-message-urls.d.mts +25 -0
- package/shared/room-message-urls.mjs +119 -0
- package/shared/room-notification-client.d.mts +18 -0
- package/shared/room-notification-client.mjs +140 -0
- package/shared/room-notification-preferences.d.mts +17 -0
- package/shared/room-notification-preferences.mjs +34 -0
- package/shared/room-resource-invalidation.d.mts +5 -0
- package/shared/room-resource-invalidation.mjs +6 -0
- package/shared/room-typing.d.mts +16 -0
- package/shared/room-typing.mjs +112 -0
- package/shared/room-unread-client.ts +128 -0
- package/shared/room-unread.d.mts +25 -0
- package/shared/room-unread.mjs +57 -0
- package/shared/room-unread.test.mjs +91 -0
- package/shared/rooms/RoomsDirectory.vue +1 -1
- package/shared/rooms/directory.ts +1 -0
- package/shared/slash-commands.d.mts +31 -0
- package/shared/slash-commands.mjs +48 -0
- package/shared/slash-commands.test.mjs +26 -0
- package/shared/ui/MessageReactionBar.vue +65 -0
- package/shared/ui/MessageReactionPicker.vue +291 -0
- package/shared/ui/MessageSearchResults.vue +106 -0
- package/shared/ui/PinnedMessages.vue +124 -0
- package/shared/ui/TypingIndicator.vue +22 -0
- package/shared/ui/message-reactions.css +254 -0
- package/shared/ui/message-search.css +186 -0
- package/shared/ui/useComposerSlashCommands.ts +92 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { MessageSearchQueryError, MessageSearchResponse } from "./message-search.mjs";
|
|
2
|
+
|
|
3
|
+
export const MESSAGE_SEARCH_DEBOUNCE_MS: 250;
|
|
4
|
+
|
|
5
|
+
export interface MessageSearchState<Hit extends { id: string }> {
|
|
6
|
+
/**
|
|
7
|
+
* `idle`: nothing to search yet. `invalid`: the query breaks a limit (see `error`).
|
|
8
|
+
* `loading`: the first page is on its way. `ready` / `error`: it arrived, or failed.
|
|
9
|
+
*/
|
|
10
|
+
status: "idle" | "invalid" | "loading" | "ready" | "error";
|
|
11
|
+
terms: readonly string[];
|
|
12
|
+
/** Newest first. */
|
|
13
|
+
hits: readonly Hit[];
|
|
14
|
+
hasMore: boolean;
|
|
15
|
+
loadingMore: boolean;
|
|
16
|
+
/** A query limit name when `invalid`; otherwise the last request's failure, if any. */
|
|
17
|
+
error: MessageSearchQueryError | string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MessageSearchController<Hit extends { id: string }> {
|
|
21
|
+
readonly state: MessageSearchState<Hit>;
|
|
22
|
+
/** Search for a new query after a short pause in typing. */
|
|
23
|
+
setQuery(value: string): void;
|
|
24
|
+
/** Read the next, older page of the current results. */
|
|
25
|
+
loadMore(): Promise<void>;
|
|
26
|
+
/** Stop searching; call when the search closes or the room changes. */
|
|
27
|
+
reset(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The client-side state of one room's history search, shared by the desktop
|
|
32
|
+
* and web apps. Answers that arrive for an earlier query are dropped.
|
|
33
|
+
*/
|
|
34
|
+
export function createMessageSearchController<Hit extends { id: string }>(options: {
|
|
35
|
+
search(query: string, before: string | null): Promise<Pick<MessageSearchResponse<Hit>, "messages" | "has_more" | "next_before"> & { terms?: string[] }>;
|
|
36
|
+
onChange?(): void;
|
|
37
|
+
debounceMs?: number;
|
|
38
|
+
}): MessageSearchController<Hit>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The client-side state of one room's history search, shared by the desktop
|
|
2
|
+
// and web apps. Framework-free: the app supplies the request and is told when
|
|
3
|
+
// something changed.
|
|
4
|
+
import { parseMessageSearchQuery } from "./message-search.mjs";
|
|
5
|
+
|
|
6
|
+
export const MESSAGE_SEARCH_DEBOUNCE_MS = 250;
|
|
7
|
+
|
|
8
|
+
const IDLE = Object.freeze({ status: "idle", terms: [], hits: [], hasMore: false, loadingMore: false, error: null });
|
|
9
|
+
|
|
10
|
+
export function createMessageSearchController({ search, onChange, debounceMs = MESSAGE_SEARCH_DEBOUNCE_MS }) {
|
|
11
|
+
let state = IDLE;
|
|
12
|
+
let query = "";
|
|
13
|
+
let nextBefore = null;
|
|
14
|
+
// Every new query (and reset) starts an epoch; answers from an earlier one are dropped.
|
|
15
|
+
let epoch = 0;
|
|
16
|
+
let timer = null;
|
|
17
|
+
|
|
18
|
+
function set(next) {
|
|
19
|
+
state = next;
|
|
20
|
+
onChange?.();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cancelTimer() {
|
|
24
|
+
if (timer !== null) clearTimeout(timer);
|
|
25
|
+
timer = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function run(terms, startedIn) {
|
|
29
|
+
try {
|
|
30
|
+
const page = await search(query, null);
|
|
31
|
+
if (startedIn !== epoch) return;
|
|
32
|
+
nextBefore = page?.next_before ?? null;
|
|
33
|
+
set({
|
|
34
|
+
status: "ready",
|
|
35
|
+
terms: Array.isArray(page?.terms) ? page.terms : terms,
|
|
36
|
+
hits: Array.isArray(page?.messages) ? page.messages : [],
|
|
37
|
+
hasMore: Boolean(page?.has_more && nextBefore),
|
|
38
|
+
loadingMore: false,
|
|
39
|
+
error: null,
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (startedIn !== epoch) return;
|
|
43
|
+
set({ ...IDLE, status: "error", terms, error: error instanceof Error && error.message ? error.message : "Search failed." });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Search for a new query after a short pause in typing. */
|
|
48
|
+
function setQuery(value) {
|
|
49
|
+
const next = typeof value === "string" ? value : "";
|
|
50
|
+
if (next.trim() === query.trim() && state !== IDLE) return;
|
|
51
|
+
query = next;
|
|
52
|
+
epoch += 1;
|
|
53
|
+
cancelTimer();
|
|
54
|
+
nextBefore = null;
|
|
55
|
+
const parsed = parseMessageSearchQuery(query);
|
|
56
|
+
if (parsed.error) {
|
|
57
|
+
// Too short is simply "not searching yet"; the other limits are worth saying.
|
|
58
|
+
set(parsed.error === "too_short" ? IDLE : { ...IDLE, status: "invalid", error: parsed.error });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const startedIn = epoch;
|
|
62
|
+
set({ ...IDLE, status: "loading", terms: parsed.terms });
|
|
63
|
+
timer = setTimeout(() => {
|
|
64
|
+
timer = null;
|
|
65
|
+
void run(parsed.terms, startedIn);
|
|
66
|
+
}, debounceMs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read the next, older page of the current results. */
|
|
70
|
+
async function loadMore() {
|
|
71
|
+
if (state.status !== "ready" || !state.hasMore || state.loadingMore || !nextBefore) return;
|
|
72
|
+
const startedIn = epoch;
|
|
73
|
+
const before = nextBefore;
|
|
74
|
+
set({ ...state, loadingMore: true, error: null });
|
|
75
|
+
try {
|
|
76
|
+
const page = await search(query, before);
|
|
77
|
+
if (startedIn !== epoch) return;
|
|
78
|
+
nextBefore = page?.next_before ?? null;
|
|
79
|
+
const known = new Set(state.hits.map((hit) => hit.id));
|
|
80
|
+
const more = (Array.isArray(page?.messages) ? page.messages : []).filter((hit) => !known.has(hit.id));
|
|
81
|
+
set({ ...state, hits: [...state.hits, ...more], hasMore: Boolean(page?.has_more && nextBefore), loadingMore: false });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (startedIn !== epoch) return;
|
|
84
|
+
// Keep what is shown; the person can press "more" again.
|
|
85
|
+
set({ ...state, loadingMore: false, error: error instanceof Error && error.message ? error.message : "Search failed." });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Stop searching; call when the search closes or the room changes. */
|
|
90
|
+
function reset() {
|
|
91
|
+
query = "";
|
|
92
|
+
epoch += 1;
|
|
93
|
+
cancelTimer();
|
|
94
|
+
nextBefore = null;
|
|
95
|
+
if (state !== IDLE) set(IDLE);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { get state() { return state; }, setQuery, loadMore, reset };
|
|
99
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const MESSAGE_SEARCH_MIN_QUERY_CHARS: 2;
|
|
2
|
+
export const MESSAGE_SEARCH_MAX_QUERY_CHARS: 200;
|
|
3
|
+
export const MESSAGE_SEARCH_MAX_TERMS: 6;
|
|
4
|
+
export const MESSAGE_SEARCH_DEFAULT_LIMIT: 30;
|
|
5
|
+
export const MESSAGE_SEARCH_MAX_LIMIT: 50;
|
|
6
|
+
|
|
7
|
+
export type MessageSearchQueryError = "too_short" | "too_long" | "too_many_terms";
|
|
8
|
+
|
|
9
|
+
export type ParsedMessageSearchQuery =
|
|
10
|
+
| { terms: string[]; error?: undefined }
|
|
11
|
+
| { error: MessageSearchQueryError; terms?: undefined };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Split a query into its terms, or say why it cannot be searched. A term is a
|
|
15
|
+
* word, or a phrase in double quotes; a message matches when it contains every
|
|
16
|
+
* term as a substring, ignoring case.
|
|
17
|
+
*/
|
|
18
|
+
export function parseMessageSearchQuery(value: unknown): ParsedMessageSearchQuery;
|
|
19
|
+
|
|
20
|
+
/** Whether a text contains every term, the same test the server applies. */
|
|
21
|
+
export function textMatchesMessageSearch(text: unknown, terms: readonly string[]): boolean;
|
|
22
|
+
|
|
23
|
+
/** `GET /rooms/:room/messages/search` */
|
|
24
|
+
export interface MessageSearchResponse<Message = unknown> {
|
|
25
|
+
room_id: string;
|
|
26
|
+
terms: string[];
|
|
27
|
+
/** Newest first. */
|
|
28
|
+
messages: Message[];
|
|
29
|
+
has_more: boolean;
|
|
30
|
+
/** Pass as `before` to read the next, older page; null on the last page. */
|
|
31
|
+
next_before: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MessageSearchSegment {
|
|
35
|
+
text: string;
|
|
36
|
+
match: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Split a text into runs that do and do not match the terms, for rendering
|
|
41
|
+
* highlights as plain text nodes (never as HTML).
|
|
42
|
+
*/
|
|
43
|
+
export function highlightMessageSearchText(text: unknown, terms: readonly string[]): MessageSearchSegment[];
|
|
44
|
+
|
|
45
|
+
/** A short excerpt around the first match, with an ellipsis where text was left out. */
|
|
46
|
+
export function messageSearchSnippet(text: unknown, terms: readonly string[], maxLength?: number): string;
|