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,114 @@
|
|
|
1
|
+
// Searching a room's messages: the limits and the one query grammar that the
|
|
2
|
+
// server and both apps share. A query is a list of terms; a message matches
|
|
3
|
+
// when it contains every term, ignoring case. A term is a word, or a phrase
|
|
4
|
+
// in double quotes. Matching is by substring, so part of an identifier, a
|
|
5
|
+
// file path or an error string finds the message.
|
|
6
|
+
|
|
7
|
+
export const MESSAGE_SEARCH_MIN_QUERY_CHARS = 2;
|
|
8
|
+
export const MESSAGE_SEARCH_MAX_QUERY_CHARS = 200;
|
|
9
|
+
export const MESSAGE_SEARCH_MAX_TERMS = 6;
|
|
10
|
+
export const MESSAGE_SEARCH_DEFAULT_LIMIT = 30;
|
|
11
|
+
export const MESSAGE_SEARCH_MAX_LIMIT = 50;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Split a query into its terms, or say why it cannot be searched.
|
|
15
|
+
* Returns `{ terms }` or `{ error: "too_short" | "too_long" | "too_many_terms" }`.
|
|
16
|
+
*/
|
|
17
|
+
export function parseMessageSearchQuery(value) {
|
|
18
|
+
const query = typeof value === "string" ? value.trim() : "";
|
|
19
|
+
if (query.length > MESSAGE_SEARCH_MAX_QUERY_CHARS) return { error: "too_long" };
|
|
20
|
+
|
|
21
|
+
const terms = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
// A phrase runs to its closing quote, or to the end when the quote is never closed.
|
|
24
|
+
for (const match of query.matchAll(/"([^"]*)"?|(\S+)/g)) {
|
|
25
|
+
const term = (match[1] ?? match[2] ?? "").replace(/\s+/g, " ").trim();
|
|
26
|
+
const key = term.toLowerCase();
|
|
27
|
+
if (!term || seen.has(key)) continue;
|
|
28
|
+
seen.add(key);
|
|
29
|
+
terms.push(term);
|
|
30
|
+
}
|
|
31
|
+
if (terms.join("").length < MESSAGE_SEARCH_MIN_QUERY_CHARS) return { error: "too_short" };
|
|
32
|
+
if (terms.length > MESSAGE_SEARCH_MAX_TERMS) return { error: "too_many_terms" };
|
|
33
|
+
return { terms };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether a text contains every term, the same test the server applies. */
|
|
37
|
+
export function textMatchesMessageSearch(text, terms) {
|
|
38
|
+
const haystack = String(text ?? "").toLowerCase();
|
|
39
|
+
return terms.length > 0 && terms.every((term) => haystack.includes(term.toLowerCase()));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function matchRanges(text, terms) {
|
|
43
|
+
const lower = text.toLowerCase();
|
|
44
|
+
// Lowercasing can expand a character (İ -> i + combining dot). Keep the
|
|
45
|
+
// original UTF-16 boundaries so highlights and snippets still slice source text.
|
|
46
|
+
let sourceOffsets = null;
|
|
47
|
+
if (lower.length !== text.length) {
|
|
48
|
+
sourceOffsets = [];
|
|
49
|
+
let start = 0;
|
|
50
|
+
for (const character of text) {
|
|
51
|
+
const end = start + character.length;
|
|
52
|
+
for (let index = 0; index < character.toLowerCase().length; index += 1) {
|
|
53
|
+
sourceOffsets.push([start, end]);
|
|
54
|
+
}
|
|
55
|
+
start = end;
|
|
56
|
+
}
|
|
57
|
+
// Context-sensitive casing must not leave offsets outside the map.
|
|
58
|
+
if (sourceOffsets.length !== lower.length) return [];
|
|
59
|
+
}
|
|
60
|
+
const ranges = [];
|
|
61
|
+
for (const term of terms) {
|
|
62
|
+
const needle = term.toLowerCase();
|
|
63
|
+
if (!needle) continue;
|
|
64
|
+
for (let at = lower.indexOf(needle); at !== -1; at = lower.indexOf(needle, at + 1)) {
|
|
65
|
+
ranges.push(sourceOffsets
|
|
66
|
+
? [sourceOffsets[at][0], sourceOffsets[at + needle.length - 1][1]]
|
|
67
|
+
: [at, at + needle.length]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
ranges.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
|
|
71
|
+
// Overlapping and touching matches read as one highlighted run.
|
|
72
|
+
const merged = [];
|
|
73
|
+
for (const range of ranges) {
|
|
74
|
+
const last = merged.at(-1);
|
|
75
|
+
if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
|
|
76
|
+
else merged.push([...range]);
|
|
77
|
+
}
|
|
78
|
+
return merged;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Split a text into runs that do and do not match the terms, for rendering
|
|
83
|
+
* highlights as plain text nodes (never as HTML).
|
|
84
|
+
*/
|
|
85
|
+
export function highlightMessageSearchText(text, terms) {
|
|
86
|
+
const source = String(text ?? "");
|
|
87
|
+
const segments = [];
|
|
88
|
+
let cursor = 0;
|
|
89
|
+
for (const [start, end] of matchRanges(source, terms)) {
|
|
90
|
+
if (start > cursor) segments.push({ text: source.slice(cursor, start), match: false });
|
|
91
|
+
segments.push({ text: source.slice(start, end), match: true });
|
|
92
|
+
cursor = end;
|
|
93
|
+
}
|
|
94
|
+
if (cursor < source.length) segments.push({ text: source.slice(cursor), match: false });
|
|
95
|
+
return segments;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A short excerpt around the first match: whitespace collapsed, cut at
|
|
100
|
+
* `maxLength` characters, with an ellipsis where text was left out.
|
|
101
|
+
*/
|
|
102
|
+
export function messageSearchSnippet(text, terms, maxLength = 220) {
|
|
103
|
+
const source = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
104
|
+
if (source.length <= maxLength) return source;
|
|
105
|
+
const first = matchRanges(source, terms)[0]?.[0] ?? 0;
|
|
106
|
+
// Keep a little lead-in so the match is read in its sentence.
|
|
107
|
+
let start = Math.max(0, Math.min(first - Math.floor(maxLength / 4), source.length - maxLength));
|
|
108
|
+
if (start > 0) {
|
|
109
|
+
const space = source.indexOf(" ", start);
|
|
110
|
+
if (space !== -1 && space < first) start = space + 1;
|
|
111
|
+
}
|
|
112
|
+
const end = Math.min(source.length, start + maxLength);
|
|
113
|
+
return `${start > 0 ? "…" : ""}${source.slice(start, end).trim()}${end < source.length ? "…" : ""}`;
|
|
114
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const LETAGENTS_ROOM_ORIGIN: string;
|
|
2
|
+
export const MESSAGE_ID_PATTERN: RegExp;
|
|
3
|
+
|
|
4
|
+
export function isValidMessageId(messageId: unknown): messageId is string;
|
|
5
|
+
export function encodeRoomPathIdentifier(identifier: string): string;
|
|
6
|
+
export function decodeRoomPath(path: string): string;
|
|
7
|
+
export function isLocalRoomIdentifier(identifier: string | null | undefined): boolean;
|
|
8
|
+
|
|
9
|
+
export function buildLetAgentsMessageUrl(
|
|
10
|
+
roomIdentifier: string,
|
|
11
|
+
messageId: string,
|
|
12
|
+
origin?: string,
|
|
13
|
+
): string;
|
|
14
|
+
|
|
15
|
+
export function parseLetAgentsMessageUrl(
|
|
16
|
+
rawUrl: string,
|
|
17
|
+
configuredOrigin?: string,
|
|
18
|
+
): { roomIdentifier: string; messageId: string | null } | null;
|
|
19
|
+
|
|
20
|
+
export function resolveSameRoomMessageReference(
|
|
21
|
+
rawUrl: string | null | undefined,
|
|
22
|
+
currentRoomIdentifier: string | null | undefined,
|
|
23
|
+
configuredOrigin?: string,
|
|
24
|
+
): string | null;
|
|
25
|
+
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export const LETAGENTS_ROOM_ORIGIN = "https://letagents.chat";
|
|
2
|
+
|
|
3
|
+
export const MESSAGE_ID_PATTERN = /^msg_[1-9]\d*$/;
|
|
4
|
+
|
|
5
|
+
export function isValidMessageId(messageId) {
|
|
6
|
+
return typeof messageId === "string" && MESSAGE_ID_PATTERN.test(messageId);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function encodeRoomPathIdentifier(identifier) {
|
|
10
|
+
return String(identifier ?? "")
|
|
11
|
+
.split("/")
|
|
12
|
+
.map((segment) => encodeURIComponent(segment))
|
|
13
|
+
.join("/");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function decodeRoomPath(path) {
|
|
17
|
+
return String(path ?? "")
|
|
18
|
+
.split("/")
|
|
19
|
+
.map((segment) => {
|
|
20
|
+
try {
|
|
21
|
+
return decodeURIComponent(segment);
|
|
22
|
+
} catch {
|
|
23
|
+
return segment;
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
.join("/");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isLocalRoomIdentifier(identifier) {
|
|
30
|
+
const value = String(identifier ?? "").trim();
|
|
31
|
+
return /^local[_-]/i.test(value) || /^git-room:local:/i.test(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildLetAgentsMessageUrl(
|
|
35
|
+
roomIdentifier,
|
|
36
|
+
messageId,
|
|
37
|
+
origin = LETAGENTS_ROOM_ORIGIN,
|
|
38
|
+
) {
|
|
39
|
+
const cleanId = String(messageId ?? "").trim();
|
|
40
|
+
if (!isValidMessageId(cleanId)) {
|
|
41
|
+
throw new Error(`Invalid message ID for permalink: ${messageId}`);
|
|
42
|
+
}
|
|
43
|
+
const cleanRoom = String(roomIdentifier ?? "").trim();
|
|
44
|
+
if (!cleanRoom) {
|
|
45
|
+
throw new Error("Room identifier is required for permalink.");
|
|
46
|
+
}
|
|
47
|
+
const base = origin ? origin.replace(/\/+$/, "") : "";
|
|
48
|
+
return `${base}/in/${encodeRoomPathIdentifier(cleanRoom)}?message=${cleanId}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseLetAgentsMessageUrl(
|
|
52
|
+
rawUrl,
|
|
53
|
+
configuredOrigin = LETAGENTS_ROOM_ORIGIN,
|
|
54
|
+
) {
|
|
55
|
+
if (typeof rawUrl !== "string") return null;
|
|
56
|
+
const trimmed = rawUrl.trim();
|
|
57
|
+
if (!trimmed) return null;
|
|
58
|
+
|
|
59
|
+
let parsed;
|
|
60
|
+
let isRelative = false;
|
|
61
|
+
try {
|
|
62
|
+
parsed = new URL(trimmed);
|
|
63
|
+
} catch {
|
|
64
|
+
if (trimmed.startsWith("/in/") || trimmed.startsWith("in/")) {
|
|
65
|
+
try {
|
|
66
|
+
parsed = new URL(trimmed.startsWith("/") ? trimmed : `/${trimmed}`, "http://localhost");
|
|
67
|
+
isRelative = true;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!isRelative) {
|
|
77
|
+
let normalizedConfiguredOrigin;
|
|
78
|
+
try {
|
|
79
|
+
normalizedConfiguredOrigin = new URL(configuredOrigin).origin.toLowerCase();
|
|
80
|
+
} catch {
|
|
81
|
+
normalizedConfiguredOrigin = String(configuredOrigin).replace(/\/+$/, "").toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
if (parsed.origin.toLowerCase() !== normalizedConfiguredOrigin) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const pathname = parsed.pathname.replace(/^\/+|\/+$/g, "");
|
|
89
|
+
if (!pathname.startsWith("in/")) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const rawRoom = pathname.slice("in/".length);
|
|
93
|
+
if (!rawRoom) return null;
|
|
94
|
+
const roomIdentifier = decodeRoomPath(rawRoom);
|
|
95
|
+
if (!roomIdentifier.trim()) return null;
|
|
96
|
+
|
|
97
|
+
const rawMessageId = parsed.searchParams.get("message");
|
|
98
|
+
const messageId = isValidMessageId(rawMessageId) ? rawMessageId.trim() : null;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
roomIdentifier,
|
|
102
|
+
messageId,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveSameRoomMessageReference(
|
|
107
|
+
rawUrl,
|
|
108
|
+
currentRoomIdentifier,
|
|
109
|
+
configuredOrigin = LETAGENTS_ROOM_ORIGIN,
|
|
110
|
+
) {
|
|
111
|
+
if (typeof rawUrl !== "string" || !currentRoomIdentifier) return null;
|
|
112
|
+
const parsed = parseLetAgentsMessageUrl(rawUrl, configuredOrigin);
|
|
113
|
+
if (!parsed?.messageId) return null;
|
|
114
|
+
if (parsed.roomIdentifier.trim() !== String(currentRoomIdentifier).trim()) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return parsed.messageId;
|
|
118
|
+
}
|
|
119
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RoomNotificationPreference, RoomNotificationPreferenceChange, RoomNotificationPreferenceEntry, RoomNotificationPreferenceList } from './room-notification-preferences.mjs';
|
|
2
|
+
export interface RoomNotificationClientState {
|
|
3
|
+
preference: RoomNotificationPreference; ready: boolean; loading: boolean; busy: boolean; error: string; version: number;
|
|
4
|
+
}
|
|
5
|
+
export declare function createRoomNotificationClient(api: {
|
|
6
|
+
get(roomId: string): Promise<RoomNotificationPreferenceEntry>;
|
|
7
|
+
list?(): Promise<RoomNotificationPreferenceList>;
|
|
8
|
+
put?(roomId: string, change: RoomNotificationPreferenceChange): Promise<RoomNotificationPreferenceEntry>;
|
|
9
|
+
}, changed?: () => void, normalizeKey?: (roomId: string) => string): {
|
|
10
|
+
setViewer(viewer: { id: string; login: string } | null): void;
|
|
11
|
+
signedIn(): boolean;
|
|
12
|
+
allowsAfterRead(roomId: string, text: unknown): Promise<boolean>;
|
|
13
|
+
state(roomId: string): RoomNotificationClientState;
|
|
14
|
+
refresh(roomId: string): Promise<void>;
|
|
15
|
+
refreshAll(): Promise<void>;
|
|
16
|
+
update(roomId: string, change: RoomNotificationPreferenceChange): Promise<void>;
|
|
17
|
+
allows(roomId: string, text: unknown, now?: number): boolean;
|
|
18
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { allowsRoomNotification, DEFAULT_ROOM_NOTIFICATION_PREFERENCE } from './room-notification-preferences.mjs';
|
|
2
|
+
|
|
3
|
+
/** Personal cache shared by desktop and the read-only web sound gate. */
|
|
4
|
+
export function createRoomNotificationClient(api, changed = () => {}, normalizeKey = (id) => id) {
|
|
5
|
+
let viewer = null;
|
|
6
|
+
let epoch = 0;
|
|
7
|
+
let listVersion = 0;
|
|
8
|
+
let listLoading = false;
|
|
9
|
+
const rooms = new Map();
|
|
10
|
+
const pendingReads = new Map();
|
|
11
|
+
let pendingList = null;
|
|
12
|
+
function state(roomId) {
|
|
13
|
+
roomId = normalizeKey(roomId);
|
|
14
|
+
if (!rooms.has(roomId)) rooms.set(roomId, {
|
|
15
|
+
preference: { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE }, ready: false, loading: false,
|
|
16
|
+
busy: false, error: '', version: 0,
|
|
17
|
+
});
|
|
18
|
+
return rooms.get(roomId);
|
|
19
|
+
}
|
|
20
|
+
function setViewer(next) {
|
|
21
|
+
if (viewer?.id === next?.id && viewer?.login === next?.login) return;
|
|
22
|
+
viewer = next;
|
|
23
|
+
epoch++;
|
|
24
|
+
listVersion++;
|
|
25
|
+
listLoading = false;
|
|
26
|
+
rooms.clear();
|
|
27
|
+
pendingReads.clear();
|
|
28
|
+
pendingList = null;
|
|
29
|
+
changed();
|
|
30
|
+
}
|
|
31
|
+
async function performRefresh(roomId) {
|
|
32
|
+
if (!viewer || !roomId) return;
|
|
33
|
+
const row = state(roomId);
|
|
34
|
+
if (row.busy) return;
|
|
35
|
+
const version = ++row.version;
|
|
36
|
+
const currentEpoch = epoch;
|
|
37
|
+
row.loading = true;
|
|
38
|
+
changed();
|
|
39
|
+
try {
|
|
40
|
+
const preference = await api.get(roomId);
|
|
41
|
+
if (epoch !== currentEpoch || row.version !== version) return;
|
|
42
|
+
row.preference = preference;
|
|
43
|
+
row.error = '';
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (epoch !== currentEpoch || row.version !== version) return;
|
|
46
|
+
if (!row.ready) row.preference = { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE };
|
|
47
|
+
row.error = error instanceof Error ? error.message : 'Notification settings could not be loaded.';
|
|
48
|
+
} finally {
|
|
49
|
+
if (epoch === currentEpoch && row.version === version) {
|
|
50
|
+
row.loading = false;
|
|
51
|
+
row.ready = true;
|
|
52
|
+
changed();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function refresh(roomId) {
|
|
57
|
+
roomId = normalizeKey(roomId);
|
|
58
|
+
const pending = performRefresh(roomId);
|
|
59
|
+
pendingReads.set(roomId, pending);
|
|
60
|
+
void pending.then(() => { if (pendingReads.get(roomId) === pending) pendingReads.delete(roomId); });
|
|
61
|
+
return pending;
|
|
62
|
+
}
|
|
63
|
+
async function performRefreshAll() {
|
|
64
|
+
if (!viewer || !api.list) return;
|
|
65
|
+
const currentEpoch = epoch;
|
|
66
|
+
const version = ++listVersion;
|
|
67
|
+
listLoading = true;
|
|
68
|
+
const roomVersions = new Map([...rooms].map(([id, row]) => [id, row.version]));
|
|
69
|
+
try {
|
|
70
|
+
const result = await api.list();
|
|
71
|
+
if (currentEpoch !== epoch || version !== listVersion) return;
|
|
72
|
+
const entries = new Map(result.preferences.map((entry) => [normalizeKey(entry.room_id), entry]));
|
|
73
|
+
for (const id of new Set([...rooms.keys(), ...entries.keys()])) {
|
|
74
|
+
const row = state(id);
|
|
75
|
+
if (row.version !== (roomVersions.get(id) ?? 0) || row.loading || row.busy) continue;
|
|
76
|
+
if (!entries.has(id) && result.truncated) continue;
|
|
77
|
+
row.preference = entries.get(id) ?? { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE };
|
|
78
|
+
row.ready = true;
|
|
79
|
+
row.error = '';
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (currentEpoch !== epoch || version !== listVersion) return;
|
|
83
|
+
for (const [id, row] of rooms) {
|
|
84
|
+
if (row.version !== (roomVersions.get(id) ?? 0) || row.loading || row.busy) continue;
|
|
85
|
+
if (!row.ready) row.preference = { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE };
|
|
86
|
+
row.ready = true;
|
|
87
|
+
row.error = error instanceof Error ? error.message : 'Notification settings could not be loaded.';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (version === listVersion && currentEpoch === epoch) listLoading = false;
|
|
91
|
+
changed();
|
|
92
|
+
}
|
|
93
|
+
function refreshAll() {
|
|
94
|
+
const pending = performRefreshAll();
|
|
95
|
+
pendingList = pending;
|
|
96
|
+
void pending.then(() => { if (pendingList === pending) pendingList = null; });
|
|
97
|
+
return pending;
|
|
98
|
+
}
|
|
99
|
+
async function update(roomId, change) {
|
|
100
|
+
roomId = normalizeKey(roomId);
|
|
101
|
+
if (!viewer || !api.put) throw new Error('Sign in to manage your room notifications.');
|
|
102
|
+
const row = state(roomId);
|
|
103
|
+
if (row.busy) return;
|
|
104
|
+
row.busy = true;
|
|
105
|
+
row.loading = false;
|
|
106
|
+
row.version++;
|
|
107
|
+
listVersion++;
|
|
108
|
+
listLoading = false;
|
|
109
|
+
const currentEpoch = epoch;
|
|
110
|
+
changed();
|
|
111
|
+
try {
|
|
112
|
+
const preference = await api.put(roomId, change);
|
|
113
|
+
if (currentEpoch !== epoch) return;
|
|
114
|
+
row.preference = preference;
|
|
115
|
+
row.ready = true;
|
|
116
|
+
row.error = '';
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (currentEpoch !== epoch) return;
|
|
119
|
+
row.error = error instanceof Error ? error.message : 'Notification settings could not be saved.';
|
|
120
|
+
} finally {
|
|
121
|
+
if (currentEpoch === epoch) { row.busy = false; changed(); }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function allows(roomId, text, now = Date.now()) {
|
|
125
|
+
const row = state(roomId);
|
|
126
|
+
if ((row.loading || listLoading) && !row.ready) return false;
|
|
127
|
+
return allowsRoomNotification(row.preference, text, viewer?.login, now);
|
|
128
|
+
}
|
|
129
|
+
async function allowsAfterRead(roomId, text) {
|
|
130
|
+
roomId = normalizeKey(roomId);
|
|
131
|
+
const currentEpoch = epoch;
|
|
132
|
+
while (currentEpoch === epoch && !state(roomId).ready) {
|
|
133
|
+
const pending = pendingReads.get(roomId) ?? pendingList;
|
|
134
|
+
if (!pending) break;
|
|
135
|
+
await pending;
|
|
136
|
+
}
|
|
137
|
+
return currentEpoch === epoch && allows(roomId, text);
|
|
138
|
+
}
|
|
139
|
+
return { setViewer, state, refresh, refreshAll, update, allows, allowsAfterRead, signedIn: () => Boolean(viewer) };
|
|
140
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type RoomNotificationLevel = 'all' | 'mentions' | 'muted';
|
|
2
|
+
export interface RoomNotificationPreference {
|
|
3
|
+
level: RoomNotificationLevel;
|
|
4
|
+
snoozed_until: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface RoomNotificationPreferenceEntry extends RoomNotificationPreference { room_id: string }
|
|
7
|
+
export interface RoomNotificationPreferenceList { preferences: RoomNotificationPreferenceEntry[]; truncated: boolean }
|
|
8
|
+
export type RoomNotificationPreferenceChange = Partial<RoomNotificationPreference>;
|
|
9
|
+
export type RoomNotificationSnoozePreset = '1h' | '8h' | 'tomorrow';
|
|
10
|
+
export declare const ROOM_NOTIFICATION_LEVELS: readonly RoomNotificationLevel[];
|
|
11
|
+
export declare const DEFAULT_ROOM_NOTIFICATION_PREFERENCE: Readonly<RoomNotificationPreference>;
|
|
12
|
+
export declare const PERSON_MENTION_START: string;
|
|
13
|
+
export declare const PERSON_MENTION_END: string;
|
|
14
|
+
export declare function mentionsPerson(text: unknown, login: string | null | undefined): boolean;
|
|
15
|
+
export declare function roomNotificationsSuppressed(preference: RoomNotificationPreference | null | undefined, now?: number): boolean;
|
|
16
|
+
export declare function allowsRoomNotification(preference: RoomNotificationPreference | null | undefined, text: unknown, login: string | null | undefined, now?: number): boolean;
|
|
17
|
+
export declare function roomNotificationSnoozeUntil(preset: RoomNotificationSnoozePreset, now?: Date): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const ROOM_NOTIFICATION_LEVELS = ['all', 'mentions', 'muted'];
|
|
2
|
+
export const DEFAULT_ROOM_NOTIFICATION_PREFERENCE = Object.freeze({ level: 'all', snoozed_until: null });
|
|
3
|
+
// Shared with PostgreSQL's regex predicate. A final sentence period is punctuation;
|
|
4
|
+
// a dot followed by a handle character is part of a longer handle.
|
|
5
|
+
export const PERSON_MENTION_START = '(^|[^A-Za-z0-9_./@:-])@';
|
|
6
|
+
export const PERSON_MENTION_END = '(?![A-Za-z0-9_/@:-]|\\.[A-Za-z0-9_])';
|
|
7
|
+
|
|
8
|
+
export function mentionsPerson(text, login) {
|
|
9
|
+
if (typeof login !== 'string' || !login) return false;
|
|
10
|
+
const escaped = login.replace(/[^A-Za-z0-9_-]/g, '\\$&');
|
|
11
|
+
return new RegExp(PERSON_MENTION_START + escaped + PERSON_MENTION_END, 'i').test(String(text ?? ''));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function roomNotificationsSuppressed(preference, now = Date.now()) {
|
|
15
|
+
return preference?.level === 'muted' || Date.parse(preference?.snoozed_until ?? '') > now;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function allowsRoomNotification(preference, text, login, now = Date.now()) {
|
|
19
|
+
if (roomNotificationsSuppressed(preference, now)) return false;
|
|
20
|
+
return preference?.level !== 'mentions' || mentionsPerson(text, login);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function roomNotificationSnoozeUntil(preset, now = new Date()) {
|
|
24
|
+
const date = new Date(now);
|
|
25
|
+
if (preset === 'tomorrow') {
|
|
26
|
+
date.setDate(date.getDate() + 1);
|
|
27
|
+
date.setHours(9, 0, 0, 0);
|
|
28
|
+
} else if (preset === '1h' || preset === '8h') {
|
|
29
|
+
date.setTime(date.getTime() + (preset === '1h' ? 1 : 8) * 60 * 60 * 1000);
|
|
30
|
+
} else {
|
|
31
|
+
throw new Error('Choose a snooze duration.');
|
|
32
|
+
}
|
|
33
|
+
return date.toISOString();
|
|
34
|
+
}
|
|
@@ -5,12 +5,17 @@ export const ROOM_RESOURCE_AGENT_APPROVAL: "agent_approval";
|
|
|
5
5
|
export const ROOM_RESOURCE_EXECUTION_DELEGATION: "execution_delegation";
|
|
6
6
|
/** What agents are waiting for changed; consumers re-read the room's wake rules. */
|
|
7
7
|
export const ROOM_RESOURCE_WAKE_RULES: "wake_rules";
|
|
8
|
+
/** A reaction was added or removed; consumers re-read the reactions of the messages they show. */
|
|
9
|
+
export const ROOM_RESOURCE_MESSAGE_PINS: "message_pins";
|
|
10
|
+
export const ROOM_RESOURCE_MESSAGE_REACTIONS: "message_reactions";
|
|
8
11
|
/** Protocol-known references; consumers independently choose what they render. */
|
|
9
12
|
export const ROOM_RESOURCE_INVALIDATION_RESOURCES: readonly [
|
|
10
13
|
"agent_work",
|
|
11
14
|
"agent_approval",
|
|
12
15
|
"execution_delegation",
|
|
13
16
|
"wake_rules",
|
|
17
|
+
"message_reactions",
|
|
18
|
+
"message_pins",
|
|
14
19
|
];
|
|
15
20
|
|
|
16
21
|
export type RoomResourceInvalidationResource =
|
|
@@ -6,6 +6,10 @@ export const ROOM_RESOURCE_AGENT_WORK = "agent_work";
|
|
|
6
6
|
export const ROOM_RESOURCE_AGENT_APPROVAL = "agent_approval";
|
|
7
7
|
export const ROOM_RESOURCE_EXECUTION_DELEGATION = "execution_delegation";
|
|
8
8
|
export const ROOM_RESOURCE_WAKE_RULES = "wake_rules";
|
|
9
|
+
// Someone added or removed an emoji reaction. Consumers re-read the reactions
|
|
10
|
+
// of the messages they show; the pointer never says which message changed.
|
|
11
|
+
export const ROOM_RESOURCE_MESSAGE_PINS = "message_pins";
|
|
12
|
+
export const ROOM_RESOURCE_MESSAGE_REACTIONS = "message_reactions";
|
|
9
13
|
// Protocol-known references. Each consumer still decides which surfaces, if
|
|
10
14
|
// any, react to a supported pointer.
|
|
11
15
|
export const ROOM_RESOURCE_INVALIDATION_RESOURCES = [
|
|
@@ -13,6 +17,8 @@ export const ROOM_RESOURCE_INVALIDATION_RESOURCES = [
|
|
|
13
17
|
ROOM_RESOURCE_AGENT_APPROVAL,
|
|
14
18
|
ROOM_RESOURCE_EXECUTION_DELEGATION,
|
|
15
19
|
ROOM_RESOURCE_WAKE_RULES,
|
|
20
|
+
ROOM_RESOURCE_MESSAGE_REACTIONS,
|
|
21
|
+
ROOM_RESOURCE_MESSAGE_PINS,
|
|
16
22
|
];
|
|
17
23
|
|
|
18
24
|
function exactKeys(value, keys) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const ROOM_TYPING: 'room_typing_v1';
|
|
2
|
+
export const TYPING: Readonly<{ ttl: number; interval: number; sweep: number; sources: number; people: number; entries: number; name: number }>;
|
|
3
|
+
export interface TypingReport { client_id: string; sequence: number; typing: boolean; ttl_ms: number }
|
|
4
|
+
export interface TypingSignal extends TypingReport { room_id: string; account_id: string; name: string; expires_at: number }
|
|
5
|
+
export function parseTypingReport(value: unknown): TypingReport | null;
|
|
6
|
+
export function parseTypingSignal(value: unknown): TypingSignal | null;
|
|
7
|
+
export function createTypingSender(options: {
|
|
8
|
+
send: (report: TypingReport) => unknown; clientId: string; now?: () => number;
|
|
9
|
+
schedule?: (callback: () => void, ms: number) => any; cancel?: (timer: any) => void;
|
|
10
|
+
}): { input(nonempty: boolean): void; stop(): void };
|
|
11
|
+
export function createTypingReceiver(now?: () => number): {
|
|
12
|
+
receive(raw: unknown, self: string | null): void; clear(): void; label(): string; nextExpiry(): number | null;
|
|
13
|
+
};
|
|
14
|
+
export function createTypingDisplay(update: (label: string) => void, clock?: {
|
|
15
|
+
now?: () => number; schedule?: (callback: () => void, ms: number) => any; cancel?: (timer: any) => void;
|
|
16
|
+
}): { receive(raw: unknown, self: string | null): void; clear(): void };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export const ROOM_TYPING = 'room_typing_v1';
|
|
2
|
+
export const TYPING = Object.freeze({ ttl: 5000, interval: 2500, sweep: 1000, sources: 4, people: 512, entries: 10000, name: 80 });
|
|
3
|
+
|
|
4
|
+
export function parseTypingReport(value) {
|
|
5
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
6
|
+
const { client_id, sequence, typing, ttl_ms } = value;
|
|
7
|
+
if (Object.keys(value).sort().join() !== 'client_id,sequence,ttl_ms,typing'
|
|
8
|
+
|| typeof client_id !== 'string' || !/^[a-zA-Z0-9_-]{16,64}$/.test(client_id)
|
|
9
|
+
|| !Number.isSafeInteger(sequence) || sequence < 1 || typeof typing !== 'boolean'
|
|
10
|
+
|| !Number.isInteger(ttl_ms) || ttl_ms < 0 || ttl_ms > TYPING.ttl
|
|
11
|
+
|| typing !== (ttl_ms > 0)) return null;
|
|
12
|
+
return { client_id, sequence, typing, ttl_ms };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseTypingSignal(value) {
|
|
16
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
17
|
+
const { room_id, account_id, name, expires_at, ...report } = value;
|
|
18
|
+
if (!parseTypingReport(report) || ![room_id, account_id, name].every(v => typeof v === 'string' && v.length > 0)
|
|
19
|
+
|| room_id.length > 512 || account_id.length > 128 || name.length > TYPING.name
|
|
20
|
+
|| !Number.isSafeInteger(expires_at)) return null;
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** No drafts enter this controller: input is only a nonempty/empty bit. */
|
|
25
|
+
export function createTypingSender({ send, clientId, now = Date.now, schedule = setTimeout, cancel = clearTimeout }) {
|
|
26
|
+
let sequence = 0, lastSent = -Infinity, lastInput = 0, active = false, reported = false, dirty = false, timer = null;
|
|
27
|
+
function report(typing) {
|
|
28
|
+
lastSent = now();
|
|
29
|
+
reported = typing;
|
|
30
|
+
dirty = false;
|
|
31
|
+
try {
|
|
32
|
+
void Promise.resolve(send({ client_id: clientId, sequence: ++sequence, typing,
|
|
33
|
+
ttl_ms: typing ? Math.max(1, TYPING.ttl - (now() - lastInput)) : 0 })).catch(() => {});
|
|
34
|
+
} catch { /* Reporting cannot interrupt composing, sending, or cleanup. */ }
|
|
35
|
+
}
|
|
36
|
+
function stop() {
|
|
37
|
+
if (timer !== null) cancel(timer);
|
|
38
|
+
timer = null;
|
|
39
|
+
if (reported) report(false);
|
|
40
|
+
active = false;
|
|
41
|
+
}
|
|
42
|
+
function tick() {
|
|
43
|
+
timer = null;
|
|
44
|
+
if (now() - lastInput >= TYPING.ttl) return stop();
|
|
45
|
+
if (dirty && now() - lastSent >= TYPING.interval) report(true);
|
|
46
|
+
timer = schedule(tick, Math.min(TYPING.interval, TYPING.ttl - (now() - lastInput)));
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
input(nonempty) {
|
|
50
|
+
if (!nonempty) return stop();
|
|
51
|
+
lastInput = now();
|
|
52
|
+
dirty = true;
|
|
53
|
+
if (!active) {
|
|
54
|
+
active = true;
|
|
55
|
+
if (now() - lastSent >= TYPING.interval) report(true);
|
|
56
|
+
}
|
|
57
|
+
if (timer === null) timer = schedule(tick, TYPING.interval);
|
|
58
|
+
},
|
|
59
|
+
stop,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Sequence guards survive stops briefly; no snapshot or replay. */
|
|
64
|
+
export function createTypingReceiver(now = Date.now) {
|
|
65
|
+
const sources = new Map();
|
|
66
|
+
return {
|
|
67
|
+
receive(raw, self) {
|
|
68
|
+
const value = parseTypingSignal(raw);
|
|
69
|
+
if (!value || value.account_id === self) return;
|
|
70
|
+
const key = `${value.account_id}:${value.client_id}`;
|
|
71
|
+
const previous = sources.get(key);
|
|
72
|
+
if (previous && previous.sequence >= value.sequence) return;
|
|
73
|
+
if (!previous && sources.size >= TYPING.people * TYPING.sources) return;
|
|
74
|
+
sources.set(key, { ...value, until: now() + Math.min(value.ttl_ms, TYPING.ttl), forget: now() + TYPING.ttl });
|
|
75
|
+
},
|
|
76
|
+
clear() { sources.clear(); },
|
|
77
|
+
nextExpiry() {
|
|
78
|
+
let earliest = Infinity;
|
|
79
|
+
for (const value of sources.values()) {
|
|
80
|
+
if (value.typing && value.until > now()) earliest = Math.min(earliest, value.until);
|
|
81
|
+
}
|
|
82
|
+
return Number.isFinite(earliest) ? earliest : null;
|
|
83
|
+
},
|
|
84
|
+
label() {
|
|
85
|
+
const people = new Map();
|
|
86
|
+
for (const [key, value] of sources) {
|
|
87
|
+
if (value.forget <= now()) sources.delete(key);
|
|
88
|
+
else if (value.typing && value.until > now()) people.set(value.account_id, value.name);
|
|
89
|
+
}
|
|
90
|
+
const names = [...people].sort(([a], [b]) => a.localeCompare(b)).map(([, name]) => name);
|
|
91
|
+
if (names.length > 2) return 'Several people are typing…';
|
|
92
|
+
return names.length ? `${names.join(' and ')} ${names.length === 1 ? 'is' : 'are'} typing…` : '';
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A single receiver timeout, and no timer at all when nobody is typing. */
|
|
98
|
+
export function createTypingDisplay(update, { now = Date.now, schedule = setTimeout, cancel = clearTimeout } = {}) {
|
|
99
|
+
const receiver = createTypingReceiver(now);
|
|
100
|
+
let timer = null;
|
|
101
|
+
function refresh() {
|
|
102
|
+
if (timer !== null) cancel(timer);
|
|
103
|
+
timer = null;
|
|
104
|
+
update(receiver.label());
|
|
105
|
+
const expiry = receiver.nextExpiry();
|
|
106
|
+
if (expiry !== null) timer = schedule(refresh, Math.max(0, expiry - now()));
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
receive(raw, self) { receiver.receive(raw, self); refresh(); },
|
|
110
|
+
clear() { receiver.clear(); refresh(); },
|
|
111
|
+
};
|
|
112
|
+
}
|