@imenam/mcp-messenger 1.0.30 → 1.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1632 +1,2655 @@
1
- // Console de supervision : les routes /admin du serveur, en lecture — à trois exceptions
2
- // près, les suppressions de boîtes, seules commandes de cette page qui écrivent, et qui
3
- // détruisent. Elles passent toutes par la même fenêtre : un aperçu de ce qui va partir,
4
- // puis une confirmation en deux clics.
5
- //
6
- // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
7
- // jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
8
- // écrites en dur dans ce fichier. Le corps des messages passe par `renderMarkdown`, qui
9
- // bâtit lui aussi des nœuds un par un et n'ouvre donc pas non plus de brèche.
10
- //
11
- // L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
12
- // ce script ne voit jamais le jeton et n'écrit rien dans le navigateur.
13
-
14
- import { renderMarkdown } from "./markdown.js";
15
-
16
- const REFRESH_MS = 5000;
17
-
18
- // Une adresse ne dit rien à qui lit la console : elle sert de graine à la couleur de
19
- // l'avatar, pour que la même boîte garde la même teinte d'un écran à l'autre.
20
- const AVATAR_COLORS = 8;
21
-
22
- const ICONS = {
23
- copy: ["M9 3h9a2 2 0 0 1 2 2v9", "M5 7h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z"],
24
- check: ["M20 6 9 17l-5-5"],
25
- clock: ["M12 6v6l4 2", "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z"],
26
- close: ["M18 6 6 18", "m6 6 12 12"],
27
- link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
28
- chevron: ["m9 18 6-6-6-6"],
29
- info: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 11v5", "M12 8h.01"],
30
- paperclip: ["m21 11-8.5 8.5a5 5 0 0 1-7-7L14 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L15 7"],
31
- trash: ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"],
32
- };
33
-
34
- const state = {
35
- /**
36
- * Ce sur quoi la liste des fils est filtrée.
37
- *
38
- * `{ kind: "all" }` ne filtre rien ; `{ kind: "agent", key }` retient toutes les boîtes
39
- * d'un projet ; `{ kind: "box", key }` une seule session. Deux niveaux, parce qu'un
40
- * agent tient une boîte par session ouverte et que les deux questions se posent.
41
- */
42
- selection: { kind: "all", key: null },
43
- /** Noms d'agents dépliés dans la colonne de gauche. Tout est replié au départ. */
44
- expanded: new Set(),
45
- /** Agents dont on a demandé à voir les boîtes vides. Elles sont cachées au départ. */
46
- showEmpty: new Set(),
47
- /**
48
- * Faut-il montrer les agents dont aucune session n'a rien échangé ? Non par défaut,
49
- * comme les boîtes vides à l'intérieur d'un agent : le serveur alloue une boîte par
50
- * session ouverte, et un projet entier peut n'avoir jamais rien dit.
51
- */
52
- showEmptyAgents: false,
53
- /** "all" | "unread" | "high" — les puces au-dessus de la liste des fils. */
54
- filter: "all",
55
- search: "",
56
- threadId: null,
57
- /**
58
- * Messages dépliés dans le lecteur, par identifiant. Vide au départ : un fil s'ouvre
59
- * replié, et se parcourt avant de se lire.
60
- */
61
- openMessages: new Set(),
62
- collapsed: false,
63
- /** Annuaire des boîtes, indexé par adresse : rempli par chaque `/admin/overview`. */
64
- directory: new Map(),
65
- /** Boîtes regroupées par nom d'agent, dans l'ordre de l'annuaire. */
66
- groups: [],
67
- };
68
-
69
- const el = {
70
- status: document.getElementById("status"),
71
- statusLabel: document.getElementById("status-label"),
72
- searchBox: document.getElementById("search-box"),
73
- search: document.getElementById("search"),
74
- controls: document.getElementById("controls"),
75
- autoRefresh: document.getElementById("auto-refresh"),
76
- refresh: document.getElementById("refresh"),
77
- logout: document.getElementById("logout"),
78
- login: document.getElementById("login"),
79
- loginForm: document.getElementById("login-form"),
80
- loginOrigin: document.getElementById("login-origin"),
81
- tokenField: document.querySelector("#login-form .field"),
82
- token: document.getElementById("token"),
83
- tokenReveal: document.getElementById("token-reveal"),
84
- tokenError: document.getElementById("token-error"),
85
- console: document.getElementById("console"),
86
- agentsPane: document.getElementById("agents-pane"),
87
- agents: document.getElementById("agents"),
88
- agentsFoot: document.getElementById("agents-foot"),
89
- collapse: document.getElementById("collapse"),
90
- threadsAvatar: document.getElementById("threads-avatar"),
91
- threadsName: document.getElementById("threads-name"),
92
- threadsScope: document.getElementById("threads-scope"),
93
- // Le panneau, pas la liste qu'il contient : c'est lui que le séparateur redimensionne.
94
- threadsPane: document.querySelector(".threads"),
95
- chips: document.getElementById("chips"),
96
- threads: document.getElementById("threads"),
97
- reader: document.getElementById("reader"),
98
- details: document.getElementById("details"),
99
- detailsBody: document.getElementById("details-body"),
100
- purge: document.getElementById("purge"),
101
- purgeOpen: document.getElementById("purge-open"),
102
- purgeClose: document.getElementById("purge-close"),
103
- purgeCancel: document.getElementById("purge-cancel"),
104
- purgeBefore: document.getElementById("purge-before"),
105
- purgeCreatedBefore: document.getElementById("purge-created-before"),
106
- purgePreview: document.getElementById("purge-preview"),
107
- purgePreviewCreated: document.getElementById("purge-preview-created"),
108
- purgePreviewEmpty: document.getElementById("purge-preview-empty"),
109
- purgeResult: document.getElementById("purge-result"),
110
- purgeConfirm: document.getElementById("purge-confirm"),
111
- };
112
-
113
- // --- Fabrique de noeuds ----------------------------------------------------
114
-
115
- function node(tag, className, text) {
116
- const element = document.createElement(tag);
117
- if (className) element.className = className;
118
- if (text !== undefined) element.textContent = text;
119
- return element;
120
- }
121
-
122
- function icon(name) {
123
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
124
- svg.setAttribute("class", "icon");
125
- svg.setAttribute("viewBox", "0 0 24 24");
126
- svg.setAttribute("aria-hidden", "true");
127
- for (const d of ICONS[name]) {
128
- const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
129
- path.setAttribute("d", d);
130
- svg.appendChild(path);
131
- }
132
- return svg;
133
- }
134
-
135
- function button(className, label, onClick) {
136
- const element = node("button", className, label);
137
- element.type = "button";
138
- element.addEventListener("click", onClick);
139
- return element;
140
- }
141
-
142
- /** Bouton de copie : les adresses et les identifiants ne se recopient pas à la main. */
143
- function copyButton(value, label) {
144
- const element = node("button", "copy");
145
- element.type = "button";
146
- element.title = label;
147
- element.setAttribute("aria-label", label);
148
- element.appendChild(icon("copy"));
149
- element.addEventListener("click", async (event) => {
150
- event.stopPropagation();
151
- try {
152
- await navigator.clipboard.writeText(value);
153
- element.replaceChildren(icon("check"));
154
- setTimeout(() => element.replaceChildren(icon("copy")), 1200);
155
- } catch {
156
- // Presse-papiers refusé (contexte non sécurisé, permission) : la valeur reste
157
- // affichée à côté, donc sélectionnable. Rien d'autre à signaler.
158
- }
159
- });
160
- return element;
161
- }
162
-
163
- function tag(className, label, iconName) {
164
- const element = node("span", className);
165
- if (iconName) element.appendChild(icon(iconName));
166
- element.appendChild(document.createTextNode(label));
167
- return element;
168
- }
169
-
170
- // --- Annuaire des boîtes ---------------------------------------------------
171
-
172
- /**
173
- * Deux noms par boîte, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte
174
- * de quoi cette session s'occupe. La console montre les deux, et n'affiche l'adresse que
175
- * là où elle sert à être copiée.
176
- *
177
- * Un nom d'agent ne désigne personne : plusieurs sessions du même projet le portent. Dès
178
- * qu'il est partagé, `name` lui accole le nom de la boîte — sans quoi un fil afficherait
179
- * « Documentor Dev → Documentor Dev » sans dire de quelles sessions il parle.
180
- */
181
- function party(agentId) {
182
- const known = state.directory.get(agentId);
183
- const agentName = (known && known.agent_name) || null;
184
- const boxName = (known && known.display_name) || null;
185
- const shared = agentName ? state.groups.some((g) => g.key === agentName && g.boxes.length > 1) : false;
186
- // Parenthèses, et non un séparateur plat : les participants d'un fil sont eux-mêmes
187
- // listés avec un point médian, qui ne doit pas se confondre avec cette précision.
188
- const name = agentName
189
- ? shared
190
- ? `${agentName} (${boxName || shortAddress(agentId)})`
191
- : agentName
192
- : shortAddress(agentId);
193
- return {
194
- id: agentId,
195
- name,
196
- agentName,
197
- box: boxName,
198
- registered: !known || known.registered,
199
- unread: (known && known.unread) || 0,
200
- };
201
- }
202
-
203
- /**
204
- * Nom de la boîte, ou son adresse à défaut.
205
- *
206
- * Une boîte n'est nommée que si l'agent l'a fait lui-même (`rename_mailbox`) ; tant qu'il
207
- * ne l'a pas fait, seule l'adresse la distingue de ses sœurs, et c'est donc elle qu'on lit.
208
- */
209
- function boxLabel(box) {
210
- return box.display_name || box.id;
211
- }
212
-
213
- function shortAddress(agentId) {
214
- const id = String(agentId || "");
215
- return id.length > 12 ? `${id.slice(0, 10)}…` : id || "—";
216
- }
217
-
218
- function initials(label) {
219
- const words = String(label).split(/[\s_·-]+/).filter(Boolean);
220
- const letters = words.length >= 2 ? words[0][0] + words[1][0] : String(label).slice(0, 2);
221
- return letters.toUpperCase();
222
- }
223
-
224
- /** Teinte stable : la même graine donne toujours la même couleur d'avatar. */
225
- function colorIndex(seed) {
226
- let hash = 0;
227
- for (const char of String(seed)) hash = (hash * 31 + char.charCodeAt(0)) % 100003;
228
- return hash % AVATAR_COLORS;
229
- }
230
-
231
- /** Pastille d'initiales. `seed` teinte, `label` écrit, `title` explique au survol. */
232
- function avatarFor(seed, label, title, extraClass, absent) {
233
- const classes = ["avatar"];
234
- if (extraClass) classes.push(extraClass);
235
- classes.push(absent ? "avatar--absent" : `avatar--c${colorIndex(seed)}`);
236
- const element = node("span", classes.join(" "), initials(label));
237
- element.title = title;
238
- return element;
239
- }
240
-
241
- function avatar(agentId, extraClass) {
242
- const who = party(agentId);
243
- const label = who.box || who.agentName || shortAddress(agentId);
244
- const title = [who.agentName, who.box, agentId].filter(Boolean).join(" — ");
245
- return avatarFor(agentId, label, title, extraClass, !who.registered);
246
- }
247
-
248
- /**
249
- * Le nom de l'agent, et lui seul.
250
- *
251
- * `party().name` accole le nom de la boîte dès que plusieurs sessions d'un projet
252
- * existent : indispensable là où deux sessions doivent se distinguer, encombrant dans le
253
- * lecteur d'un fil, où l'on suit une conversation entre deux correspondants déjà connus.
254
- * Ce que la boîte ajoute — son nom, son adresse — se lit dans les détails du message.
255
- */
256
- function agentLabel(agentId) {
257
- const who = party(agentId);
258
- return who.agentName || who.box || shortAddress(agentId);
259
- }
260
-
261
- /**
262
- * Pastille de l'agent, et non de la boîte.
263
- *
264
- * Dans une liste, ce qui se cherche du regard est le projet : deux sessions d'un même
265
- * agent portent donc la même pastille, et c'est voulu — ce qui les distingue, le nom de
266
- * la boîte, se lit au survol. La pastille de boîte reste celle du lecteur et du volet,
267
- * là où l'on regarde une session en particulier.
268
- */
269
- function agentAvatar(agentId, extraClass, title) {
270
- const who = party(agentId);
271
- const seed = who.agentName || agentId;
272
- const label = who.agentName || who.box || shortAddress(agentId);
273
- return avatarFor(seed, label, title ?? boxTitle(agentId), extraClass, !who.registered);
274
- }
275
-
276
- /** Ce qu'une pastille dit au survol : le projet, la session, l'adresse. */
277
- function boxTitle(agentId) {
278
- const who = party(agentId);
279
- return [
280
- who.agentName || "agent sans nom",
281
- who.box || (who.registered ? "boîte sans nom" : "boîte jamais connectée"),
282
- agentId,
283
- ].join("\n");
284
- }
285
-
286
- // --- Dates -----------------------------------------------------------------
287
-
288
- function formatDate(iso) {
289
- const date = new Date(iso);
290
- return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleString();
291
- }
292
-
293
- /** Heure seule aujourd'hui, « hier » la veille, date courte au-delà : la liste doit se survoler. */
294
- function formatShort(iso) {
295
- const date = new Date(iso);
296
- if (Number.isNaN(date.getTime())) return String(iso);
297
- const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
298
- const day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
299
- const today = new Date();
300
- const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
301
- const days = Math.round((midnight - day) / 86400000);
302
- if (days === 0) return time;
303
- if (days === 1) return `hier ${time}`;
304
- return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
305
- }
306
-
307
- /** Jour seul, sans heure : une purge se raisonne en jours, pas en minutes. */
308
- function formatDay(iso) {
309
- const date = new Date(iso);
310
- return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleDateString();
311
- }
312
-
313
- function plural(count, singular, pluralForm) {
314
- return `${count} ${count > 1 ? pluralForm || `${singular}s` : singular}`;
315
- }
316
-
317
- // --- Accès au serveur ------------------------------------------------------
318
-
319
- async function api(path, params) {
320
- const url = new URL(path, window.location.origin);
321
- for (const [key, value] of Object.entries(params || {})) {
322
- if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
323
- }
324
- const response = await fetch(url, { headers: { accept: "application/json" }, credentials: "same-origin" });
325
- if (response.status === 401) {
326
- setLoggedIn(false);
327
- throw new Error("unauthorized");
328
- }
329
- if (!response.ok) {
330
- const payload = await response.json().catch(() => ({}));
331
- throw new Error(payload.error || response.statusText);
332
- }
333
- return response.json();
334
- }
335
-
336
- /** Envoi d'une action à la console. Les seules qui existent suppriment : elles passent par ici. */
337
- async function post(path, body) {
338
- return action("POST", path, body);
339
- }
340
-
341
- /** Suppression d'une ressource nommée par son adresse : pas de corps, l'URL suffit. */
342
- async function remove(path) {
343
- return action("DELETE", path, null);
344
- }
345
-
346
- async function action(method, path, body) {
347
- const response = await fetch(path, {
348
- method,
349
- headers: { "content-type": "application/json", accept: "application/json" },
350
- credentials: "same-origin",
351
- body: body === null ? undefined : JSON.stringify(body),
352
- });
353
- if (response.status === 401) {
354
- setLoggedIn(false);
355
- throw new Error("unauthorized");
356
- }
357
- const payload = await response.json().catch(() => ({}));
358
- if (!response.ok) throw new Error(payload.error || response.statusText);
359
- return payload;
360
- }
361
-
362
- function setLoggedIn(loggedIn) {
363
- el.login.hidden = loggedIn;
364
- el.console.hidden = !loggedIn;
365
- el.controls.hidden = !loggedIn;
366
- el.searchBox.hidden = !loggedIn;
367
- if (!loggedIn) {
368
- setStatus("idle", "non connecté");
369
- el.token.focus();
370
- }
371
- }
372
-
373
- function setStatus(kind, label) {
374
- el.status.className = `pill pill--${kind}`;
375
- el.statusLabel.textContent = label;
376
- }
377
-
378
- // --- Colonne des agents ----------------------------------------------------
379
-
380
- /**
381
- * Regroupe les boîtes sous leur nom d'agent.
382
- *
383
- * L'agent dit d'où l'on écrit, la boîte quelle session parle : un projet qui a deux
384
- * sessions ouvertes tient deux boîtes, et la colonne doit montrer cette imbrication plutôt
385
- * que d'aligner des boîtes qui portent toutes le même nom. Une boîte sans nom d'agent est
386
- * son propre groupe : rien ne permet de la rattacher à un projet.
387
- *
388
- * L'annuaire arrive déjà classé par activité : le groupe hérite du rang de sa boîte la
389
- * plus récente, sans nouveau tri.
390
- */
391
- function groupByAgent(boxes) {
392
- const groups = new Map();
393
- for (const box of boxes) {
394
- const key = box.agent_name || box.id;
395
- if (!groups.has(key)) {
396
- groups.set(key, { key, name: box.agent_name || boxLabel(box), boxes: [], unread: 0, filled: 0, activity: "" });
397
- }
398
- const group = groups.get(key);
399
- group.boxes.push(box);
400
- group.unread += box.unread;
401
- if (!isEmptyBox(box)) group.filled += 1;
402
- // L'agent hérite de l'activité de sa session la plus vivante.
403
- if (activityOf(box) > group.activity) group.activity = activityOf(box);
404
- }
405
- const list = [...groups.values()];
406
- for (const group of list) group.boxes.sort(byBox);
407
- // Le classement est refait ici plutôt que laissé à l'ordre d'arrivée de l'annuaire :
408
- // ce que la colonne promet ne doit pas dépendre du hasard d'un tri fait ailleurs. Un
409
- // agent dont aucune session n'a rien échangé passe derrière les autres, comme ses boîtes.
410
- list.sort(
411
- (a, b) =>
412
- compareEmpty(a.filled === 0, b.filled === 0) ||
413
- compareActivity(a.activity, b.activity) ||
414
- (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
415
- );
416
- return list;
417
- }
418
-
419
- /**
420
- * Une boîte est vide tant que rien n'y est passé, ni envoi ni réception.
421
- *
422
- * Le serveur alloue une boîte à chaque session ouverte : beaucoup n'échangeront jamais
423
- * rien et n'ont donc rien à dire à qui supervise. Une boîte vide n'a par construction
424
- * aucun non-lu — `received` à zéro l'implique — la masquer ne cache donc jamais de
425
- * courrier en attente.
426
- */
427
- function isEmptyBox(box) {
428
- return box.sent + box.received === 0;
429
- }
430
-
431
- /** Dernière activité d'une boîte, ou chaîne vide quand elle n'a jamais rien fait. */
432
- function activityOf(box) {
433
- return box.last_activity_at || "";
434
- }
435
-
436
- /**
437
- * Compare deux dates ISO, la plus récente d'abord.
438
- *
439
- * Les dates ISO se comparent comme du texte, et la chaîne vide — une boîte qui n'a jamais
440
- * vécu — est plus petite que toute date : elle finit donc en queue, sans cas particulier.
441
- */
442
- function compareActivity(left, right) {
443
- if (left === right) return 0;
444
- return left < right ? 1 : -1;
445
- }
446
-
447
- /** Le plein avant le vide. Premier critère du classement, avant même la date. */
448
- function compareEmpty(left, right) {
449
- if (left === right) return 0;
450
- return left ? 1 : -1;
451
- }
452
-
453
- /**
454
- * Deux classements emboîtés : d'abord les boîtes qui ont servi, puis les vides ; dans
455
- * chacun des deux, la plus récemment active en tête. À égalité, l'adresse départage, pour
456
- * que l'ordre ne bouge pas d'un rafraîchissement à l'autre.
457
- */
458
- function byBox(a, b) {
459
- return (
460
- compareEmpty(isEmptyBox(a), isEmptyBox(b)) ||
461
- compareActivity(activityOf(a), activityOf(b)) ||
462
- (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
463
- );
464
- }
465
-
466
- function isSelected(kind, key) {
467
- return state.selection.kind === kind && state.selection.key === key;
468
- }
469
-
470
- /**
471
- * Choisit ce que la liste des fils montre.
472
- *
473
- * Recliquer sur la sélection courante ne la relâche pas : cliquer un agent veut dire
474
- * « montre-moi cet agent », et le redire ne peut pas vouloir dire le contraire. On revient
475
- * à la vue générale par « Tous les agents », en tête de colonne, là où on l'attend.
476
- */
477
- function select(kind, key) {
478
- if (isSelected(kind, key)) return;
479
- state.selection = { kind, key };
480
- state.threadId = null;
481
- refresh();
482
- }
483
-
484
- function renderAgents(overview) {
485
- state.directory = new Map(overview.agents.map((box) => [box.id, box]));
486
- state.groups = groupByAgent(overview.agents);
487
- el.agentsFoot.textContent = [
488
- plural(state.groups.length, "agent"),
489
- plural(overview.agents.length, "boîte"),
490
- plural(overview.threads, "fil"),
491
- ].join(" · ");
492
-
493
- el.agents.replaceChildren();
494
- el.agents.appendChild(allRow(overview));
495
- // Les agents sans le moindre échange sont derrière les autres dans `state.groups` :
496
- // les montrer allonge la liste par la fin, sans jamais en réordonner le début.
497
- const silent = state.groups.filter((group) => group.filled === 0).length;
498
- for (const group of state.groups) {
499
- if (group.filled === 0 && !state.showEmptyAgents) continue;
500
- el.agents.appendChild(groupRow(group));
501
- }
502
- if (silent > 0) el.agents.appendChild(silentAgentsToggleRow(silent));
503
- }
504
-
505
- /**
506
- * Dernière ligne de la colonne : ouvre ou referme les agents dont aucune session n'a rien
507
- * échangé. Même geste que pour les boîtes vides d'un agent, un cran plus haut.
508
- */
509
- function silentAgentsToggleRow(silent) {
510
- const item = node("li");
511
- const what = silent > 1 ? `les ${silent} agents sans échange` : "l'agent sans échange";
512
- const toggle = button(
513
- "box-empty-toggle agents-empty-toggle",
514
- `${state.showEmptyAgents ? "Masquer" : "Afficher"} ${what}`,
515
- () => {
516
- state.showEmptyAgents = !state.showEmptyAgents;
517
- renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
518
- }
519
- );
520
- toggle.setAttribute("aria-expanded", state.showEmptyAgents ? "true" : "false");
521
- item.appendChild(toggle);
522
- return item;
523
- }
524
-
525
- function allRow(overview) {
526
- const item = node("li");
527
- const unread = overview.agents.reduce((total, box) => total + box.unread, 0);
528
- const row = node("button", `agent${isSelected("all", null) ? " is-selected" : ""}`);
529
- row.type = "button";
530
-
531
- const star = node("span", "avatar avatar--absent", "∗");
532
- star.title = "Tous les agents";
533
- row.appendChild(star);
534
-
535
- const text = node("div", "agent-text");
536
- text.appendChild(node("div", "agent-name", "Tous les agents"));
537
- text.appendChild(node("div", "agent-box", plural(overview.threads, "fil")));
538
- row.appendChild(text);
539
- if (unread > 0) row.appendChild(node("span", "count count--accent", String(unread)));
540
-
541
- row.addEventListener("click", () => select("all", null));
542
- item.appendChild(row);
543
- return item;
544
- }
545
-
546
- /**
547
- * Un agent et, dépliées sous lui, ses boîtes.
548
- *
549
- * Deux zones de clic, deux gestes distincts et sans effet l'un sur l'autre : le chevron
550
- * ouvre ou ferme, le nom filtre sur l'ensemble des sessions du projet. Filtrer ne déplie
551
- * pas — un clic ne doit faire que ce qu'il annonce, et voir les sessions d'un agent est
552
- * une autre question que ne lire que ses fils.
553
- */
554
- function groupRow(group) {
555
- const item = node("li", "agent-group");
556
- const open = state.expanded.has(group.key);
557
-
558
- const header = node("div", `agent-head${open ? " is-open" : ""}`);
559
-
560
- const toggle = node("button", "agent-toggle");
561
- toggle.type = "button";
562
- toggle.setAttribute("aria-expanded", open ? "true" : "false");
563
- toggle.setAttribute("aria-label", `${open ? "Replier" : "Déplier"} les boîtes de ${group.name}`);
564
- toggle.appendChild(icon("chevron"));
565
- toggle.addEventListener("click", () => {
566
- if (open) state.expanded.delete(group.key);
567
- else state.expanded.add(group.key);
568
- renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
569
- });
570
- header.appendChild(toggle);
571
-
572
- const classes = ["agent"];
573
- if (isSelected("agent", group.key)) classes.push("is-selected");
574
- if (group.unread > 0) classes.push("has-unread");
575
- if (group.boxes.every((box) => !box.registered)) classes.push("agent--muted");
576
- const row = node("button", classes.join(" "));
577
- row.type = "button";
578
- row.appendChild(
579
- avatarFor(
580
- group.key,
581
- group.name,
582
- group.name,
583
- null,
584
- group.boxes.every((box) => !box.registered)
585
- )
586
- );
587
-
588
- const empty = group.boxes.length - group.filled;
589
- // Le décompte annonce ce que le dépli va montrer : sans quoi « 5 boîtes » suivi de deux
590
- // lignes se lirait comme un bug plutôt que comme des boîtes vides mises de côté.
591
- const counts = empty > 0 ? `${plural(group.filled, "boîte")} · ${plural(empty, "vide")}` : plural(group.filled, "boîte");
592
-
593
- const text = node("div", "agent-text");
594
- text.appendChild(node("div", "agent-name", group.name));
595
- text.appendChild(node("div", "agent-box", counts));
596
- row.appendChild(text);
597
- if (group.unread > 0) row.appendChild(node("span", "count count--accent", String(group.unread)));
598
- row.title = `${group.name} · ${counts}`;
599
- row.addEventListener("click", () => select("agent", group.key));
600
- header.appendChild(row);
601
- item.appendChild(header);
602
-
603
- if (open) {
604
- const list = node("ul", "box-list");
605
- // Les vides sont derrière dans `group.boxes` : les montrer, c'est allonger la liste,
606
- // jamais en réordonner le début.
607
- const revealed = state.showEmpty.has(group.key);
608
- for (const box of group.boxes) {
609
- if (isEmptyBox(box) && !revealed) continue;
610
- list.appendChild(boxRow(box));
611
- }
612
- if (empty > 0) list.appendChild(emptyToggleRow(group, empty, revealed));
613
- item.appendChild(list);
614
- }
615
- return item;
616
- }
617
-
618
- /** Dernière ligne du dépli : ouvre ou referme les boîtes qui n'ont jamais rien échangé. */
619
- function emptyToggleRow(group, empty, revealed) {
620
- const item = node("li");
621
- const what = empty > 1 ? `les ${empty} boîtes vides` : "la boîte vide";
622
- const toggle = button("box-empty-toggle", `${revealed ? "Masquer" : "Afficher"} ${what}`, () => {
623
- if (revealed) state.showEmpty.delete(group.key);
624
- else state.showEmpty.add(group.key);
625
- renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
626
- });
627
- toggle.setAttribute("aria-expanded", revealed ? "true" : "false");
628
- item.appendChild(toggle);
629
- return item;
630
- }
631
-
632
- function boxRow(box) {
633
- const item = node("li");
634
- const classes = ["agent", "box"];
635
- if (isEmptyBox(box)) classes.push("is-empty");
636
- if (isSelected("box", box.id)) classes.push("is-selected");
637
- if (box.unread > 0) classes.push("has-unread");
638
- if (!box.registered) classes.push("agent--muted");
639
- const row = node("button", classes.join(" "));
640
- row.type = "button";
641
-
642
- const label = boxLabel(box);
643
- row.appendChild(avatarFor(box.id, label, box.id, "avatar--sm", !box.registered));
644
-
645
- const text = node("div", "agent-text");
646
- // Sans nom donné par l'agent, l'adresse tient lieu de nom : elle est alors la seule
647
- // chose qui distingue cette session de ses sœurs.
648
- const name = node("div", "agent-name", label);
649
- if (!box.display_name) name.classList.add("mono", "is-absent");
650
- text.appendChild(name);
651
- // Les sessions d'un agent sont classées sur cette date : sans elle, leur ordre
652
- // paraîtrait arbitraire. Les compteurs passent dans l'info-bulle, faute de place.
653
- const activity = box.last_activity_at
654
- ? `activité ${formatShort(box.last_activity_at)}`
655
- : box.registered
656
- ? "aucune activité"
657
- : "jamais connectée";
658
- const meta = node("div", "agent-box", activity);
659
- if (!box.last_activity_at) meta.classList.add("is-absent");
660
- text.appendChild(meta);
661
- row.appendChild(text);
662
-
663
- if (box.unread > 0) row.appendChild(node("span", "count count--accent", String(box.unread)));
664
- row.title = `${box.id}\n${plural(box.sent, "envoyé")} · ${plural(box.received, "reçu")}`;
665
- row.addEventListener("click", () => select("box", box.id));
666
-
667
- // La ligne et son bouton de suppression sont deux boutons voisins, jamais imbriqués :
668
- // un bouton dans un bouton n'est pas du HTML, et le clic n'y serait pas dissociable.
669
- const line = node("div", "box-line");
670
- line.appendChild(row);
671
- line.appendChild(deleteBoxButton(box));
672
- item.appendChild(line);
673
- return item;
674
- }
675
-
676
- /**
677
- * Suppression d'une boîte, à la main.
678
- *
679
- * Le bouton n'efface rien : il ouvre la fenêtre de nettoyage sur cette seule boîte, avec
680
- * ce qu'elle contient sous les yeux et la même double confirmation que les nettoyages en
681
- * lot. Un geste destructeur à un clic d'une liste ne peut pas être plus court que ça.
682
- */
683
- function deleteBoxButton(box) {
684
- const label = `Supprimer la boîte ${boxLabel(box)}`;
685
- const remove = node("button", "box-delete");
686
- remove.type = "button";
687
- remove.title = label;
688
- remove.setAttribute("aria-label", label);
689
- remove.appendChild(icon("trash"));
690
- remove.addEventListener("click", (event) => {
691
- event.stopPropagation();
692
- openPurgeFor(box);
693
- });
694
- return remove;
695
- }
696
-
697
- /** Nombre de fils affiché en pied de colonne, conservé entre deux rendus locaux. */
698
- function currentThreadCount() {
699
- return state.threadCount || 0;
700
- }
701
-
702
- /** Adresses que la sélection courante recouvre, ou null quand elle ne filtre rien. */
703
- function selectedAddresses() {
704
- if (state.selection.kind === "box") return [state.selection.key];
705
- if (state.selection.kind === "agent") {
706
- const group = state.groups.find((g) => g.key === state.selection.key);
707
- return group ? group.boxes.map((box) => box.id) : [];
708
- }
709
- return null;
710
- }
711
-
712
- function selectionLabel() {
713
- if (state.selection.kind === "box") {
714
- const box = state.directory.get(state.selection.key);
715
- return box ? boxLabel(box) : shortAddress(state.selection.key);
716
- }
717
- if (state.selection.kind === "agent") return state.selection.key;
718
- return "Toutes les boîtes";
719
- }
720
-
721
- // --- Colonne des fils ------------------------------------------------------
722
-
723
- function renderThreads(threads) {
724
- state.threadCount = threads.length;
725
- renderThreadsHead(threads.length);
726
-
727
- el.threads.replaceChildren();
728
- if (threads.length === 0) {
729
- const empty = node("li", "empty");
730
- empty.appendChild(node("span", "empty-title", "Aucun fil"));
731
- empty.appendChild(document.createTextNode(emptyReason()));
732
- el.threads.appendChild(empty);
733
- return;
734
- }
735
- for (const thread of threads) el.threads.appendChild(threadRow(thread));
736
- }
737
-
738
- /**
739
- * En-tête de la liste : ce sur quoi elle est filtrée, en grand.
740
- *
741
- * Sans filtre, elle s'intitule « Fils » et rien d'autre. Dès qu'un agent est choisi, c'est
742
- * lui le sujet : sa pastille et son nom prennent la place du titre, et la ligne en dessous
743
- * dit ce que le filtre recouvre — toutes ses sessions, ou une seule.
744
- */
745
- function renderThreadsHead(count) {
746
- const { kind, key } = state.selection;
747
- const fils = plural(count, "fil");
748
-
749
- if (kind === "all") {
750
- el.threadsAvatar.hidden = true;
751
- el.threadsAvatar.replaceChildren();
752
- el.threadsName.textContent = "Fils";
753
- el.threadsScope.textContent = `Toutes les boîtes · ${fils}`;
754
- return;
755
- }
756
-
757
- // Une boîte est d'abord une session de son agent : c'est donc l'agent qui s'affiche,
758
- // et le nom de la boîte qui précise de quelle session il s'agit.
759
- const address = kind === "box" ? key : (state.groups.find((g) => g.key === key)?.boxes[0] || {}).id;
760
- const who = address ? party(address) : null;
761
- const sessions = kind === "agent" ? (state.groups.find((g) => g.key === key)?.boxes.length ?? 0) : 0;
762
- const box = kind === "box" ? state.directory.get(key) : null;
763
-
764
- el.threadsAvatar.hidden = false;
765
- el.threadsAvatar.replaceChildren(
766
- address ? agentAvatar(address, "avatar--lg") : avatarFor(key, key, key, "avatar--lg", false)
767
- );
768
- el.threadsName.textContent = kind === "agent" ? key : who?.agentName || selectionLabel();
769
- el.threadsScope.textContent =
770
- kind === "agent"
771
- ? `${plural(sessions, "session")} · ${fils}`
772
- : `${box ? boxLabel(box) : shortAddress(key)} · ${fils}`;
773
- }
774
-
775
- function emptyReason() {
776
- if (state.search) return "Aucun fil ne correspond à cette recherche.";
777
- if (state.filter === "unread") return "Tout est lu de ce côté.";
778
- if (state.filter === "high") return "Aucun message en priorité haute.";
779
- if (state.selection.kind === "box") return "Cette boîte n'a encore échangé avec personne.";
780
- if (state.selection.kind === "agent") return "Aucune session de cet agent n'a encore échangé.";
781
- return "Aucun agent n'a encore écrit sur ce serveur.";
782
- }
783
-
784
- function threadRow(thread) {
785
- const item = node("li");
786
- const classes = ["thread"];
787
- if (state.threadId === thread.thread_id) classes.push("is-selected");
788
- if (thread.unread > 0) classes.push("is-unread");
789
- const row = node("button", classes.join(" "));
790
- row.type = "button";
791
-
792
- const top = node("div", "thread-top");
793
- // Le titre du fil prime ; tant que personne ne l'a nommé, le sujet d'ouverture en tient lieu.
794
- top.appendChild(node("span", "thread-title", thread.title || thread.subject));
795
- if (thread.priority === "high") top.appendChild(tag("tag tag--high", "haute"));
796
- top.appendChild(node("span", "thread-time", formatShort(thread.last_message_at)));
797
- row.appendChild(top);
798
-
799
- row.appendChild(threadParticipants(thread));
800
-
801
- const bottom = node("div", "thread-bottom");
802
- bottom.appendChild(node("span", "thread-preview", thread.last_message_preview));
803
- const count = node("span", thread.unread > 0 ? "count count--accent" : "count", String(thread.messages));
804
- count.title = thread.unread > 0 ? `${plural(thread.messages, "message")}, ${thread.unread} non lu(s)` : plural(thread.messages, "message");
805
- bottom.appendChild(count);
806
- row.appendChild(bottom);
807
-
808
- row.addEventListener("click", () => openThread(thread.thread_id));
809
- item.appendChild(row);
810
- return item;
811
- }
812
-
813
- /**
814
- * Qui parle dans ce fil : les pastilles d'abord, les noms ensuite.
815
- *
816
- * Un fil lie exactement deux boîtes, la pastille suffit donc à les distinguer d'un coup
817
- * d'œil, sans compter ni déborder. Chaque pastille garde la couleur de son adresse d'un
818
- * écran à l'autre, et dit au survol tout ce qu'on peut vouloir savoir de la boîte : le
819
- * projet d'où elle écrit, ce dont cette session s'occupe, son adresse, et si c'est elle
820
- * qui a écrit en dernier.
821
- */
822
- function threadParticipants(thread) {
823
- const who = node("div", "thread-who");
824
-
825
- const stack = node("div", "avatar-stack");
826
- for (const id of thread.participants) {
827
- const last = id === thread.last_message_from ? "\na écrit le dernier message" : "";
828
- stack.appendChild(agentAvatar(id, "avatar--sm avatar--stack", `${boxTitle(id)}${last}`));
829
- }
830
- who.appendChild(stack);
831
-
832
- who.appendChild(node("span", "thread-names", thread.participants.map((id) => party(id).name).join(" · ")));
833
- return who;
834
- }
835
-
836
- // --- Lecteur du fil --------------------------------------------------------
837
-
838
- function renderEmptyReader() {
839
- const empty = node("div", "empty");
840
- empty.appendChild(node("span", "empty-title", "Aucun fil ouvert"));
841
- empty.appendChild(document.createTextNode("Choisissez un fil pour lire la conversation."));
842
- el.reader.replaceChildren(empty);
843
- }
844
-
845
- function renderThread(data) {
846
- const thread = data.thread || {};
847
- const messages = data.messages || [];
848
- const container = document.createDocumentFragment();
849
-
850
- const head = node("div", "reader-head");
851
- const opening = messages.length > 0 ? messages[0].subject : "";
852
- head.appendChild(node("h2", null, thread.title || opening || data.thread_id));
853
- if (thread.description) head.appendChild(node("p", "reader-desc", thread.description));
854
-
855
- const participants = [];
856
- for (const message of messages) {
857
- for (const id of [message.from, message.to]) {
858
- if (id && !participants.includes(id)) participants.push(id);
859
- }
860
- }
861
-
862
- const meta = node("div", "reader-meta");
863
- const stack = node("div", "avatar-stack");
864
- // Le projet d'abord : deux sessions d'un même agent portent la même pastille, le nom
865
- // de la boîte se lisant au survol.
866
- for (const id of participants) stack.appendChild(agentAvatar(id, "avatar--sm avatar--stack"));
867
- meta.appendChild(stack);
868
- meta.appendChild(
869
- node(
870
- "span",
871
- "reader-participants",
872
- `${participants.map(agentLabel).join(", ")} · ${plural(messages.length, "message")}`
873
- )
874
- );
875
- const threadId = node("span", "mono muted", data.thread_id);
876
- meta.appendChild(threadId);
877
- meta.appendChild(copyButton(data.thread_id, "Copier l'identifiant du fil"));
878
- head.appendChild(meta);
879
- container.appendChild(head);
880
-
881
- const list = node("ul", "messages");
882
- messages.forEach((message, index) => {
883
- list.appendChild(messageRow(message, index === messages.length - 1));
884
- });
885
- container.appendChild(list);
886
-
887
- el.reader.replaceChildren(container);
888
- }
889
-
890
- /**
891
- * Un message du fil, replié ou déplié.
892
- *
893
- * Un fil se relit d'abord de haut en bas : ce qui doit sauter aux yeux est qui a écrit,
894
- * quand, et sur quoi. Les corps sont donc repliés au départ, chaque message tenant sur sa
895
- * ligne de titre, et c'est cette ligne qui l'ouvre. L'état est retenu par identifiant de
896
- * message : le rafraîchissement automatique ne referme donc pas ce qu'on est en train de
897
- * lire.
898
- */
899
- function messageRow(message, isLast) {
900
- const item = node("li");
901
- fillMessage(item, message, isLast);
902
- return item;
903
- }
904
-
905
- /**
906
- * Remplit la ligne d'un message, dépliée ou non.
907
- *
908
- * Le nœud est donné plutôt que rendu : ouvrir un message ne redessine que lui, et c'est
909
- * bien cette ligne-là, celle qui est dans la page, qu'il faut réécrire — en reconstruire
910
- * une autre reviendrait à modifier un élément que personne ne regarde.
911
- */
912
- function fillMessage(item, message, isLast) {
913
- const open = state.openMessages.has(message.message_id);
914
- const classes = ["message"];
915
- if (open) classes.push("is-expanded");
916
- if (isLast) classes.push("is-last");
917
- const row = node("div", classes.join(" "));
918
- const column = node("div", "message-body-col");
919
-
920
- /*
921
- Tout ce qui est visible d'un message replié est cliquable, pastille comprise : le
922
- bandeau est le bouton, et le survol le grise pour qu'on le voie comme tel. Pas de
923
- chevron — sur une ligne qui s'annonce déjà comme une cible, il n'apprend rien.
924
- */
925
- const head = node("button", "message-head");
926
- head.type = "button";
927
- head.setAttribute("aria-expanded", open ? "true" : "false");
928
- head.appendChild(agentAvatar(message.from));
929
-
930
- const identity = node("span", "message-identity");
931
- identity.appendChild(node("span", "message-from", agentLabel(message.from)));
932
- if (message.priority === "high") identity.appendChild(tag("tag tag--high", "priorité haute"));
933
- if (message.priority === "low") identity.appendChild(tag("tag", "priorité basse"));
934
- if (!message.read) {
935
- const dot = node("span", "message-dot");
936
- dot.title = `non lu par ${agentLabel(message.to)}`;
937
- identity.appendChild(dot);
938
- }
939
- identity.appendChild(node("span", "message-time", formatShort(message.created_at)));
940
- identity.appendChild(detailsButton(message));
941
- head.appendChild(identity);
942
-
943
- // L'objet, toujours : c'est le titre du message, et c'est lui qu'on cherche du regard.
944
- // Le bandeau ne bouge donc pas quand le message s'ouvre, seul le corps vient dessous.
945
- head.appendChild(node("span", "message-subject", message.subject || "sans objet"));
946
-
947
- head.addEventListener("click", () => {
948
- if (open) state.openMessages.delete(message.message_id);
949
- else state.openMessages.add(message.message_id);
950
- fillMessage(item, message, isLast);
951
- });
952
- column.appendChild(head);
953
-
954
- if (!open) {
955
- row.appendChild(column);
956
- item.replaceChildren(row);
957
- return;
958
- }
959
-
960
- // Ce qui suit la ligne de titre s'aligne sous le texte, pas sous la pastille.
961
- const content = node("div", "message-content");
962
- column.appendChild(content);
963
-
964
- content.appendChild(messageText(message));
965
- if (message.attachments && message.attachments.length > 0) {
966
- content.appendChild(attachmentList(message.attachments));
967
- }
968
-
969
- const foot = node("div", "message-foot");
970
- foot.appendChild(
971
- message.read
972
- ? tag("tag tag--ok", `lu par ${agentLabel(message.to)}`, "check")
973
- : tag("tag tag--warn", `non lu par ${agentLabel(message.to)}`, "clock")
974
- );
975
- content.appendChild(foot);
976
-
977
- row.appendChild(column);
978
- item.replaceChildren(row);
979
- }
980
-
981
- /**
982
- * Le corps d'un message, rendu comme du markdown : c'est la langue dans laquelle les
983
- * agents s'écrivent. L'aperçu, lui, arrive déjà débarrassé de sa syntaxe par le serveur
984
- * et reste donc du texte simple.
985
- */
986
- function messageText(message) {
987
- if (!message.body) return node("p", "message-text", message.preview || "");
988
- const container = node("div", "message-text markdown");
989
- container.appendChild(renderMarkdown(message.body));
990
- return container;
991
- }
992
-
993
- /** Pièces jointes d'un message : un lien de téléchargement par fichier. */
994
- function attachmentList(attachments) {
995
- const list = node("ul", "attachments");
996
- for (const attachment of attachments) {
997
- const item = node("li");
998
- const link = node("a", "attachment");
999
- link.href = `/admin/attachments/${encodeURIComponent(attachment.attachment_id)}`;
1000
- link.download = attachment.filename;
1001
- link.title = attachment.mime_type;
1002
- link.appendChild(icon("paperclip"));
1003
- link.appendChild(node("span", "attachment-name", attachment.filename));
1004
- link.appendChild(node("span", "attachment-size", formatSize(attachment.size)));
1005
- item.appendChild(link);
1006
- list.appendChild(item);
1007
- }
1008
- return list;
1009
- }
1010
-
1011
- function formatSize(bytes) {
1012
- if (bytes < 1024) return `${bytes} o`;
1013
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
1014
- return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
1015
- }
1016
-
1017
- // --- Détails d'un message --------------------------------------------------
1018
-
1019
- /**
1020
- * Bouton d'information, au bout de la ligne.
1021
- *
1022
- * Un fil ne lie que deux boîtes : répéter à chaque message qui écrit à qui, sous quel nom
1023
- * de boîte, encombre la lecture pour une information déjà connue. Elle est donc rangée
1024
- * ici, à un clic, avec tout le reste — adresses, identifiants, distribution.
1025
- */
1026
- function detailsButton(message) {
1027
- const open = node("button", "message-info");
1028
- open.type = "button";
1029
- open.title = "Détails du message";
1030
- open.setAttribute("aria-label", "Détails du message");
1031
- open.appendChild(icon("info"));
1032
- // Le bandeau entier ouvre le message : ce clic-ci ne doit pas le replier au passage.
1033
- open.addEventListener("click", (event) => {
1034
- event.stopPropagation();
1035
- openDetails(message.message_id);
1036
- });
1037
- return open;
1038
- }
1039
-
1040
- async function openDetails(messageId) {
1041
- el.detailsBody.replaceChildren(node("p", "muted", "Chargement…"));
1042
- el.details.showModal();
1043
- try {
1044
- renderDetails(await api(`/admin/messages/${encodeURIComponent(messageId)}`));
1045
- } catch (error) {
1046
- if (error.message === "unauthorized") el.details.close();
1047
- else el.detailsBody.replaceChildren(node("p", "purge-error", `Erreur : ${error.message}`));
1048
- }
1049
- }
1050
-
1051
- /** Tout ce que la conversation ne montre pas, et que la supervision peut vouloir lire. */
1052
- function renderDetails(message) {
1053
- const container = document.createDocumentFragment();
1054
-
1055
- const head = node("div", "modal-head");
1056
- const titles = node("div");
1057
- titles.appendChild(node("h2", null, "Détails du message"));
1058
- titles.appendChild(node("p", "details-subject", message.subject));
1059
- head.appendChild(titles);
1060
- const close = node("button", "btn btn--icon btn--ghost");
1061
- close.type = "button";
1062
- close.title = "Fermer";
1063
- close.setAttribute("aria-label", "Fermer les détails");
1064
- close.appendChild(icon("close"));
1065
- close.addEventListener("click", () => el.details.close());
1066
- head.appendChild(close);
1067
- container.appendChild(head);
1068
-
1069
- const ids = node("div", "details-section");
1070
- ids.appendChild(node("span", "section-label", "Message"));
1071
- ids.appendChild(kvCopy("Identifiant", message.message_id));
1072
- ids.appendChild(kv("Objet", node("span", "kv-value", message.subject)));
1073
- ids.appendChild(kv("Envoyé le", node("span", "kv-value", formatDate(message.created_at))));
1074
- ids.appendChild(kv("Priorité", priorityTag(message.priority)));
1075
- container.appendChild(ids);
1076
-
1077
- const thread = node("div", "details-section");
1078
- thread.appendChild(node("span", "section-label", "Fil"));
1079
- // Sans nom donné par un participant, le fil n'a que son identifiant et le sujet d'ouverture.
1080
- thread.appendChild(kv("Nom", node("span", "kv-value", message.thread_title || "fil sans nom")));
1081
- thread.appendChild(kvCopy("Identifiant", message.thread_id));
1082
- container.appendChild(thread);
1083
-
1084
- container.appendChild(partySection("De", message.from));
1085
- container.appendChild(partySection("À", message.to));
1086
-
1087
- const deliveries = node("div", "details-section");
1088
- deliveries.appendChild(node("span", "section-label", "Distribution"));
1089
- deliveries.appendChild(deliveriesTable(message.deliveries || []));
1090
- container.appendChild(deliveries);
1091
-
1092
- el.detailsBody.replaceChildren(container);
1093
- }
1094
-
1095
- function kv(key, valueNode) {
1096
- const row = node("div", "kv");
1097
- row.appendChild(node("span", "kv-key", key));
1098
- row.appendChild(valueNode);
1099
- row.appendChild(node("span"));
1100
- return row;
1101
- }
1102
-
1103
- function kvCopy(key, value) {
1104
- const row = node("div", "kv");
1105
- row.appendChild(node("span", "kv-key", key));
1106
- row.appendChild(node("span", "kv-value mono", value));
1107
- row.appendChild(copyButton(value, `Copier ${key.toLowerCase()}`));
1108
- return row;
1109
- }
1110
-
1111
- function priorityTag(priority) {
1112
- const labels = { high: "haute", normal: "normale", low: "basse" };
1113
- return tag(priority === "high" ? "tag tag--high" : "tag", labels[priority] || priority);
1114
- }
1115
-
1116
- /**
1117
- * Une boîte, telle qu'elle doit se lire : le nom de l'agent, ce dont la session s'occupe,
1118
- * et seulement ensuite l'adresse — la seule chose qui identifie vraiment la session.
1119
- */
1120
- function partySection(label, agentId) {
1121
- const who = party(agentId);
1122
- const section = node("div", "details-section");
1123
- section.appendChild(node("span", "section-label", label));
1124
-
1125
- const block = node("div", "party");
1126
- block.appendChild(avatar(agentId, "avatar--sm"));
1127
-
1128
- const text = node("div", "agent-text");
1129
- text.appendChild(node("div", "party-name", who.name));
1130
- text.appendChild(node("div", "party-box", who.box || (who.registered ? "boîte sans nom" : "jamais connectée")));
1131
- const addr = node("div", "party-addr");
1132
- addr.appendChild(node("span", "mono", agentId));
1133
- addr.appendChild(copyButton(agentId, "Copier l'adresse"));
1134
- text.appendChild(addr);
1135
-
1136
- block.appendChild(text);
1137
- section.appendChild(block);
1138
- return section;
1139
- }
1140
-
1141
- function deliveriesTable(deliveries) {
1142
- const table = node("table", "deliveries");
1143
- const head = node("tr");
1144
- for (const label of ["Agent", "Rôle", "Dossier", "État"]) head.appendChild(node("th", null, label));
1145
- table.appendChild(head);
1146
-
1147
- for (const delivery of deliveries) {
1148
- const row = node("tr");
1149
- const who = node("td");
1150
- who.appendChild(document.createTextNode(party(delivery.agent_id).name));
1151
- row.appendChild(who);
1152
- row.appendChild(node("td", "role", delivery.role === "sender" ? "expéditeur" : "destinataire"));
1153
- row.appendChild(node("td", "folder", delivery.folder));
1154
- const stateCell = node("td");
1155
- stateCell.appendChild(
1156
- delivery.read ? tag("tag tag--ok", "lu") : tag("tag tag--warn", delivery.role === "sender" ? "—" : "non lu")
1157
- );
1158
- row.appendChild(stateCell);
1159
- table.appendChild(row);
1160
- }
1161
- return table;
1162
- }
1163
-
1164
- // --- Navigation ------------------------------------------------------------
1165
-
1166
- async function openThread(threadId) {
1167
- state.threadId = threadId;
1168
- // Un fil s'ouvre replié, même celui qu'on rouvre : ce qui était déplié l'était pour
1169
- // l'autre conversation.
1170
- state.openMessages.clear();
1171
- await refreshThread();
1172
- await refresh({ keepThread: true });
1173
- }
1174
-
1175
- async function refreshThread() {
1176
- if (!state.threadId) {
1177
- renderEmptyReader();
1178
- return;
1179
- }
1180
- try {
1181
- renderThread(await api(`/admin/threads/${encodeURIComponent(state.threadId)}`));
1182
- } catch (error) {
1183
- if (error.message !== "unauthorized") el.reader.replaceChildren(node("div", "banner", error.message));
1184
- }
1185
- }
1186
-
1187
- // --- Boucle de rafraîchissement --------------------------------------------
1188
-
1189
- async function refresh(options) {
1190
- try {
1191
- const [overview, list] = await Promise.all([
1192
- api("/admin/overview"),
1193
- api("/admin/threads", {
1194
- // Une sélection d'agent recouvre toutes ses sessions : la route prend la liste.
1195
- agent: (selectedAddresses() || []).join(","),
1196
- search: state.search,
1197
- unread_only: state.filter === "unread" ? "true" : undefined,
1198
- priority: state.filter === "high" ? "high" : undefined,
1199
- limit: 100,
1200
- }),
1201
- ]);
1202
- setLoggedIn(true);
1203
- setStatus("ok", `connecté · ${window.location.host}`);
1204
- renderAgents(overview);
1205
- renderThreads(list.threads);
1206
- if (!options || !options.keepThread) {
1207
- if (state.threadId) await refreshThread();
1208
- else renderEmptyReader();
1209
- }
1210
- } catch (error) {
1211
- if (error.message === "unauthorized") {
1212
- setStatus("idle", "authentification requise");
1213
- return;
1214
- }
1215
- setStatus("ko", `erreur : ${error.message}`);
1216
- }
1217
- }
1218
-
1219
- // --- Largeur des panneaux --------------------------------------------------
1220
-
1221
- /**
1222
- * Ce que chaque séparateur commande.
1223
- *
1224
- * `edge` dit de quel côté se trouve le panneau réglé : ici, chacun borde le panneau qui
1225
- * le précède. La distinction reste écrite car un panneau ancré à droite se réglerait à
1226
- * l'envers, en comptant depuis son bord droit.
1227
- */
1228
- const PANELS = {
1229
- "resize-agents": { name: "--w-agents", panel: "agentsPane", edge: "before", min: 180, max: 520, initial: 240 },
1230
- "resize-threads": { name: "--w-threads", panel: "threadsPane", edge: "before", min: 260, max: 720, initial: 400 },
1231
- };
1232
-
1233
- const LAYOUT_KEY = "messenger.layout";
1234
-
1235
- function setPanelWidth(id, pixels) {
1236
- const panel = PANELS[id];
1237
- const width = Math.round(Math.min(panel.max, Math.max(panel.min, pixels)));
1238
- // Passer par le CSSOM plutôt que par un attribut `style` : la CSP interdit le second.
1239
- el.console.style.setProperty(panel.name, `${width}px`);
1240
- return width;
1241
- }
1242
-
1243
- function panelWidth(id) {
1244
- const current = parseInt(el.console.style.getPropertyValue(PANELS[id].name), 10);
1245
- return Number.isFinite(current) ? current : PANELS[id].initial;
1246
- }
1247
-
1248
- /** Les largeurs suivent l'utilisateur d'une visite à l'autre : elles vivent dans son navigateur. */
1249
- function saveLayout() {
1250
- try {
1251
- const widths = {};
1252
- for (const id of Object.keys(PANELS)) widths[id] = panelWidth(id);
1253
- window.localStorage.setItem(LAYOUT_KEY, JSON.stringify(widths));
1254
- } catch {
1255
- // Navigation privée, stockage refusé : la mise en page reste bonne pour cette visite.
1256
- }
1257
- }
1258
-
1259
- function loadLayout() {
1260
- let saved = null;
1261
- try {
1262
- saved = JSON.parse(window.localStorage.getItem(LAYOUT_KEY) || "null");
1263
- } catch {
1264
- saved = null;
1265
- }
1266
- if (!saved) return;
1267
- for (const [id, width] of Object.entries(saved)) {
1268
- // Une largeur venue du stockage est une donnée comme une autre : elle est bornée
1269
- // comme si elle sortait d'un glissé, sinon une valeur aberrante casserait la page.
1270
- if (PANELS[id] && Number.isFinite(Number(width))) setPanelWidth(id, Number(width));
1271
- }
1272
- }
1273
-
1274
- function startResize(event, id) {
1275
- const panel = PANELS[id];
1276
- const handle = document.getElementById(id);
1277
- const rect = el[panel.panel].getBoundingClientRect();
1278
- const measure = (x) => (panel.edge === "before" ? x - rect.left : rect.right - x);
1279
-
1280
- el.console.classList.add("is-resizing");
1281
- handle.classList.add("is-active");
1282
- handle.setPointerCapture?.(event.pointerId);
1283
-
1284
- const onMove = (move) => setPanelWidth(id, measure(move.clientX));
1285
- const onUp = () => {
1286
- window.removeEventListener("pointermove", onMove);
1287
- window.removeEventListener("pointerup", onUp);
1288
- el.console.classList.remove("is-resizing");
1289
- handle.classList.remove("is-active");
1290
- saveLayout();
1291
- };
1292
- window.addEventListener("pointermove", onMove);
1293
- window.addEventListener("pointerup", onUp);
1294
- }
1295
-
1296
- /** Au clavier, les flèches déplacent le séparateur ; le pas est plus large avec Maj. */
1297
- function nudgeResize(event, id) {
1298
- const step = (event.shiftKey ? 48 : 16) * (event.key === "ArrowLeft" ? -1 : 1);
1299
- const towards = PANELS[id].edge === "before" ? step : -step;
1300
- setPanelWidth(id, panelWidth(id) + towards);
1301
- saveLayout();
1302
- }
1303
-
1304
- for (const id of Object.keys(PANELS)) {
1305
- const handle = document.getElementById(id);
1306
- handle.addEventListener("pointerdown", (event) => {
1307
- event.preventDefault();
1308
- startResize(event, id);
1309
- });
1310
- // Un double-clic rend sa largeur d'origine : c'est le moyen de sortir d'un réglage raté.
1311
- handle.addEventListener("dblclick", () => {
1312
- setPanelWidth(id, PANELS[id].initial);
1313
- saveLayout();
1314
- });
1315
- handle.addEventListener("keydown", (event) => {
1316
- if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
1317
- event.preventDefault();
1318
- nudgeResize(event, id);
1319
- });
1320
- }
1321
-
1322
- loadLayout();
1323
-
1324
- // --- Purge des boîtes inactives --------------------------------------------
1325
-
1326
- /**
1327
- * Ce que la fenêtre de nettoyage a sous la main : à quel titre les boîtes sont
1328
- * supprimables (`empty` ou une date), celles que le serveur a désignées, celles qui
1329
- * restent cochées, et si la suppression a déjà été demandée une fois. Le second clic est
1330
- * ce qui la déclenche.
1331
- */
1332
- const purge = { mode: null, before: null, mailboxes: [], selected: new Set(), armed: false };
1333
-
1334
- function openPurge() {
1335
- const month = new Date(Date.now() - 30 * 86_400_000);
1336
- const today = new Date().toISOString().slice(0, 10);
1337
- el.purgeBefore.value = month.toISOString().slice(0, 10);
1338
- el.purgeBefore.max = today;
1339
- el.purgeCreatedBefore.value = month.toISOString().slice(0, 10);
1340
- el.purgeCreatedBefore.max = today;
1341
- resetPurge();
1342
- el.purge.showModal();
1343
- }
1344
-
1345
- /**
1346
- * Ouvre la fenêtre sur une seule boîte, celle qu'on vient de désigner dans la colonne.
1347
- *
1348
- * Rien à demander au serveur : l'annuaire vient d'être lu, et ce qu'il en dit est ce que
1349
- * la ligne cliquée affiche. La liste est donc dressée sur place, avec la même mise en
1350
- * forme et la même confirmation que les nettoyages en lot.
1351
- */
1352
- function openPurgeFor(box) {
1353
- openPurge();
1354
- purge.mode = "one";
1355
- purge.mailboxes = [
1356
- {
1357
- id: box.id,
1358
- agent_name: box.agent_name,
1359
- display_name: box.display_name,
1360
- last_activity_at: box.last_activity_at,
1361
- messages: box.sent + box.received,
1362
- },
1363
- ];
1364
- purge.selected = new Set([box.id]);
1365
- renderPurgePreview();
1366
- }
1367
-
1368
- /** Toute modification remet la confirmation à zéro : on ne supprime que ce qu'on vient de voir. */
1369
- function resetPurge() {
1370
- purge.mode = null;
1371
- purge.before = null;
1372
- purge.mailboxes = [];
1373
- purge.selected = new Set();
1374
- purge.armed = false;
1375
- el.purgeResult.replaceChildren();
1376
- el.purgeConfirm.disabled = true;
1377
- el.purgeConfirm.textContent = "Supprimer";
1378
- }
1379
-
1380
- /** Boîtes par lesquelles rien n'est jamais passé, sans considération de date. */
1381
- async function previewEmpty() {
1382
- await previewPurge("empty", () => api("/admin/mailboxes/empty"));
1383
- }
1384
-
1385
- /** Boîtes qui ont servi, mais que plus rien n'a touchées depuis la date choisie. */
1386
- async function previewStale() {
1387
- const before = el.purgeBefore.value;
1388
- if (!before) return;
1389
- await previewPurge("stale", () => api("/admin/mailboxes/stale", { before }), before);
1390
- }
1391
-
1392
- /** Boîtes ouvertes avant la date choisie, qu'elles servent encore ou non. */
1393
- async function previewCreated() {
1394
- const before = el.purgeCreatedBefore.value;
1395
- if (!before) return;
1396
- await previewPurge("created", () => api("/admin/mailboxes/created-before", { created_before: before }), before);
1397
- }
1398
-
1399
- async function previewPurge(mode, load, before = null) {
1400
- resetPurge();
1401
- try {
1402
- const { mailboxes } = await load();
1403
- purge.mode = mode;
1404
- purge.before = before;
1405
- purge.mailboxes = mailboxes;
1406
- purge.selected = new Set(mailboxes.map((mailbox) => mailbox.id));
1407
- renderPurgePreview();
1408
- } catch (error) {
1409
- el.purgeResult.replaceChildren(node("p", "purge-error", `Erreur : ${error.message}`));
1410
- }
1411
- }
1412
-
1413
- function renderPurgePreview() {
1414
- const container = document.createDocumentFragment();
1415
- const count = purge.mailboxes.length;
1416
- // Le critère est rappelé sous les yeux de qui va confirmer : les quatre suppressions
1417
- // remplissent la même liste, et elles ne portent pas sur les mêmes boîtes. Les deux
1418
- // critères datés se distinguent par leur seule formulation : « depuis » regarde
1419
- // l'activité, « créée avant » regarde l'ouverture de la boîte.
1420
- const dated =
1421
- purge.mode === "created"
1422
- ? ` créée avant le ${formatDay(purge.before)}`
1423
- : ` sans activité depuis le ${formatDay(purge.before)}`;
1424
- const criterion = purge.mode === "empty" ? "vide" : purge.mode === "one" ? "" : dated;
1425
-
1426
- if (count === 0) {
1427
- container.appendChild(node("p", "purge-empty", `Aucune boîte ${criterion.trim()}.`));
1428
- el.purgeResult.replaceChildren(container);
1429
- return;
1430
- }
1431
-
1432
- container.appendChild(
1433
- node(
1434
- "p",
1435
- "purge-count",
1436
- purge.mode === "one"
1437
- ? "Cette boîte, et tout ce que personne d'autre ne pourrait plus lire :"
1438
- : purge.mode === "empty"
1439
- ? `${plural(count, "boîte vide", "boîtes vides")}.`
1440
- : purge.mode === "created"
1441
- ? `${plural(count, "boîte créée", "boîtes créées")} avant le ${formatDay(purge.before)}.`
1442
- : `${plural(count, "boîte")}${dated}.`
1443
- )
1444
- );
1445
-
1446
- const list = node("ul", "purge-list");
1447
- for (const mailbox of purge.mailboxes) {
1448
- const item = node("li", "purge-item");
1449
- const label = node("label", "purge-line");
1450
-
1451
- const box = document.createElement("input");
1452
- box.type = "checkbox";
1453
- box.checked = true;
1454
- box.addEventListener("change", () => {
1455
- if (box.checked) purge.selected.add(mailbox.id);
1456
- else purge.selected.delete(mailbox.id);
1457
- // Décocher change ce qui sera supprimé : la confirmation déjà armée ne vaut plus.
1458
- purge.armed = false;
1459
- updatePurgeButton();
1460
- });
1461
- label.appendChild(box);
1462
-
1463
- const text = node("span", "purge-text");
1464
- text.appendChild(node("span", "purge-name", mailbox.display_name || mailbox.agent_name || "boîte sans nom"));
1465
- text.appendChild(node("span", "mono muted", mailbox.id));
1466
- const activity = mailbox.last_activity_at ? `dernière activité ${formatDay(mailbox.last_activity_at)}` : "jamais utilisée";
1467
- text.appendChild(node("span", "purge-meta", `${plural(mailbox.messages, "message")} · ${activity}`));
1468
- label.appendChild(text);
1469
-
1470
- item.appendChild(label);
1471
- list.appendChild(item);
1472
- }
1473
- container.appendChild(list);
1474
-
1475
- el.purgeResult.replaceChildren(container);
1476
- updatePurgeButton();
1477
- }
1478
-
1479
- function updatePurgeButton() {
1480
- const count = purge.selected.size;
1481
- el.purgeConfirm.disabled = count === 0;
1482
- el.purgeConfirm.textContent = purge.armed
1483
- ? `Confirmer : supprimer ${plural(count, "boîte")}`
1484
- : `Supprimer ${plural(count, "boîte")}`;
1485
- }
1486
-
1487
- async function confirmPurge() {
1488
- if (purge.selected.size === 0 || !purge.mode) return;
1489
-
1490
- // Premier clic : la suppression est armée, pas faite. Rien ne part sans un second clic.
1491
- if (!purge.armed) {
1492
- purge.armed = true;
1493
- updatePurgeButton();
1494
- return;
1495
- }
1496
-
1497
- el.purgeConfirm.disabled = true;
1498
- const deleted = [...purge.selected];
1499
- try {
1500
- // Une boîte désignée à la main s'efface par son adresse : il n'y a pas de critère à
1501
- // revérifier, l'utilisateur vient de la montrer du doigt.
1502
- const removed =
1503
- purge.mode === "one"
1504
- ? await remove(`/admin/mailboxes/${encodeURIComponent(deleted[0])}`)
1505
- : await post("/admin/mailboxes/purge", {
1506
- // Le critère repart avec les adresses : c'est lui que le serveur revérifie.
1507
- ...(purge.mode === "empty"
1508
- ? { empty: true }
1509
- : purge.mode === "created"
1510
- ? { created_before: purge.before }
1511
- : { before: purge.before }),
1512
- mailboxes: deleted,
1513
- });
1514
- resetPurge();
1515
- el.purgeResult.replaceChildren(
1516
- node(
1517
- "p",
1518
- "purge-done",
1519
- `${plural(removed.mailboxes, "boîte")} supprimée(s), ${plural(removed.messages, "message")} et ${plural(removed.threads, "fil")} avec.`
1520
- )
1521
- );
1522
- // La console montre encore ce qui n'existe plus : elle se relit entièrement, et son
1523
- // filtre est relâché s'il portait sur une boîte qui vient de disparaître.
1524
- if (state.selection.kind === "box" && deleted.includes(state.selection.key)) {
1525
- state.selection = { kind: "all", key: null };
1526
- }
1527
- state.threadId = null;
1528
- await refresh();
1529
- } catch (error) {
1530
- purge.armed = false;
1531
- updatePurgeButton();
1532
- el.purgeResult.appendChild(node("p", "purge-error", `Erreur : ${error.message}`));
1533
- }
1534
- }
1535
-
1536
- // --- Écouteurs -------------------------------------------------------------
1537
-
1538
- el.purgeOpen.addEventListener("click", openPurge);
1539
- el.purgeClose.addEventListener("click", () => el.purge.close());
1540
- el.purgeCancel.addEventListener("click", () => el.purge.close());
1541
- el.purgeBefore.addEventListener("change", resetPurge);
1542
- el.purgeCreatedBefore.addEventListener("change", resetPurge);
1543
- el.purgePreview.addEventListener("click", previewStale);
1544
- el.purgePreviewCreated.addEventListener("click", previewCreated);
1545
- el.purgePreviewEmpty.addEventListener("click", previewEmpty);
1546
- el.purgeConfirm.addEventListener("click", confirmPurge);
1547
-
1548
- el.refresh.addEventListener("click", () => refresh({ keepThread: true }));
1549
-
1550
- // Le bouton reste en place une fois la colonne repliée : il est le seul moyen de la
1551
- // rouvrir. Seul ce qu'il annonce change.
1552
- el.collapse.addEventListener("click", () => {
1553
- state.collapsed = !state.collapsed;
1554
- el.console.classList.toggle("is-collapsed", state.collapsed);
1555
- const label = state.collapsed ? "Déplier la liste des agents" : "Replier la liste des agents";
1556
- el.collapse.title = state.collapsed ? "Déplier" : "Replier";
1557
- el.collapse.setAttribute("aria-label", label);
1558
- el.collapse.setAttribute("aria-expanded", String(!state.collapsed));
1559
- });
1560
-
1561
- el.chips.addEventListener("click", (event) => {
1562
- const chip = event.target.closest(".chip");
1563
- if (!chip) return;
1564
- state.filter = chip.dataset.filter;
1565
- for (const other of el.chips.querySelectorAll(".chip")) {
1566
- other.classList.toggle("is-active", other === chip);
1567
- }
1568
- refresh({ keepThread: true });
1569
- });
1570
-
1571
- let searchTimer = null;
1572
- el.search.addEventListener("input", (event) => {
1573
- state.search = event.target.value.trim();
1574
- clearTimeout(searchTimer);
1575
- searchTimer = setTimeout(() => refresh({ keepThread: true }), 250);
1576
- });
1577
-
1578
- el.tokenReveal.addEventListener("click", () => {
1579
- const shown = el.token.type === "text";
1580
- el.token.type = shown ? "password" : "text";
1581
- el.tokenReveal.setAttribute("aria-label", shown ? "Afficher le jeton" : "Masquer le jeton");
1582
- el.token.focus();
1583
- });
1584
-
1585
- el.loginForm.addEventListener("submit", async (event) => {
1586
- event.preventDefault();
1587
- el.tokenError.textContent = "";
1588
- el.tokenField.classList.remove("is-invalid");
1589
- const token = el.token.value.trim();
1590
- if (!token) return;
1591
- const response = await fetch("/ui/login", {
1592
- method: "POST",
1593
- headers: { "content-type": "application/json" },
1594
- credentials: "same-origin",
1595
- body: JSON.stringify({ token }),
1596
- }).catch(() => null);
1597
- // Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
1598
- el.token.value = "";
1599
- el.token.type = "password";
1600
- if (!response || !response.ok) {
1601
- el.tokenField.classList.add("is-invalid");
1602
- el.tokenError.textContent =
1603
- response && response.status === 429
1604
- ? "Trop de tentatives, réessayez dans quelques minutes."
1605
- : "Jeton refusé. Vérifiez MESSENGER_ADMIN_TOKEN côté serveur.";
1606
- return;
1607
- }
1608
- refresh();
1609
- });
1610
-
1611
- el.logout.addEventListener("click", async () => {
1612
- await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
1613
- state.selection = { kind: "all", key: null };
1614
- state.expanded = new Set();
1615
- state.showEmpty = new Set();
1616
- state.threadId = null;
1617
- state.openMessages = new Set();
1618
- state.directory = new Map();
1619
- state.groups = [];
1620
- el.agents.replaceChildren();
1621
- el.threads.replaceChildren();
1622
- renderEmptyReader();
1623
- setLoggedIn(false);
1624
- });
1625
-
1626
- setInterval(() => {
1627
- if (el.autoRefresh.checked && el.login.hidden) refresh({ keepThread: true });
1628
- }, REFRESH_MS);
1629
-
1630
- el.loginOrigin.textContent = `MCP Messenger · ${window.location.host}`;
1631
- renderEmptyReader();
1632
- refresh();
1
+ // Console de supervision : les routes /admin du serveur. Deux familles de commandes
2
+ // écrivent : les liaisons entre boîtes (création, suppression), et les suppressions de
3
+ // boîtes, qui passent toutes par la même fenêtre — un aperçu de ce qui va partir, puis une
4
+ // confirmation en deux clics.
5
+ //
6
+ // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
7
+ // jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
8
+ // écrites en dur dans ce fichier. Le corps des messages passe par `renderMarkdown`, qui
9
+ // bâtit lui aussi des nœuds un par un et n'ouvre donc pas non plus de brèche.
10
+ //
11
+ // L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
12
+ // ce script ne voit jamais le jeton et n'écrit rien dans le navigateur.
13
+
14
+ import { renderMarkdown } from "./markdown.js";
15
+
16
+ const REFRESH_MS = 5000;
17
+
18
+ // Une adresse ne dit rien à qui lit la console : elle sert de graine à la couleur de
19
+ // l'avatar, pour que la même boîte garde la même teinte d'un écran à l'autre.
20
+ const AVATAR_COLORS = 8;
21
+
22
+ const ICONS = {
23
+ copy: ["M9 3h9a2 2 0 0 1 2 2v9", "M5 7h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z"],
24
+ check: ["M20 6 9 17l-5-5"],
25
+ clock: ["M12 6v6l4 2", "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z"],
26
+ close: ["M18 6 6 18", "m6 6 12 12"],
27
+ link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
28
+ chevron: ["m9 18 6-6-6-6"],
29
+ info: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 11v5", "M12 8h.01"],
30
+ pencil: ["M21.2 6.8a2.8 2.8 0 0 0-4-4L3.8 16.2a2 2 0 0 0-.5.8L2 22l5-1.3a2 2 0 0 0 .8-.5z", "m15 5 4 4"],
31
+ paperclip: ["m21 11-8.5 8.5a5 5 0 0 1-7-7L14 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L15 7"],
32
+ trash: ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"],
33
+ unlink: [
34
+ "m18.84 12.25 1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71",
35
+ "m5.17 11.75-1.71 1.71a5 5 0 0 0 7.07 7.07l1.71-1.71",
36
+ "M8 2v3",
37
+ "M2 8h3",
38
+ "M16 22v-3",
39
+ "M22 16h-3",
40
+ ],
41
+ search: ["m21 21-4.3-4.3", "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"],
42
+ };
43
+
44
+ const state = {
45
+ /** "threads" | "links" — l'écran affiché, choisi par les onglets de la barre du haut. */
46
+ view: "threads",
47
+ /** Liaisons du serveur, relues à chaque rafraîchissement. */
48
+ links: [],
49
+ /**
50
+ * Ce sur quoi la liste des fils est filtrée.
51
+ *
52
+ * `{ kind: "all" }` ne filtre rien ; `{ kind: "agent", key }` retient toutes les boîtes
53
+ * d'un projet ; `{ kind: "box", key }` une seule session. Deux niveaux, parce qu'un
54
+ * agent tient une boîte par session ouverte et que les deux questions se posent.
55
+ */
56
+ selection: { kind: "all", key: null },
57
+ /** Noms d'agents dépliés dans la colonne de gauche. Tout est replié au départ. */
58
+ expanded: new Set(),
59
+ /** Agents dont on a demandé à voir les boîtes vides. Elles sont cachées au départ. */
60
+ showEmpty: new Set(),
61
+ /**
62
+ * Faut-il montrer les agents dont aucune session n'a rien échangé ? Non par défaut,
63
+ * comme les boîtes vides à l'intérieur d'un agent : le serveur alloue une boîte par
64
+ * session ouverte, et un projet entier peut n'avoir jamais rien dit.
65
+ */
66
+ showEmptyAgents: false,
67
+ /** "all" | "unread" | "high" — les puces au-dessus de la liste des fils. */
68
+ filter: "all",
69
+ search: "",
70
+ threadId: null,
71
+ /**
72
+ * Messages dépliés dans le lecteur, par identifiant. Vide au départ : un fil s'ouvre
73
+ * replié, et se parcourt avant de se lire.
74
+ */
75
+ openMessages: new Set(),
76
+ collapsed: false,
77
+ /** Annuaire des boîtes, indexé par adresse : rempli par chaque `/admin/overview`. */
78
+ directory: new Map(),
79
+ /** Boîtes regroupées par nom d'agent, dans l'ordre de l'annuaire. */
80
+ groups: [],
81
+ };
82
+
83
+ const el = {
84
+ status: document.getElementById("status"),
85
+ statusLabel: document.getElementById("status-label"),
86
+ searchBox: document.getElementById("search-box"),
87
+ search: document.getElementById("search"),
88
+ controls: document.getElementById("controls"),
89
+ autoRefresh: document.getElementById("auto-refresh"),
90
+ refresh: document.getElementById("refresh"),
91
+ logout: document.getElementById("logout"),
92
+ login: document.getElementById("login"),
93
+ loginForm: document.getElementById("login-form"),
94
+ loginOrigin: document.getElementById("login-origin"),
95
+ tokenField: document.querySelector("#login-form .field"),
96
+ token: document.getElementById("token"),
97
+ tokenReveal: document.getElementById("token-reveal"),
98
+ tokenError: document.getElementById("token-error"),
99
+ console: document.getElementById("console"),
100
+ agentsPane: document.getElementById("agents-pane"),
101
+ agents: document.getElementById("agents"),
102
+ agentsFoot: document.getElementById("agents-foot"),
103
+ collapse: document.getElementById("collapse"),
104
+ threadsAvatar: document.getElementById("threads-avatar"),
105
+ threadsName: document.getElementById("threads-name"),
106
+ threadsScope: document.getElementById("threads-scope"),
107
+ // Le panneau, pas la liste qu'il contient : c'est lui que le séparateur redimensionne.
108
+ threadsPane: document.querySelector(".threads"),
109
+ chips: document.getElementById("chips"),
110
+ threads: document.getElementById("threads"),
111
+ reader: document.getElementById("reader"),
112
+ details: document.getElementById("details"),
113
+ detailsBody: document.getElementById("details-body"),
114
+ purge: document.getElementById("purge"),
115
+ purgeOpen: document.getElementById("purge-open"),
116
+ purgeClose: document.getElementById("purge-close"),
117
+ purgeCancel: document.getElementById("purge-cancel"),
118
+ purgeBefore: document.getElementById("purge-before"),
119
+ purgeCreatedBefore: document.getElementById("purge-created-before"),
120
+ purgePreview: document.getElementById("purge-preview"),
121
+ purgePreviewCreated: document.getElementById("purge-preview-created"),
122
+ purgePreviewEmpty: document.getElementById("purge-preview-empty"),
123
+ purgeResult: document.getElementById("purge-result"),
124
+ purgeConfirm: document.getElementById("purge-confirm"),
125
+ tabs: document.getElementById("tabs"),
126
+ linksBadge: document.getElementById("links-badge"),
127
+ links: document.getElementById("links"),
128
+ linksWindow: document.getElementById("links-window"),
129
+ linksScroll: document.getElementById("links-scroll"),
130
+ linksCanvas: document.getElementById("links-canvas"),
131
+ linksPanel: document.getElementById("links-panel"),
132
+ };
133
+
134
+ // --- Fabrique de noeuds ----------------------------------------------------
135
+
136
+ function node(tag, className, text) {
137
+ const element = document.createElement(tag);
138
+ if (className) element.className = className;
139
+ if (text !== undefined) element.textContent = text;
140
+ return element;
141
+ }
142
+
143
+ function icon(name) {
144
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
145
+ svg.setAttribute("class", "icon");
146
+ svg.setAttribute("viewBox", "0 0 24 24");
147
+ svg.setAttribute("aria-hidden", "true");
148
+ for (const d of ICONS[name]) {
149
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
150
+ path.setAttribute("d", d);
151
+ svg.appendChild(path);
152
+ }
153
+ return svg;
154
+ }
155
+
156
+ function button(className, label, onClick) {
157
+ const element = node("button", className, label);
158
+ element.type = "button";
159
+ element.addEventListener("click", onClick);
160
+ return element;
161
+ }
162
+
163
+ /** Bouton de copie : les adresses et les identifiants ne se recopient pas à la main. */
164
+ function copyButton(value, label) {
165
+ const element = node("button", "copy");
166
+ element.type = "button";
167
+ element.title = label;
168
+ element.setAttribute("aria-label", label);
169
+ element.appendChild(icon("copy"));
170
+ element.addEventListener("click", async (event) => {
171
+ event.stopPropagation();
172
+ try {
173
+ await navigator.clipboard.writeText(value);
174
+ element.replaceChildren(icon("check"));
175
+ setTimeout(() => element.replaceChildren(icon("copy")), 1200);
176
+ } catch {
177
+ // Presse-papiers refusé (contexte non sécurisé, permission) : la valeur reste
178
+ // affichée à côté, donc sélectionnable. Rien d'autre à signaler.
179
+ }
180
+ });
181
+ return element;
182
+ }
183
+
184
+ function tag(className, label, iconName) {
185
+ const element = node("span", className);
186
+ if (iconName) element.appendChild(icon(iconName));
187
+ element.appendChild(document.createTextNode(label));
188
+ return element;
189
+ }
190
+
191
+ // --- Annuaire des boîtes ---------------------------------------------------
192
+
193
+ /**
194
+ * Deux noms par boîte, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte
195
+ * de quoi cette session s'occupe. La console montre les deux, et n'affiche l'adresse que
196
+ * là où elle sert à être copiée.
197
+ *
198
+ * Un nom d'agent ne désigne personne : plusieurs sessions du même projet le portent. Dès
199
+ * qu'il est partagé, `name` lui accole le nom de la boîte — sans quoi un fil afficherait
200
+ * « Documentor Dev → Documentor Dev » sans dire de quelles sessions il parle.
201
+ */
202
+ function party(agentId) {
203
+ const known = state.directory.get(agentId);
204
+ const agentName = (known && known.agent_name) || null;
205
+ const boxName = (known && known.display_name) || null;
206
+ const shared = agentName ? state.groups.some((g) => g.key === agentName && g.boxes.length > 1) : false;
207
+ // Parenthèses, et non un séparateur plat : les participants d'un fil sont eux-mêmes
208
+ // listés avec un point médian, qui ne doit pas se confondre avec cette précision.
209
+ const name = agentName
210
+ ? shared
211
+ ? `${agentName} (${boxName || shortAddress(agentId)})`
212
+ : agentName
213
+ : shortAddress(agentId);
214
+ return {
215
+ id: agentId,
216
+ name,
217
+ agentName,
218
+ box: boxName,
219
+ registered: !known || known.registered,
220
+ unread: (known && known.unread) || 0,
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Nom de la boîte, ou son adresse à défaut.
226
+ *
227
+ * Une boîte est nommée par son agent (`rename_mailbox`) ou depuis la carte des liaisons ;
228
+ * sans nom, seule l'adresse la distingue de ses sœurs, et c'est donc elle qu'on lit.
229
+ */
230
+ function boxLabel(box) {
231
+ return box.display_name || box.id;
232
+ }
233
+
234
+ function shortAddress(agentId) {
235
+ const id = String(agentId || "");
236
+ return id.length > 12 ? `${id.slice(0, 10)}…` : id || "—";
237
+ }
238
+
239
+ function initials(label) {
240
+ const words = String(label).split(/[\s_·-]+/).filter(Boolean);
241
+ const letters = words.length >= 2 ? words[0][0] + words[1][0] : String(label).slice(0, 2);
242
+ return letters.toUpperCase();
243
+ }
244
+
245
+ /** Teinte stable : la même graine donne toujours la même couleur d'avatar. */
246
+ function colorIndex(seed) {
247
+ let hash = 0;
248
+ for (const char of String(seed)) hash = (hash * 31 + char.charCodeAt(0)) % 100003;
249
+ return hash % AVATAR_COLORS;
250
+ }
251
+
252
+ /** Pastille d'initiales. `seed` teinte, `label` écrit, `title` explique au survol. */
253
+ function avatarFor(seed, label, title, extraClass, absent) {
254
+ const classes = ["avatar"];
255
+ if (extraClass) classes.push(extraClass);
256
+ classes.push(absent ? "avatar--absent" : `avatar--c${colorIndex(seed)}`);
257
+ const element = node("span", classes.join(" "), initials(label));
258
+ element.title = title;
259
+ return element;
260
+ }
261
+
262
+ function avatar(agentId, extraClass) {
263
+ const who = party(agentId);
264
+ const label = who.box || who.agentName || shortAddress(agentId);
265
+ const title = [who.agentName, who.box, agentId].filter(Boolean).join(" — ");
266
+ return avatarFor(agentId, label, title, extraClass, !who.registered);
267
+ }
268
+
269
+ /**
270
+ * Le nom de l'agent, et lui seul.
271
+ *
272
+ * `party().name` accole le nom de la boîte dès que plusieurs sessions d'un projet
273
+ * existent : indispensable là où deux sessions doivent se distinguer, encombrant dans le
274
+ * lecteur d'un fil, où l'on suit une conversation entre deux correspondants déjà connus.
275
+ * Ce que la boîte ajoute — son nom, son adresse — se lit dans les détails du message.
276
+ */
277
+ function agentLabel(agentId) {
278
+ const who = party(agentId);
279
+ return who.agentName || who.box || shortAddress(agentId);
280
+ }
281
+
282
+ /**
283
+ * Pastille de l'agent, et non de la boîte.
284
+ *
285
+ * Dans une liste, ce qui se cherche du regard est le projet : deux sessions d'un même
286
+ * agent portent donc la même pastille, et c'est voulu — ce qui les distingue, le nom de
287
+ * la boîte, se lit au survol. La pastille de boîte reste celle du lecteur et du volet,
288
+ * là où l'on regarde une session en particulier.
289
+ */
290
+ function agentAvatar(agentId, extraClass, title) {
291
+ const who = party(agentId);
292
+ const seed = who.agentName || agentId;
293
+ const label = who.agentName || who.box || shortAddress(agentId);
294
+ return avatarFor(seed, label, title ?? boxTitle(agentId), extraClass, !who.registered);
295
+ }
296
+
297
+ /** Ce qu'une pastille dit au survol : le projet, la session, l'adresse. */
298
+ function boxTitle(agentId) {
299
+ const who = party(agentId);
300
+ return [
301
+ who.agentName || "agent sans nom",
302
+ who.box || (who.registered ? "boîte sans nom" : "boîte jamais connectée"),
303
+ agentId,
304
+ ].join("\n");
305
+ }
306
+
307
+ // --- Dates -----------------------------------------------------------------
308
+
309
+ function formatDate(iso) {
310
+ const date = new Date(iso);
311
+ return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleString();
312
+ }
313
+
314
+ /** Heure seule aujourd'hui, « hier » la veille, date courte au-delà : la liste doit se survoler. */
315
+ function formatShort(iso) {
316
+ const date = new Date(iso);
317
+ if (Number.isNaN(date.getTime())) return String(iso);
318
+ const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
319
+ const day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
320
+ const today = new Date();
321
+ const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
322
+ const days = Math.round((midnight - day) / 86400000);
323
+ if (days === 0) return time;
324
+ if (days === 1) return `hier ${time}`;
325
+ return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
326
+ }
327
+
328
+ /** Jour seul, sans heure : une purge se raisonne en jours, pas en minutes. */
329
+ function formatDay(iso) {
330
+ const date = new Date(iso);
331
+ return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleDateString();
332
+ }
333
+
334
+ function plural(count, singular, pluralForm) {
335
+ return `${count} ${count > 1 ? pluralForm || `${singular}s` : singular}`;
336
+ }
337
+
338
+ // --- Accès au serveur ------------------------------------------------------
339
+
340
+ async function api(path, params) {
341
+ const url = new URL(path, window.location.origin);
342
+ for (const [key, value] of Object.entries(params || {})) {
343
+ if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
344
+ }
345
+ const response = await fetch(url, { headers: { accept: "application/json" }, credentials: "same-origin" });
346
+ if (response.status === 401) {
347
+ setLoggedIn(false);
348
+ throw new Error("unauthorized");
349
+ }
350
+ if (!response.ok) {
351
+ const payload = await response.json().catch(() => ({}));
352
+ throw new Error(payload.error || response.statusText);
353
+ }
354
+ return response.json();
355
+ }
356
+
357
+ /** Envoi d'une action à la console. */
358
+ async function post(path, body) {
359
+ return action("POST", path, body);
360
+ }
361
+
362
+ /** Modification partielle d'une ressource nommée par son adresse. */
363
+ async function patch(path, body) {
364
+ return action("PATCH", path, body);
365
+ }
366
+
367
+ /** Suppression d'une ressource nommée par son adresse : pas de corps, l'URL suffit. */
368
+ async function remove(path) {
369
+ return action("DELETE", path, null);
370
+ }
371
+
372
+ async function action(method, path, body) {
373
+ const response = await fetch(path, {
374
+ method,
375
+ headers: { "content-type": "application/json", accept: "application/json" },
376
+ credentials: "same-origin",
377
+ body: body === null ? undefined : JSON.stringify(body),
378
+ });
379
+ if (response.status === 401) {
380
+ setLoggedIn(false);
381
+ throw new Error("unauthorized");
382
+ }
383
+ const payload = await response.json().catch(() => ({}));
384
+ if (!response.ok) throw new Error(payload.error || response.statusText);
385
+ return payload;
386
+ }
387
+
388
+ function setLoggedIn(loggedIn) {
389
+ el.login.hidden = loggedIn;
390
+ el.console.hidden = !loggedIn || state.view !== "threads";
391
+ el.links.hidden = !loggedIn || state.view !== "links";
392
+ el.tabs.hidden = !loggedIn;
393
+ el.controls.hidden = !loggedIn;
394
+ // La recherche porte sur les fils ; le volet des liaisons a la sienne.
395
+ el.searchBox.hidden = !loggedIn || state.view !== "threads";
396
+ if (!loggedIn) {
397
+ setStatus("idle", "non connecté");
398
+ el.token.focus();
399
+ }
400
+ }
401
+
402
+ function setStatus(kind, label) {
403
+ el.status.className = `pill pill--${kind}`;
404
+ el.statusLabel.textContent = label;
405
+ }
406
+
407
+ // --- Colonne des agents ----------------------------------------------------
408
+
409
+ /**
410
+ * Regroupe les boîtes sous leur nom d'agent.
411
+ *
412
+ * L'agent dit d'où l'on écrit, la boîte quelle session parle : un projet qui a deux
413
+ * sessions ouvertes tient deux boîtes, et la colonne doit montrer cette imbrication plutôt
414
+ * que d'aligner des boîtes qui portent toutes le même nom. Une boîte sans nom d'agent est
415
+ * son propre groupe : rien ne permet de la rattacher à un projet.
416
+ *
417
+ * L'annuaire arrive déjà classé par activité : le groupe hérite du rang de sa boîte la
418
+ * plus récente, sans nouveau tri.
419
+ */
420
+ function groupByAgent(boxes) {
421
+ const groups = new Map();
422
+ for (const box of boxes) {
423
+ const key = box.agent_name || box.id;
424
+ if (!groups.has(key)) {
425
+ groups.set(key, { key, name: box.agent_name || boxLabel(box), boxes: [], unread: 0, filled: 0, activity: "" });
426
+ }
427
+ const group = groups.get(key);
428
+ group.boxes.push(box);
429
+ group.unread += box.unread;
430
+ if (!isEmptyBox(box)) group.filled += 1;
431
+ // L'agent hérite de l'activité de sa session la plus vivante.
432
+ if (activityOf(box) > group.activity) group.activity = activityOf(box);
433
+ }
434
+ const list = [...groups.values()];
435
+ for (const group of list) group.boxes.sort(byBox);
436
+ // Le classement est refait ici plutôt que laissé à l'ordre d'arrivée de l'annuaire :
437
+ // ce que la colonne promet ne doit pas dépendre du hasard d'un tri fait ailleurs. Un
438
+ // agent dont aucune session n'a rien échangé passe derrière les autres, comme ses boîtes.
439
+ list.sort(
440
+ (a, b) =>
441
+ compareEmpty(a.filled === 0, b.filled === 0) ||
442
+ compareActivity(a.activity, b.activity) ||
443
+ (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
444
+ );
445
+ return list;
446
+ }
447
+
448
+ /**
449
+ * Une boîte est vide tant que rien n'y est passé, ni envoi ni réception.
450
+ *
451
+ * Le serveur alloue une boîte à chaque session ouverte : beaucoup n'échangeront jamais
452
+ * rien et n'ont donc rien à dire à qui supervise. Une boîte vide n'a par construction
453
+ * aucun non-lu — `received` à zéro l'implique — la masquer ne cache donc jamais de
454
+ * courrier en attente.
455
+ */
456
+ function isEmptyBox(box) {
457
+ return box.sent + box.received === 0;
458
+ }
459
+
460
+ /** Dernière activité d'une boîte, ou chaîne vide quand elle n'a jamais rien fait. */
461
+ function activityOf(box) {
462
+ return box.last_activity_at || "";
463
+ }
464
+
465
+ /**
466
+ * Compare deux dates ISO, la plus récente d'abord.
467
+ *
468
+ * Les dates ISO se comparent comme du texte, et la chaîne vide — une boîte qui n'a jamais
469
+ * vécu — est plus petite que toute date : elle finit donc en queue, sans cas particulier.
470
+ */
471
+ function compareActivity(left, right) {
472
+ if (left === right) return 0;
473
+ return left < right ? 1 : -1;
474
+ }
475
+
476
+ /** Le plein avant le vide. Premier critère du classement, avant même la date. */
477
+ function compareEmpty(left, right) {
478
+ if (left === right) return 0;
479
+ return left ? 1 : -1;
480
+ }
481
+
482
+ /**
483
+ * Deux classements emboîtés : d'abord les boîtes qui ont servi, puis les vides ; dans
484
+ * chacun des deux, la plus récemment active en tête. À égalité, l'adresse départage, pour
485
+ * que l'ordre ne bouge pas d'un rafraîchissement à l'autre.
486
+ */
487
+ function byBox(a, b) {
488
+ return (
489
+ compareEmpty(isEmptyBox(a), isEmptyBox(b)) ||
490
+ compareActivity(activityOf(a), activityOf(b)) ||
491
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
492
+ );
493
+ }
494
+
495
+ function isSelected(kind, key) {
496
+ return state.selection.kind === kind && state.selection.key === key;
497
+ }
498
+
499
+ /**
500
+ * Choisit ce que la liste des fils montre.
501
+ *
502
+ * Recliquer sur la sélection courante ne la relâche pas : cliquer un agent veut dire
503
+ * « montre-moi cet agent », et le redire ne peut pas vouloir dire le contraire. On revient
504
+ * à la vue générale par « Tous les agents », en tête de colonne, là où on l'attend.
505
+ */
506
+ function select(kind, key) {
507
+ if (isSelected(kind, key)) return;
508
+ state.selection = { kind, key };
509
+ state.threadId = null;
510
+ refresh();
511
+ }
512
+
513
+ function renderAgents(overview) {
514
+ state.directory = new Map(overview.agents.map((box) => [box.id, box]));
515
+ state.groups = groupByAgent(overview.agents);
516
+ el.agentsFoot.textContent = [
517
+ plural(state.groups.length, "agent"),
518
+ plural(overview.agents.length, "boîte"),
519
+ plural(overview.threads, "fil"),
520
+ ].join(" · ");
521
+
522
+ el.agents.replaceChildren();
523
+ el.agents.appendChild(allRow(overview));
524
+ // Les agents sans le moindre échange sont derrière les autres dans `state.groups` :
525
+ // les montrer allonge la liste par la fin, sans jamais en réordonner le début.
526
+ const silent = state.groups.filter((group) => group.filled === 0).length;
527
+ for (const group of state.groups) {
528
+ if (group.filled === 0 && !state.showEmptyAgents) continue;
529
+ el.agents.appendChild(groupRow(group));
530
+ }
531
+ if (silent > 0) el.agents.appendChild(silentAgentsToggleRow(silent));
532
+ }
533
+
534
+ /**
535
+ * Dernière ligne de la colonne : ouvre ou referme les agents dont aucune session n'a rien
536
+ * échangé. Même geste que pour les boîtes vides d'un agent, un cran plus haut.
537
+ */
538
+ function silentAgentsToggleRow(silent) {
539
+ const item = node("li");
540
+ const what = silent > 1 ? `les ${silent} agents sans échange` : "l'agent sans échange";
541
+ const toggle = button(
542
+ "box-empty-toggle agents-empty-toggle",
543
+ `${state.showEmptyAgents ? "Masquer" : "Afficher"} ${what}`,
544
+ () => {
545
+ state.showEmptyAgents = !state.showEmptyAgents;
546
+ renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
547
+ }
548
+ );
549
+ toggle.setAttribute("aria-expanded", state.showEmptyAgents ? "true" : "false");
550
+ item.appendChild(toggle);
551
+ return item;
552
+ }
553
+
554
+ function allRow(overview) {
555
+ const item = node("li");
556
+ const unread = overview.agents.reduce((total, box) => total + box.unread, 0);
557
+ const row = node("button", `agent${isSelected("all", null) ? " is-selected" : ""}`);
558
+ row.type = "button";
559
+
560
+ const star = node("span", "avatar avatar--absent", "∗");
561
+ star.title = "Tous les agents";
562
+ row.appendChild(star);
563
+
564
+ const text = node("div", "agent-text");
565
+ text.appendChild(node("div", "agent-name", "Tous les agents"));
566
+ text.appendChild(node("div", "agent-box", plural(overview.threads, "fil")));
567
+ row.appendChild(text);
568
+ if (unread > 0) row.appendChild(node("span", "count count--accent", String(unread)));
569
+
570
+ row.addEventListener("click", () => select("all", null));
571
+ item.appendChild(row);
572
+ return item;
573
+ }
574
+
575
+ /**
576
+ * Un agent et, dépliées sous lui, ses boîtes.
577
+ *
578
+ * Deux zones de clic, deux gestes distincts et sans effet l'un sur l'autre : le chevron
579
+ * ouvre ou ferme, le nom filtre sur l'ensemble des sessions du projet. Filtrer ne déplie
580
+ * pas — un clic ne doit faire que ce qu'il annonce, et voir les sessions d'un agent est
581
+ * une autre question que ne lire que ses fils.
582
+ */
583
+ function groupRow(group) {
584
+ const item = node("li", "agent-group");
585
+ const open = state.expanded.has(group.key);
586
+
587
+ const header = node("div", `agent-head${open ? " is-open" : ""}`);
588
+
589
+ const toggle = node("button", "agent-toggle");
590
+ toggle.type = "button";
591
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
592
+ toggle.setAttribute("aria-label", `${open ? "Replier" : "Déplier"} les boîtes de ${group.name}`);
593
+ toggle.appendChild(icon("chevron"));
594
+ toggle.addEventListener("click", () => {
595
+ if (open) state.expanded.delete(group.key);
596
+ else state.expanded.add(group.key);
597
+ renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
598
+ });
599
+ header.appendChild(toggle);
600
+
601
+ const classes = ["agent"];
602
+ if (isSelected("agent", group.key)) classes.push("is-selected");
603
+ if (group.unread > 0) classes.push("has-unread");
604
+ if (group.boxes.every((box) => !box.registered)) classes.push("agent--muted");
605
+ const row = node("button", classes.join(" "));
606
+ row.type = "button";
607
+ row.appendChild(
608
+ avatarFor(
609
+ group.key,
610
+ group.name,
611
+ group.name,
612
+ null,
613
+ group.boxes.every((box) => !box.registered)
614
+ )
615
+ );
616
+
617
+ const empty = group.boxes.length - group.filled;
618
+ // Le décompte annonce ce que le dépli va montrer : sans quoi « 5 boîtes » suivi de deux
619
+ // lignes se lirait comme un bug plutôt que comme des boîtes vides mises de côté.
620
+ const counts = empty > 0 ? `${plural(group.filled, "boîte")} · ${plural(empty, "vide")}` : plural(group.filled, "boîte");
621
+
622
+ const text = node("div", "agent-text");
623
+ text.appendChild(node("div", "agent-name", group.name));
624
+ text.appendChild(node("div", "agent-box", counts));
625
+ row.appendChild(text);
626
+ if (group.unread > 0) row.appendChild(node("span", "count count--accent", String(group.unread)));
627
+ row.title = `${group.name} · ${counts}`;
628
+ row.addEventListener("click", () => select("agent", group.key));
629
+ header.appendChild(row);
630
+ item.appendChild(header);
631
+
632
+ if (open) {
633
+ const list = node("ul", "box-list");
634
+ // Les vides sont derrière dans `group.boxes` : les montrer, c'est allonger la liste,
635
+ // jamais en réordonner le début.
636
+ const revealed = state.showEmpty.has(group.key);
637
+ for (const box of group.boxes) {
638
+ if (isEmptyBox(box) && !revealed) continue;
639
+ list.appendChild(boxRow(box));
640
+ }
641
+ if (empty > 0) list.appendChild(emptyToggleRow(group, empty, revealed));
642
+ item.appendChild(list);
643
+ }
644
+ return item;
645
+ }
646
+
647
+ /** Dernière ligne du dépli : ouvre ou referme les boîtes qui n'ont jamais rien échangé. */
648
+ function emptyToggleRow(group, empty, revealed) {
649
+ const item = node("li");
650
+ const what = empty > 1 ? `les ${empty} boîtes vides` : "la boîte vide";
651
+ const toggle = button("box-empty-toggle", `${revealed ? "Masquer" : "Afficher"} ${what}`, () => {
652
+ if (revealed) state.showEmpty.delete(group.key);
653
+ else state.showEmpty.add(group.key);
654
+ renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
655
+ });
656
+ toggle.setAttribute("aria-expanded", revealed ? "true" : "false");
657
+ item.appendChild(toggle);
658
+ return item;
659
+ }
660
+
661
+ function boxRow(box) {
662
+ const item = node("li");
663
+ const classes = ["agent", "box"];
664
+ if (isEmptyBox(box)) classes.push("is-empty");
665
+ if (isSelected("box", box.id)) classes.push("is-selected");
666
+ if (box.unread > 0) classes.push("has-unread");
667
+ if (!box.registered) classes.push("agent--muted");
668
+ const row = node("button", classes.join(" "));
669
+ row.type = "button";
670
+
671
+ const label = boxLabel(box);
672
+ row.appendChild(avatarFor(box.id, label, box.id, "avatar--sm", !box.registered));
673
+
674
+ const text = node("div", "agent-text");
675
+ // Sans nom donné par l'agent, l'adresse tient lieu de nom : elle est alors la seule
676
+ // chose qui distingue cette session de ses sœurs.
677
+ const name = node("div", "agent-name", label);
678
+ if (!box.display_name) name.classList.add("mono", "is-absent");
679
+ text.appendChild(name);
680
+ // Les sessions d'un agent sont classées sur cette date : sans elle, leur ordre
681
+ // paraîtrait arbitraire. Les compteurs passent dans l'info-bulle, faute de place.
682
+ const activity = box.last_activity_at
683
+ ? `activité ${formatShort(box.last_activity_at)}`
684
+ : box.registered
685
+ ? "aucune activité"
686
+ : "jamais connectée";
687
+ const meta = node("div", "agent-box", activity);
688
+ if (!box.last_activity_at) meta.classList.add("is-absent");
689
+ text.appendChild(meta);
690
+ row.appendChild(text);
691
+
692
+ if (box.unread > 0) row.appendChild(node("span", "count count--accent", String(box.unread)));
693
+ row.title = `${box.id}\n${plural(box.sent, "envoyé")} · ${plural(box.received, "reçu")}`;
694
+ row.addEventListener("click", () => select("box", box.id));
695
+
696
+ // La ligne et son bouton de suppression sont deux boutons voisins, jamais imbriqués :
697
+ // un bouton dans un bouton n'est pas du HTML, et le clic n'y serait pas dissociable.
698
+ const line = node("div", "box-line");
699
+ line.appendChild(row);
700
+ line.appendChild(deleteBoxButton(box));
701
+ item.appendChild(line);
702
+ return item;
703
+ }
704
+
705
+ /**
706
+ * Suppression d'une boîte, à la main.
707
+ *
708
+ * Le bouton n'efface rien : il ouvre la fenêtre de nettoyage sur cette seule boîte, avec
709
+ * ce qu'elle contient sous les yeux et la même double confirmation que les nettoyages en
710
+ * lot. Un geste destructeur à un clic d'une liste ne peut pas être plus court que ça.
711
+ */
712
+ function deleteBoxButton(box) {
713
+ const label = `Supprimer la boîte ${boxLabel(box)}`;
714
+ const remove = node("button", "box-delete");
715
+ remove.type = "button";
716
+ remove.title = label;
717
+ remove.setAttribute("aria-label", label);
718
+ remove.appendChild(icon("trash"));
719
+ remove.addEventListener("click", (event) => {
720
+ event.stopPropagation();
721
+ openPurgeFor(box);
722
+ });
723
+ return remove;
724
+ }
725
+
726
+ /** Nombre de fils affiché en pied de colonne, conservé entre deux rendus locaux. */
727
+ function currentThreadCount() {
728
+ return state.threadCount || 0;
729
+ }
730
+
731
+ /** Adresses que la sélection courante recouvre, ou null quand elle ne filtre rien. */
732
+ function selectedAddresses() {
733
+ if (state.selection.kind === "box") return [state.selection.key];
734
+ if (state.selection.kind === "agent") {
735
+ const group = state.groups.find((g) => g.key === state.selection.key);
736
+ return group ? group.boxes.map((box) => box.id) : [];
737
+ }
738
+ return null;
739
+ }
740
+
741
+ function selectionLabel() {
742
+ if (state.selection.kind === "box") {
743
+ const box = state.directory.get(state.selection.key);
744
+ return box ? boxLabel(box) : shortAddress(state.selection.key);
745
+ }
746
+ if (state.selection.kind === "agent") return state.selection.key;
747
+ return "Toutes les boîtes";
748
+ }
749
+
750
+ // --- Colonne des fils ------------------------------------------------------
751
+
752
+ function renderThreads(threads) {
753
+ state.threadCount = threads.length;
754
+ renderThreadsHead(threads.length);
755
+
756
+ el.threads.replaceChildren();
757
+ if (threads.length === 0) {
758
+ const empty = node("li", "empty");
759
+ empty.appendChild(node("span", "empty-title", "Aucun fil"));
760
+ empty.appendChild(document.createTextNode(emptyReason()));
761
+ el.threads.appendChild(empty);
762
+ return;
763
+ }
764
+ for (const thread of threads) el.threads.appendChild(threadRow(thread));
765
+ }
766
+
767
+ /**
768
+ * En-tête de la liste : ce sur quoi elle est filtrée, en grand.
769
+ *
770
+ * Sans filtre, elle s'intitule « Fils » et rien d'autre. Dès qu'un agent est choisi, c'est
771
+ * lui le sujet : sa pastille et son nom prennent la place du titre, et la ligne en dessous
772
+ * dit ce que le filtre recouvre — toutes ses sessions, ou une seule.
773
+ */
774
+ function renderThreadsHead(count) {
775
+ const { kind, key } = state.selection;
776
+ const fils = plural(count, "fil");
777
+
778
+ if (kind === "all") {
779
+ el.threadsAvatar.hidden = true;
780
+ el.threadsAvatar.replaceChildren();
781
+ el.threadsName.textContent = "Fils";
782
+ el.threadsScope.textContent = `Toutes les boîtes · ${fils}`;
783
+ return;
784
+ }
785
+
786
+ // Une boîte est d'abord une session de son agent : c'est donc l'agent qui s'affiche,
787
+ // et le nom de la boîte qui précise de quelle session il s'agit.
788
+ const address = kind === "box" ? key : (state.groups.find((g) => g.key === key)?.boxes[0] || {}).id;
789
+ const who = address ? party(address) : null;
790
+ const sessions = kind === "agent" ? (state.groups.find((g) => g.key === key)?.boxes.length ?? 0) : 0;
791
+ const box = kind === "box" ? state.directory.get(key) : null;
792
+
793
+ el.threadsAvatar.hidden = false;
794
+ el.threadsAvatar.replaceChildren(
795
+ address ? agentAvatar(address, "avatar--lg") : avatarFor(key, key, key, "avatar--lg", false)
796
+ );
797
+ el.threadsName.textContent = kind === "agent" ? key : who?.agentName || selectionLabel();
798
+ el.threadsScope.textContent =
799
+ kind === "agent"
800
+ ? `${plural(sessions, "session")} · ${fils}`
801
+ : `${box ? boxLabel(box) : shortAddress(key)} · ${fils}`;
802
+ }
803
+
804
+ function emptyReason() {
805
+ if (state.search) return "Aucun fil ne correspond à cette recherche.";
806
+ if (state.filter === "unread") return "Tout est lu de ce côté.";
807
+ if (state.filter === "high") return "Aucun message en priorité haute.";
808
+ if (state.selection.kind === "box") return "Cette boîte n'a encore échangé avec personne.";
809
+ if (state.selection.kind === "agent") return "Aucune session de cet agent n'a encore échangé.";
810
+ return "Aucun agent n'a encore écrit sur ce serveur.";
811
+ }
812
+
813
+ function threadRow(thread) {
814
+ const item = node("li");
815
+ const classes = ["thread"];
816
+ if (state.threadId === thread.thread_id) classes.push("is-selected");
817
+ if (thread.unread > 0) classes.push("is-unread");
818
+ const row = node("button", classes.join(" "));
819
+ row.type = "button";
820
+
821
+ const top = node("div", "thread-top");
822
+ // Le titre du fil prime ; tant que personne ne l'a nommé, le sujet d'ouverture en tient lieu.
823
+ top.appendChild(node("span", "thread-title", thread.title || thread.subject));
824
+ if (thread.priority === "high") top.appendChild(tag("tag tag--high", "haute"));
825
+ top.appendChild(node("span", "thread-time", formatShort(thread.last_message_at)));
826
+ row.appendChild(top);
827
+
828
+ row.appendChild(threadParticipants(thread));
829
+
830
+ const bottom = node("div", "thread-bottom");
831
+ bottom.appendChild(node("span", "thread-preview", thread.last_message_preview));
832
+ const count = node("span", thread.unread > 0 ? "count count--accent" : "count", String(thread.messages));
833
+ count.title = thread.unread > 0 ? `${plural(thread.messages, "message")}, ${thread.unread} non lu(s)` : plural(thread.messages, "message");
834
+ bottom.appendChild(count);
835
+ row.appendChild(bottom);
836
+
837
+ row.addEventListener("click", () => openThread(thread.thread_id));
838
+ item.appendChild(row);
839
+ return item;
840
+ }
841
+
842
+ /**
843
+ * Qui parle dans ce fil : les pastilles d'abord, les noms ensuite.
844
+ *
845
+ * Un fil lie exactement deux boîtes, la pastille suffit donc à les distinguer d'un coup
846
+ * d'œil, sans compter ni déborder. Chaque pastille garde la couleur de son adresse d'un
847
+ * écran à l'autre, et dit au survol tout ce qu'on peut vouloir savoir de la boîte : le
848
+ * projet d'où elle écrit, ce dont cette session s'occupe, son adresse, et si c'est elle
849
+ * qui a écrit en dernier.
850
+ */
851
+ function threadParticipants(thread) {
852
+ const who = node("div", "thread-who");
853
+
854
+ const stack = node("div", "avatar-stack");
855
+ for (const id of thread.participants) {
856
+ const last = id === thread.last_message_from ? "\na écrit le dernier message" : "";
857
+ stack.appendChild(agentAvatar(id, "avatar--sm avatar--stack", `${boxTitle(id)}${last}`));
858
+ }
859
+ who.appendChild(stack);
860
+
861
+ who.appendChild(node("span", "thread-names", thread.participants.map((id) => party(id).name).join(" · ")));
862
+ return who;
863
+ }
864
+
865
+ // --- Lecteur du fil --------------------------------------------------------
866
+
867
+ function renderEmptyReader() {
868
+ const empty = node("div", "empty");
869
+ empty.appendChild(node("span", "empty-title", "Aucun fil ouvert"));
870
+ empty.appendChild(document.createTextNode("Choisissez un fil pour lire la conversation."));
871
+ el.reader.replaceChildren(empty);
872
+ }
873
+
874
+ function renderThread(data) {
875
+ const thread = data.thread || {};
876
+ const messages = data.messages || [];
877
+ const container = document.createDocumentFragment();
878
+
879
+ const head = node("div", "reader-head");
880
+ const opening = messages.length > 0 ? messages[0].subject : "";
881
+ head.appendChild(node("h2", null, thread.title || opening || data.thread_id));
882
+ if (thread.description) head.appendChild(node("p", "reader-desc", thread.description));
883
+
884
+ const participants = [];
885
+ for (const message of messages) {
886
+ for (const id of [message.from, message.to]) {
887
+ if (id && !participants.includes(id)) participants.push(id);
888
+ }
889
+ }
890
+
891
+ const meta = node("div", "reader-meta");
892
+ const stack = node("div", "avatar-stack");
893
+ // Le projet d'abord : deux sessions d'un même agent portent la même pastille, le nom
894
+ // de la boîte se lisant au survol.
895
+ for (const id of participants) stack.appendChild(agentAvatar(id, "avatar--sm avatar--stack"));
896
+ meta.appendChild(stack);
897
+ meta.appendChild(
898
+ node(
899
+ "span",
900
+ "reader-participants",
901
+ `${participants.map(agentLabel).join(", ")} · ${plural(messages.length, "message")}`
902
+ )
903
+ );
904
+ const threadId = node("span", "mono muted", data.thread_id);
905
+ meta.appendChild(threadId);
906
+ meta.appendChild(copyButton(data.thread_id, "Copier l'identifiant du fil"));
907
+ head.appendChild(meta);
908
+ container.appendChild(head);
909
+
910
+ const list = node("ul", "messages");
911
+ messages.forEach((message, index) => {
912
+ list.appendChild(messageRow(message, index === messages.length - 1));
913
+ });
914
+ container.appendChild(list);
915
+
916
+ el.reader.replaceChildren(container);
917
+ }
918
+
919
+ /**
920
+ * Un message du fil, replié ou déplié.
921
+ *
922
+ * Un fil se relit d'abord de haut en bas : ce qui doit sauter aux yeux est qui a écrit,
923
+ * quand, et sur quoi. Les corps sont donc repliés au départ, chaque message tenant sur sa
924
+ * ligne de titre, et c'est cette ligne qui l'ouvre. L'état est retenu par identifiant de
925
+ * message : le rafraîchissement automatique ne referme donc pas ce qu'on est en train de
926
+ * lire.
927
+ */
928
+ function messageRow(message, isLast) {
929
+ const item = node("li");
930
+ fillMessage(item, message, isLast);
931
+ return item;
932
+ }
933
+
934
+ /**
935
+ * Remplit la ligne d'un message, dépliée ou non.
936
+ *
937
+ * Le nœud est donné plutôt que rendu : ouvrir un message ne redessine que lui, et c'est
938
+ * bien cette ligne-là, celle qui est dans la page, qu'il faut réécrire — en reconstruire
939
+ * une autre reviendrait à modifier un élément que personne ne regarde.
940
+ */
941
+ function fillMessage(item, message, isLast) {
942
+ const open = state.openMessages.has(message.message_id);
943
+ const classes = ["message"];
944
+ if (open) classes.push("is-expanded");
945
+ if (isLast) classes.push("is-last");
946
+ const row = node("div", classes.join(" "));
947
+ const column = node("div", "message-body-col");
948
+
949
+ /*
950
+ Tout ce qui est visible d'un message replié est cliquable, pastille comprise : le
951
+ bandeau est le bouton, et le survol le grise pour qu'on le voie comme tel. Pas de
952
+ chevron — sur une ligne qui s'annonce déjà comme une cible, il n'apprend rien.
953
+ */
954
+ const head = node("button", "message-head");
955
+ head.type = "button";
956
+ head.setAttribute("aria-expanded", open ? "true" : "false");
957
+ head.appendChild(agentAvatar(message.from));
958
+
959
+ const identity = node("span", "message-identity");
960
+ identity.appendChild(node("span", "message-from", agentLabel(message.from)));
961
+ if (message.priority === "high") identity.appendChild(tag("tag tag--high", "priorité haute"));
962
+ if (message.priority === "low") identity.appendChild(tag("tag", "priorité basse"));
963
+ if (!message.read) {
964
+ const dot = node("span", "message-dot");
965
+ dot.title = `non lu par ${agentLabel(message.to)}`;
966
+ identity.appendChild(dot);
967
+ }
968
+ identity.appendChild(node("span", "message-time", formatShort(message.created_at)));
969
+ identity.appendChild(detailsButton(message));
970
+ head.appendChild(identity);
971
+
972
+ // L'objet, toujours : c'est le titre du message, et c'est lui qu'on cherche du regard.
973
+ // Le bandeau ne bouge donc pas quand le message s'ouvre, seul le corps vient dessous.
974
+ head.appendChild(node("span", "message-subject", message.subject || "sans objet"));
975
+
976
+ head.addEventListener("click", () => {
977
+ if (open) state.openMessages.delete(message.message_id);
978
+ else state.openMessages.add(message.message_id);
979
+ fillMessage(item, message, isLast);
980
+ });
981
+ column.appendChild(head);
982
+
983
+ if (!open) {
984
+ row.appendChild(column);
985
+ item.replaceChildren(row);
986
+ return;
987
+ }
988
+
989
+ // Ce qui suit la ligne de titre s'aligne sous le texte, pas sous la pastille.
990
+ const content = node("div", "message-content");
991
+ column.appendChild(content);
992
+
993
+ content.appendChild(messageText(message));
994
+ if (message.attachments && message.attachments.length > 0) {
995
+ content.appendChild(attachmentList(message.attachments));
996
+ }
997
+
998
+ const foot = node("div", "message-foot");
999
+ foot.appendChild(
1000
+ message.read
1001
+ ? tag("tag tag--ok", `lu par ${agentLabel(message.to)}`, "check")
1002
+ : tag("tag tag--warn", `non lu par ${agentLabel(message.to)}`, "clock")
1003
+ );
1004
+ content.appendChild(foot);
1005
+
1006
+ row.appendChild(column);
1007
+ item.replaceChildren(row);
1008
+ }
1009
+
1010
+ /**
1011
+ * Le corps d'un message, rendu comme du markdown : c'est la langue dans laquelle les
1012
+ * agents s'écrivent. L'aperçu, lui, arrive déjà débarrassé de sa syntaxe par le serveur
1013
+ * et reste donc du texte simple.
1014
+ */
1015
+ function messageText(message) {
1016
+ if (!message.body) return node("p", "message-text", message.preview || "");
1017
+ const container = node("div", "message-text markdown");
1018
+ container.appendChild(renderMarkdown(message.body));
1019
+ return container;
1020
+ }
1021
+
1022
+ /** Pièces jointes d'un message : un lien de téléchargement par fichier. */
1023
+ function attachmentList(attachments) {
1024
+ const list = node("ul", "attachments");
1025
+ for (const attachment of attachments) {
1026
+ const item = node("li");
1027
+ const link = node("a", "attachment");
1028
+ link.href = `/admin/attachments/${encodeURIComponent(attachment.attachment_id)}`;
1029
+ link.download = attachment.filename;
1030
+ link.title = attachment.mime_type;
1031
+ link.appendChild(icon("paperclip"));
1032
+ link.appendChild(node("span", "attachment-name", attachment.filename));
1033
+ link.appendChild(node("span", "attachment-size", formatSize(attachment.size)));
1034
+ item.appendChild(link);
1035
+ list.appendChild(item);
1036
+ }
1037
+ return list;
1038
+ }
1039
+
1040
+ function formatSize(bytes) {
1041
+ if (bytes < 1024) return `${bytes} o`;
1042
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
1043
+ return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
1044
+ }
1045
+
1046
+ // --- Détails d'un message --------------------------------------------------
1047
+
1048
+ /**
1049
+ * Bouton d'information, au bout de la ligne.
1050
+ *
1051
+ * Un fil ne lie que deux boîtes : répéter à chaque message qui écrit à qui, sous quel nom
1052
+ * de boîte, encombre la lecture pour une information déjà connue. Elle est donc rangée
1053
+ * ici, à un clic, avec tout le reste — adresses, identifiants, distribution.
1054
+ */
1055
+ function detailsButton(message) {
1056
+ const open = node("button", "message-info");
1057
+ open.type = "button";
1058
+ open.title = "Détails du message";
1059
+ open.setAttribute("aria-label", "Détails du message");
1060
+ open.appendChild(icon("info"));
1061
+ // Le bandeau entier ouvre le message : ce clic-ci ne doit pas le replier au passage.
1062
+ open.addEventListener("click", (event) => {
1063
+ event.stopPropagation();
1064
+ openDetails(message.message_id);
1065
+ });
1066
+ return open;
1067
+ }
1068
+
1069
+ async function openDetails(messageId) {
1070
+ el.detailsBody.replaceChildren(node("p", "muted", "Chargement…"));
1071
+ el.details.showModal();
1072
+ try {
1073
+ renderDetails(await api(`/admin/messages/${encodeURIComponent(messageId)}`));
1074
+ } catch (error) {
1075
+ if (error.message === "unauthorized") el.details.close();
1076
+ else el.detailsBody.replaceChildren(node("p", "purge-error", `Erreur : ${error.message}`));
1077
+ }
1078
+ }
1079
+
1080
+ /** Tout ce que la conversation ne montre pas, et que la supervision peut vouloir lire. */
1081
+ function renderDetails(message) {
1082
+ const container = document.createDocumentFragment();
1083
+
1084
+ const head = node("div", "modal-head");
1085
+ const titles = node("div");
1086
+ titles.appendChild(node("h2", null, "Détails du message"));
1087
+ titles.appendChild(node("p", "details-subject", message.subject));
1088
+ head.appendChild(titles);
1089
+ const close = node("button", "btn btn--icon btn--ghost");
1090
+ close.type = "button";
1091
+ close.title = "Fermer";
1092
+ close.setAttribute("aria-label", "Fermer les détails");
1093
+ close.appendChild(icon("close"));
1094
+ close.addEventListener("click", () => el.details.close());
1095
+ head.appendChild(close);
1096
+ container.appendChild(head);
1097
+
1098
+ const ids = node("div", "details-section");
1099
+ ids.appendChild(node("span", "section-label", "Message"));
1100
+ ids.appendChild(kvCopy("Identifiant", message.message_id));
1101
+ ids.appendChild(kv("Objet", node("span", "kv-value", message.subject)));
1102
+ ids.appendChild(kv("Envoyé le", node("span", "kv-value", formatDate(message.created_at))));
1103
+ ids.appendChild(kv("Priorité", priorityTag(message.priority)));
1104
+ container.appendChild(ids);
1105
+
1106
+ const thread = node("div", "details-section");
1107
+ thread.appendChild(node("span", "section-label", "Fil"));
1108
+ // Sans nom donné par un participant, le fil n'a que son identifiant et le sujet d'ouverture.
1109
+ thread.appendChild(kv("Nom", node("span", "kv-value", message.thread_title || "fil sans nom")));
1110
+ thread.appendChild(kvCopy("Identifiant", message.thread_id));
1111
+ container.appendChild(thread);
1112
+
1113
+ container.appendChild(partySection("De", message.from));
1114
+ container.appendChild(partySection("À", message.to));
1115
+
1116
+ const deliveries = node("div", "details-section");
1117
+ deliveries.appendChild(node("span", "section-label", "Distribution"));
1118
+ deliveries.appendChild(deliveriesTable(message.deliveries || []));
1119
+ container.appendChild(deliveries);
1120
+
1121
+ el.detailsBody.replaceChildren(container);
1122
+ }
1123
+
1124
+ function kv(key, valueNode) {
1125
+ const row = node("div", "kv");
1126
+ row.appendChild(node("span", "kv-key", key));
1127
+ row.appendChild(valueNode);
1128
+ row.appendChild(node("span"));
1129
+ return row;
1130
+ }
1131
+
1132
+ function kvCopy(key, value) {
1133
+ const row = node("div", "kv");
1134
+ row.appendChild(node("span", "kv-key", key));
1135
+ row.appendChild(node("span", "kv-value mono", value));
1136
+ row.appendChild(copyButton(value, `Copier ${key.toLowerCase()}`));
1137
+ return row;
1138
+ }
1139
+
1140
+ function priorityTag(priority) {
1141
+ const labels = { high: "haute", normal: "normale", low: "basse" };
1142
+ return tag(priority === "high" ? "tag tag--high" : "tag", labels[priority] || priority);
1143
+ }
1144
+
1145
+ /**
1146
+ * Une boîte, telle qu'elle doit se lire : le nom de l'agent, ce dont la session s'occupe,
1147
+ * et seulement ensuite l'adresse — la seule chose qui identifie vraiment la session.
1148
+ */
1149
+ function partySection(label, agentId) {
1150
+ const who = party(agentId);
1151
+ const section = node("div", "details-section");
1152
+ section.appendChild(node("span", "section-label", label));
1153
+
1154
+ const block = node("div", "party");
1155
+ block.appendChild(avatar(agentId, "avatar--sm"));
1156
+
1157
+ const text = node("div", "agent-text");
1158
+ text.appendChild(node("div", "party-name", who.name));
1159
+ text.appendChild(node("div", "party-box", who.box || (who.registered ? "boîte sans nom" : "jamais connectée")));
1160
+ const addr = node("div", "party-addr");
1161
+ addr.appendChild(node("span", "mono", agentId));
1162
+ addr.appendChild(copyButton(agentId, "Copier l'adresse"));
1163
+ text.appendChild(addr);
1164
+
1165
+ block.appendChild(text);
1166
+ section.appendChild(block);
1167
+ return section;
1168
+ }
1169
+
1170
+ function deliveriesTable(deliveries) {
1171
+ const table = node("table", "deliveries");
1172
+ const head = node("tr");
1173
+ for (const label of ["Agent", "Rôle", "Dossier", "État"]) head.appendChild(node("th", null, label));
1174
+ table.appendChild(head);
1175
+
1176
+ for (const delivery of deliveries) {
1177
+ const row = node("tr");
1178
+ const who = node("td");
1179
+ who.appendChild(document.createTextNode(party(delivery.agent_id).name));
1180
+ row.appendChild(who);
1181
+ row.appendChild(node("td", "role", delivery.role === "sender" ? "expéditeur" : "destinataire"));
1182
+ row.appendChild(node("td", "folder", delivery.folder));
1183
+ const stateCell = node("td");
1184
+ stateCell.appendChild(
1185
+ delivery.read ? tag("tag tag--ok", "lu") : tag("tag tag--warn", delivery.role === "sender" ? "—" : "non lu")
1186
+ );
1187
+ row.appendChild(stateCell);
1188
+ table.appendChild(row);
1189
+ }
1190
+ return table;
1191
+ }
1192
+
1193
+ // --- Navigation ------------------------------------------------------------
1194
+
1195
+ async function openThread(threadId) {
1196
+ state.threadId = threadId;
1197
+ // Un fil s'ouvre replié, même celui qu'on rouvre : ce qui était déplié l'était pour
1198
+ // l'autre conversation.
1199
+ state.openMessages.clear();
1200
+ await refreshThread();
1201
+ await refresh({ keepThread: true });
1202
+ }
1203
+
1204
+ async function refreshThread() {
1205
+ if (!state.threadId) {
1206
+ renderEmptyReader();
1207
+ return;
1208
+ }
1209
+ try {
1210
+ renderThread(await api(`/admin/threads/${encodeURIComponent(state.threadId)}`));
1211
+ } catch (error) {
1212
+ if (error.message !== "unauthorized") el.reader.replaceChildren(node("div", "banner", error.message));
1213
+ }
1214
+ }
1215
+
1216
+ // --- Boucle de rafraîchissement --------------------------------------------
1217
+
1218
+ async function refresh(options) {
1219
+ if (state.view === "links") {
1220
+ await refreshLinks();
1221
+ return;
1222
+ }
1223
+ try {
1224
+ const [overview, list, links] = await Promise.all([
1225
+ api("/admin/overview"),
1226
+ api("/admin/threads", {
1227
+ // Une sélection d'agent recouvre toutes ses sessions : la route prend la liste.
1228
+ agent: (selectedAddresses() || []).join(","),
1229
+ search: state.search,
1230
+ unread_only: state.filter === "unread" ? "true" : undefined,
1231
+ priority: state.filter === "high" ? "high" : undefined,
1232
+ limit: 100,
1233
+ }),
1234
+ api("/admin/links"),
1235
+ ]);
1236
+ setLoggedIn(true);
1237
+ setStatus("ok", `connecté · ${window.location.host}`);
1238
+ renderAgents(overview);
1239
+ state.links = links.links;
1240
+ renderLinksBadge();
1241
+ renderThreads(list.threads);
1242
+ if (!options || !options.keepThread) {
1243
+ if (state.threadId) await refreshThread();
1244
+ else renderEmptyReader();
1245
+ }
1246
+ } catch (error) {
1247
+ if (error.message === "unauthorized") {
1248
+ setStatus("idle", "authentification requise");
1249
+ return;
1250
+ }
1251
+ setStatus("ko", `erreur : ${error.message}`);
1252
+ }
1253
+ }
1254
+
1255
+ // --- Largeur des panneaux --------------------------------------------------
1256
+
1257
+ /**
1258
+ * Ce que chaque séparateur commande.
1259
+ *
1260
+ * `edge` dit de quel côté se trouve le panneau réglé : ici, chacun borde le panneau qui
1261
+ * le précède. La distinction reste écrite car un panneau ancré à droite se réglerait à
1262
+ * l'envers, en comptant depuis son bord droit.
1263
+ */
1264
+ const PANELS = {
1265
+ "resize-agents": { name: "--w-agents", panel: "agentsPane", edge: "before", min: 180, max: 520, initial: 240 },
1266
+ "resize-threads": { name: "--w-threads", panel: "threadsPane", edge: "before", min: 260, max: 720, initial: 400 },
1267
+ };
1268
+
1269
+ const LAYOUT_KEY = "messenger.layout";
1270
+
1271
+ function setPanelWidth(id, pixels) {
1272
+ const panel = PANELS[id];
1273
+ const width = Math.round(Math.min(panel.max, Math.max(panel.min, pixels)));
1274
+ // Passer par le CSSOM plutôt que par un attribut `style` : la CSP interdit le second.
1275
+ el.console.style.setProperty(panel.name, `${width}px`);
1276
+ return width;
1277
+ }
1278
+
1279
+ function panelWidth(id) {
1280
+ const current = parseInt(el.console.style.getPropertyValue(PANELS[id].name), 10);
1281
+ return Number.isFinite(current) ? current : PANELS[id].initial;
1282
+ }
1283
+
1284
+ /** Les largeurs suivent l'utilisateur d'une visite à l'autre : elles vivent dans son navigateur. */
1285
+ function saveLayout() {
1286
+ try {
1287
+ const widths = {};
1288
+ for (const id of Object.keys(PANELS)) widths[id] = panelWidth(id);
1289
+ window.localStorage.setItem(LAYOUT_KEY, JSON.stringify(widths));
1290
+ } catch {
1291
+ // Navigation privée, stockage refusé : la mise en page reste bonne pour cette visite.
1292
+ }
1293
+ }
1294
+
1295
+ function loadLayout() {
1296
+ let saved = null;
1297
+ try {
1298
+ saved = JSON.parse(window.localStorage.getItem(LAYOUT_KEY) || "null");
1299
+ } catch {
1300
+ saved = null;
1301
+ }
1302
+ if (!saved) return;
1303
+ for (const [id, width] of Object.entries(saved)) {
1304
+ // Une largeur venue du stockage est une donnée comme une autre : elle est bornée
1305
+ // comme si elle sortait d'un glissé, sinon une valeur aberrante casserait la page.
1306
+ if (PANELS[id] && Number.isFinite(Number(width))) setPanelWidth(id, Number(width));
1307
+ }
1308
+ }
1309
+
1310
+ function startResize(event, id) {
1311
+ const panel = PANELS[id];
1312
+ const handle = document.getElementById(id);
1313
+ const rect = el[panel.panel].getBoundingClientRect();
1314
+ const measure = (x) => (panel.edge === "before" ? x - rect.left : rect.right - x);
1315
+
1316
+ el.console.classList.add("is-resizing");
1317
+ handle.classList.add("is-active");
1318
+ handle.setPointerCapture?.(event.pointerId);
1319
+
1320
+ const onMove = (move) => setPanelWidth(id, measure(move.clientX));
1321
+ const onUp = () => {
1322
+ window.removeEventListener("pointermove", onMove);
1323
+ window.removeEventListener("pointerup", onUp);
1324
+ el.console.classList.remove("is-resizing");
1325
+ handle.classList.remove("is-active");
1326
+ saveLayout();
1327
+ };
1328
+ window.addEventListener("pointermove", onMove);
1329
+ window.addEventListener("pointerup", onUp);
1330
+ }
1331
+
1332
+ /** Au clavier, les flèches déplacent le séparateur ; le pas est plus large avec Maj. */
1333
+ function nudgeResize(event, id) {
1334
+ const step = (event.shiftKey ? 48 : 16) * (event.key === "ArrowLeft" ? -1 : 1);
1335
+ const towards = PANELS[id].edge === "before" ? step : -step;
1336
+ setPanelWidth(id, panelWidth(id) + towards);
1337
+ saveLayout();
1338
+ }
1339
+
1340
+ for (const id of Object.keys(PANELS)) {
1341
+ const handle = document.getElementById(id);
1342
+ handle.addEventListener("pointerdown", (event) => {
1343
+ event.preventDefault();
1344
+ startResize(event, id);
1345
+ });
1346
+ // Un double-clic rend sa largeur d'origine : c'est le moyen de sortir d'un réglage raté.
1347
+ handle.addEventListener("dblclick", () => {
1348
+ setPanelWidth(id, PANELS[id].initial);
1349
+ saveLayout();
1350
+ });
1351
+ handle.addEventListener("keydown", (event) => {
1352
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
1353
+ event.preventDefault();
1354
+ nudgeResize(event, id);
1355
+ });
1356
+ }
1357
+
1358
+ loadLayout();
1359
+
1360
+ // --- Liaisons --------------------------------------------------------------
1361
+
1362
+ /**
1363
+ * Carte des liaisons : une colonne par agent, celle dont la dernière boîte est la plus
1364
+ * récente à gauche ; en hauteur, l'heure exacte de création de chaque boîte, la plus
1365
+ * récente en haut. Les cinq dernières minutes occupent la plus grande part de la hauteur,
1366
+ * le reste de la fenêtre détaillée est resserré. Au-delà de la fenêtre, les boîtes sont
1367
+ * empilées en liste sous leur agent.
1368
+ */
1369
+ const LINKS_WINDOW_KEY = "messenger.links.window";
1370
+ const LINKS_WINDOWS = [5, 10, 15, 30, 60, 120, 240];
1371
+ /** Âge en deçà duquel une boîte est signalée comme fraîche. */
1372
+ const FRESH_MINUTES = 5;
1373
+ const MAP = {
1374
+ axis: 92,
1375
+ lane: 180,
1376
+ gap: 12,
1377
+ head: 56,
1378
+ card: 46,
1379
+ cardGap: 4,
1380
+ /** Hauteur de la fenêtre détaillée, quelle que soit sa durée. */
1381
+ timePx: 480,
1382
+ oldRow: 30,
1383
+ oldShown: 5,
1384
+ /** Boîtes plus anciennes que la fenêtre proposées dans le volet avant « Afficher ». */
1385
+ oldCandidates: 8,
1386
+ };
1387
+
1388
+ const linksView = {
1389
+ windowMinutes: 30,
1390
+ /** Adresse de la boîte choisie sur la carte. */
1391
+ selected: null,
1392
+ /** Boîte pour laquelle le volet est construit : ses champs de saisie survivent aux rafraîchissements. */
1393
+ panelFor: null,
1394
+ search: "",
1395
+ note: "",
1396
+ /** Colonnes dont la liste des boîtes anciennes est entièrement dépliée. */
1397
+ showOld: new Set(),
1398
+ showAllCandidates: false,
1399
+ /** Liaison dont la suppression attend son second clic. */
1400
+ armedUnlink: null,
1401
+ /** Glissé en cours depuis la poignée d'une boîte. */
1402
+ drag: null,
1403
+ /** Boîte dont le nom est en cours de saisie sur la carte. */
1404
+ renaming: null,
1405
+ /** Refus du serveur pour le dernier nom proposé : la saisie reste ouverte. */
1406
+ renameError: "",
1407
+ /** Dernier clic sur une boîte : un second clic rapproché sur la même ouvre la saisie. */
1408
+ lastClick: null,
1409
+ /** Position de chaque boîte affichée sur la carte, par adresse. */
1410
+ rects: new Map(),
1411
+ error: "",
1412
+ };
1413
+
1414
+ function loadLinksWindow() {
1415
+ let saved = null;
1416
+ try {
1417
+ saved = Number(window.localStorage.getItem(LINKS_WINDOW_KEY));
1418
+ } catch {
1419
+ saved = null;
1420
+ }
1421
+ if (LINKS_WINDOWS.includes(saved)) linksView.windowMinutes = saved;
1422
+ el.linksWindow.value = String(linksView.windowMinutes);
1423
+ }
1424
+
1425
+ function saveLinksWindow() {
1426
+ try {
1427
+ window.localStorage.setItem(LINKS_WINDOW_KEY, String(linksView.windowMinutes));
1428
+ } catch {
1429
+ // Stockage refusé : le réglage vaut pour cette visite.
1430
+ }
1431
+ }
1432
+
1433
+ /** Heure à la seconde : deux boîtes ouvertes dans la même minute doivent se distinguer. */
1434
+ function formatClock(value) {
1435
+ const date = new Date(value);
1436
+ return Number.isNaN(date.getTime())
1437
+ ? String(value)
1438
+ : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
1439
+ }
1440
+
1441
+ /** Âge lisible : « 38 s », « 4 min », « 2 h 05 ». */
1442
+ function formatAge(ms) {
1443
+ const seconds = Math.max(0, Math.floor(ms / 1000));
1444
+ if (seconds < 60) return `${seconds} s`;
1445
+ const minutes = Math.floor(seconds / 60);
1446
+ if (minutes < 60) return `${minutes} min`;
1447
+ const hours = Math.floor(minutes / 60);
1448
+ return `${hours} h ${String(minutes % 60).padStart(2, "0")}`;
1449
+ }
1450
+
1451
+ function formatOffset(minutes) {
1452
+ if (minutes < 60) return `−${minutes} min`;
1453
+ const hours = Math.floor(minutes / 60);
1454
+ const rest = minutes % 60;
1455
+ return rest ? `−${hours} h ${String(rest).padStart(2, "0")}` : `−${hours} h`;
1456
+ }
1457
+
1458
+ /** Boîtes qu'une liaison peut relier : celles qu'une session a ouvertes. */
1459
+ function linkableBoxes() {
1460
+ return [...state.directory.values()].filter((box) => box.registered);
1461
+ }
1462
+
1463
+ function ageOf(box, now) {
1464
+ return now - new Date(box.created_at).getTime();
1465
+ }
1466
+
1467
+ function isRecent(box, now) {
1468
+ return ageOf(box, now) <= linksView.windowMinutes * 60_000;
1469
+ }
1470
+
1471
+ /** Rang de création des boîtes de la fenêtre détaillée : 1 pour la plus récente. */
1472
+ function creationRanks(boxes, now) {
1473
+ const recent = boxes.filter((box) => isRecent(box, now)).sort(byCreation);
1474
+ return new Map(recent.map((box, index) => [box.id, index + 1]));
1475
+ }
1476
+
1477
+ function byCreation(a, b) {
1478
+ return a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : a.id < b.id ? -1 : 1;
1479
+ }
1480
+
1481
+ /** Adresses liées à chaque boîte. */
1482
+ function linkIndex() {
1483
+ const index = new Map();
1484
+ for (const link of state.links) {
1485
+ const [a, b] = link.mailboxes.map((end) => end.mailbox);
1486
+ if (!index.has(a)) index.set(a, new Set());
1487
+ if (!index.has(b)) index.set(b, new Set());
1488
+ index.get(a).add(b);
1489
+ index.get(b).add(a);
1490
+ }
1491
+ return index;
1492
+ }
1493
+
1494
+ /** Colonnes de la carte : les boîtes groupées par agent, l'agent le plus récent en tête. */
1495
+ function linkLanes(boxes) {
1496
+ const lanes = new Map();
1497
+ for (const box of boxes) {
1498
+ const key = box.agent_name || box.id;
1499
+ if (!lanes.has(key)) lanes.set(key, { key, name: box.agent_name || "agent sans nom", seed: key, boxes: [] });
1500
+ lanes.get(key).boxes.push(box);
1501
+ }
1502
+ const list = [...lanes.values()];
1503
+ for (const lane of list) lane.boxes.sort(byCreation);
1504
+ list.sort((a, b) => byCreation(a.boxes[0], b.boxes[0]));
1505
+ return list;
1506
+ }
1507
+
1508
+ /** Nombre de boîtes de la fenêtre détaillée que rien ne relie encore. */
1509
+ function renderLinksBadge() {
1510
+ const now = Date.now();
1511
+ const linked = linkIndex();
1512
+ const waiting = linkableBoxes().filter((box) => isRecent(box, now) && !linked.has(box.id)).length;
1513
+ el.linksBadge.hidden = waiting === 0;
1514
+ el.linksBadge.textContent = String(waiting);
1515
+ el.linksBadge.title = `${plural(waiting, "boîte récente")} sans liaison`;
1516
+ }
1517
+
1518
+ async function refreshLinks() {
1519
+ try {
1520
+ const [overview, links] = await Promise.all([api("/admin/overview"), api("/admin/links")]);
1521
+ setLoggedIn(true);
1522
+ setStatus("ok", `connecté · ${window.location.host}`);
1523
+ renderAgents(overview);
1524
+ state.links = links.links;
1525
+ renderLinksView();
1526
+ } catch (error) {
1527
+ if (error.message === "unauthorized") {
1528
+ setStatus("idle", "authentification requise");
1529
+ return;
1530
+ }
1531
+ setStatus("ko", `erreur : ${error.message}`);
1532
+ }
1533
+ }
1534
+
1535
+ function renderLinksView() {
1536
+ renderLinksBadge();
1537
+ if (linksView.selected && !state.directory.has(linksView.selected)) linksView.selected = null;
1538
+ // Un rafraîchissement pendant un glissé effacerait le fil qu'on est en train de tirer,
1539
+ // et pendant un renommage le nom en cours de saisie.
1540
+ if (!linksView.drag && !linksView.renaming) renderLinksMap();
1541
+ renderLinksPanel();
1542
+ }
1543
+
1544
+ function setView(view) {
1545
+ if (state.view === view) return;
1546
+ state.view = view;
1547
+ for (const tab of el.tabs.querySelectorAll(".tab")) {
1548
+ tab.classList.toggle("is-active", tab.dataset.view === view);
1549
+ }
1550
+ setLoggedIn(true);
1551
+ refresh({ keepThread: true });
1552
+ }
1553
+
1554
+ // Carte ---------------------------------------------------------------------
1555
+
1556
+ function mapGeometry() {
1557
+ const minutes = linksView.windowMinutes;
1558
+ const nowY = MAP.head + MAP.card / 2 + 6;
1559
+ return { minutes, nowY, bottom: nowY + MAP.timePx };
1560
+ }
1561
+
1562
+ /** Hauteur, sur la carte, d'une boîte ouverte il y a `ageMinutes` : l'échelle est régulière sur toute la fenêtre. */
1563
+ function yForAge(geometry, ageMinutes) {
1564
+ return geometry.nowY + (ageMinutes / geometry.minutes) * MAP.timePx;
1565
+ }
1566
+
1567
+ /** Graduations de la fenêtre : un pas rond qui en donne six au plus, et la fin de la fenêtre. */
1568
+ function timeTicks(geometry) {
1569
+ const { minutes } = geometry;
1570
+ const step = [1, 2, 5, 10, 15, 30, 60].find((candidate) => minutes / candidate <= 6) || 60;
1571
+ const ticks = [];
1572
+ for (let m = step; m < minutes; m += step) ticks.push(m);
1573
+ ticks.push(minutes);
1574
+ return ticks;
1575
+ }
1576
+
1577
+ function placed(element, x, y, width, height) {
1578
+ element.style.left = `${x}px`;
1579
+ element.style.top = `${y}px`;
1580
+ if (width !== undefined) element.style.width = `${width}px`;
1581
+ if (height !== undefined) element.style.height = `${height}px`;
1582
+ return element;
1583
+ }
1584
+
1585
+ function renderLinksMap() {
1586
+ const now = Date.now();
1587
+ const geometry = mapGeometry();
1588
+ const boxes = linkableBoxes();
1589
+ const lanes = linkLanes(boxes);
1590
+ const ranks = creationRanks(boxes, now);
1591
+ const linked = linkIndex();
1592
+ const canvas = el.linksCanvas;
1593
+ linksView.rects = new Map();
1594
+
1595
+ if (lanes.length === 0) {
1596
+ const empty = node("div", "empty");
1597
+ empty.appendChild(node("span", "empty-title", "Aucune boîte"));
1598
+ empty.appendChild(document.createTextNode("Les boîtes apparaissent ici dès qu'une session se connecte."));
1599
+ canvas.replaceChildren(empty);
1600
+ canvas.style.width = "";
1601
+ canvas.style.height = "";
1602
+ return;
1603
+ }
1604
+
1605
+ const width = MAP.axis + lanes.length * (MAP.lane + MAP.gap);
1606
+ const fragment = document.createDocumentFragment();
1607
+
1608
+ // Colonnes, et ce qui les place : la position de chaque boîte est calculée ici, une fois.
1609
+ const laneLayouts = lanes.map((lane, index) => {
1610
+ const x = MAP.axis + index * (MAP.lane + MAP.gap);
1611
+ const recent = lane.boxes.filter((box) => isRecent(box, now));
1612
+ const old = lane.boxes.filter((box) => !isRecent(box, now));
1613
+ let previousBottom = MAP.head + 4;
1614
+ const cards = recent.map((box) => {
1615
+ const center = yForAge(geometry, ageOf(box, now) / 60_000);
1616
+ return { box, top: center - MAP.card / 2 };
1617
+ });
1618
+ // Deux boîtes trop proches dans le temps se chevaucheraient : la plus ancienne descend.
1619
+ for (const card of cards) {
1620
+ card.top = Math.max(card.top, previousBottom + MAP.cardGap);
1621
+ previousBottom = card.top + MAP.card;
1622
+ }
1623
+ return { lane, x, cards, old, cardsBottom: previousBottom };
1624
+ });
1625
+
1626
+ // La marge laisse la place au libellé de la dernière graduation, écrit sous son trait.
1627
+ const timeBottom = Math.max(geometry.bottom + 32, ...laneLayouts.map((layout) => layout.cardsBottom + 12));
1628
+ const oldTop = timeBottom + 28;
1629
+ const oldRows = Math.max(
1630
+ 1,
1631
+ ...laneLayouts.map((layout) =>
1632
+ linksView.showOld.has(layout.lane.key) ? layout.old.length + 1 : Math.min(layout.old.length, MAP.oldShown) + 1
1633
+ )
1634
+ );
1635
+ const height = oldTop + oldRows * MAP.oldRow + 16;
1636
+
1637
+ for (const layout of laneLayouts) fragment.appendChild(laneColumn(layout, height, now));
1638
+
1639
+ fragment.appendChild(timeAxis(geometry, width, now));
1640
+
1641
+ const separator = placed(node("div", "links-separator"), 8, timeBottom + 8, width - 16);
1642
+ fragment.appendChild(separator);
1643
+ const oldLabel = placed(node("div", "links-axis-label links-axis-old"), 8, timeBottom + 14, MAP.axis - 16);
1644
+ oldLabel.appendChild(node("strong", null, `Plus de ${formatOffset(linksView.windowMinutes).slice(1)}`));
1645
+ oldLabel.appendChild(node("span", null, "listes empilées"));
1646
+ fragment.appendChild(oldLabel);
1647
+
1648
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1649
+ svg.setAttribute("class", "links-wires");
1650
+ svg.setAttribute("width", String(width));
1651
+ svg.setAttribute("height", String(height));
1652
+ fragment.appendChild(svg);
1653
+
1654
+ for (const layout of laneLayouts) {
1655
+ for (const card of layout.cards) {
1656
+ fragment.appendChild(boxCard(card.box, layout.x + 10, card.top, ranks.get(card.box.id), linked, now));
1657
+ }
1658
+ fragment.appendChild(oldList(layout, oldTop, linked));
1659
+ }
1660
+
1661
+ const rename = linksView.renaming ? renameField(linksView.renaming) : null;
1662
+ if (rename) fragment.appendChild(rename);
1663
+ else linksView.renaming = null;
1664
+
1665
+ canvas.replaceChildren(fragment);
1666
+ canvas.style.width = `${width}px`;
1667
+ canvas.style.height = `${height}px`;
1668
+ drawWires(svg);
1669
+
1670
+ const input = canvas.querySelector(".links-rename-input");
1671
+ if (input) {
1672
+ input.focus();
1673
+ input.select();
1674
+ }
1675
+ }
1676
+
1677
+ function laneColumn(layout, height, now) {
1678
+ const { lane, x } = layout;
1679
+ const column = placed(node("div", "links-lane"), x, 0, MAP.lane, height);
1680
+ const head = node("div", "links-lane-head");
1681
+ head.appendChild(avatarFor(lane.seed, lane.name, lane.name, null, false));
1682
+ const text = node("div", "links-lane-text");
1683
+ text.appendChild(node("div", "links-lane-name", lane.name));
1684
+ const age = ageOf(lane.boxes[0], now);
1685
+ const last = node("div", "links-lane-last", `dernière : il y a ${formatAge(age)}`);
1686
+ if (age <= FRESH_MINUTES * 60_000) last.classList.add("is-fresh");
1687
+ text.appendChild(last);
1688
+ head.appendChild(text);
1689
+ head.title = `${lane.name} · ${plural(lane.boxes.length, "boîte")}`;
1690
+ column.appendChild(head);
1691
+ return column;
1692
+ }
1693
+
1694
+ function timeAxis(geometry, width, now) {
1695
+ const axis = document.createDocumentFragment();
1696
+
1697
+ const tick = (minutes, kind) => {
1698
+ const y = yForAge(geometry, minutes);
1699
+ axis.appendChild(placed(node("div", `links-tick links-tick--${kind}`), 8, y, width - 16));
1700
+ const label = placed(node("div", `links-axis-label links-axis-label--${kind}`), 8, y + 3, MAP.axis - 16);
1701
+ label.appendChild(node("strong", null, minutes === 0 ? "maintenant" : formatOffset(minutes)));
1702
+ label.appendChild(node("span", "mono", formatClock(now - minutes * 60_000)));
1703
+ axis.appendChild(label);
1704
+ };
1705
+
1706
+ tick(0, "now");
1707
+ for (const m of timeTicks(geometry)) tick(m, "step");
1708
+ return axis;
1709
+ }
1710
+
1711
+ /** États d'une boîte sur la carte : choisie, liée à la boîte choisie, renommée, ou source d'un glissé. */
1712
+ function boxClasses(base, boxId, linked) {
1713
+ const classes = [base];
1714
+ if (linksView.selected === boxId) classes.push("is-selected");
1715
+ if (linksView.selected && linked.get(linksView.selected)?.has(boxId)) classes.push("is-linked");
1716
+ if (linksView.renaming === boxId) classes.push("is-renaming");
1717
+ if (linksView.drag?.from === boxId) classes.push("is-drag-source");
1718
+ return classes.join(" ");
1719
+ }
1720
+
1721
+ function boxCard(box, x, y, rank, linked, now) {
1722
+ const age = ageOf(box, now);
1723
+ const card = placed(node("button", boxClasses("links-card", box.id, linked)), x, y, MAP.lane - 20, MAP.card);
1724
+ card.type = "button";
1725
+ card.dataset.box = box.id;
1726
+ card.title = boxTitle(box.id);
1727
+
1728
+ card.appendChild(node("span", rank <= 3 ? "links-rank is-top" : "links-rank", String(rank)));
1729
+ const text = node("span", "links-card-text");
1730
+ const line = node("span", "links-card-line");
1731
+ const name = node("span", "links-card-name", box.display_name || box.id);
1732
+ if (!box.display_name) name.classList.add("mono", "is-absent");
1733
+ line.appendChild(name);
1734
+ line.appendChild(renameHandle(box));
1735
+ text.appendChild(line);
1736
+ const meta = node("span", "links-card-meta");
1737
+ meta.appendChild(node("span", "mono", formatClock(box.created_at)));
1738
+ const ago = node("span", "links-card-ago", formatAge(age));
1739
+ if (age <= FRESH_MINUTES * 60_000) ago.classList.add("is-fresh");
1740
+ meta.appendChild(ago);
1741
+ text.appendChild(meta);
1742
+ card.appendChild(text);
1743
+
1744
+ const count = linked.get(box.id)?.size || 0;
1745
+ if (count > 0) {
1746
+ const badge = node("span", "links-card-count");
1747
+ badge.appendChild(icon("link"));
1748
+ badge.appendChild(document.createTextNode(String(count)));
1749
+ badge.title = plural(count, "liaison");
1750
+ card.appendChild(badge);
1751
+ }
1752
+
1753
+ card.appendChild(dragHandle(box));
1754
+ card.addEventListener("click", () => clickLinkBox(box.id));
1755
+ linksView.rects.set(box.id, { x, y, w: MAP.lane - 20, h: MAP.card, kind: "card" });
1756
+ return card;
1757
+ }
1758
+
1759
+ function oldList(layout, top, linked) {
1760
+ const { lane, x, old } = layout;
1761
+ const list = document.createDocumentFragment();
1762
+ const expanded = linksView.showOld.has(lane.key);
1763
+ const shown = expanded ? old : old.slice(0, MAP.oldShown);
1764
+
1765
+ if (old.length === 0) {
1766
+ list.appendChild(placed(node("div", "links-old-none", "aucune autre boîte"), x + 10, top, MAP.lane - 20, 24));
1767
+ return list;
1768
+ }
1769
+
1770
+ shown.forEach((box, index) => {
1771
+ const y = top + index * MAP.oldRow;
1772
+ const row = placed(node("button", boxClasses("links-old-row", box.id, linked)), x + 10, y, MAP.lane - 20, MAP.oldRow - 4);
1773
+ row.type = "button";
1774
+ row.dataset.box = box.id;
1775
+ row.title = boxTitle(box.id);
1776
+ const name = node("span", "links-old-name", box.display_name || box.id);
1777
+ if (!box.display_name) name.classList.add("mono", "is-absent");
1778
+ row.appendChild(name);
1779
+ if (linked.get(box.id)?.size) row.appendChild(icon("link"));
1780
+ row.appendChild(renameHandle(box));
1781
+ row.appendChild(node("span", "links-old-date", formatShort(box.created_at)));
1782
+ row.appendChild(dragHandle(box));
1783
+ row.addEventListener("click", () => clickLinkBox(box.id));
1784
+ linksView.rects.set(box.id, { x: x + 10, y, w: MAP.lane - 20, h: MAP.oldRow - 4, kind: "row" });
1785
+ list.appendChild(row);
1786
+ });
1787
+
1788
+ if (old.length > MAP.oldShown) {
1789
+ const more = expanded ? "Replier" : `+ ${old.length - MAP.oldShown} autres`;
1790
+ const toggle = placed(
1791
+ button("links-old-more", more, () => {
1792
+ if (expanded) linksView.showOld.delete(lane.key);
1793
+ else linksView.showOld.add(lane.key);
1794
+ renderLinksMap();
1795
+ }),
1796
+ x + 10,
1797
+ top + shown.length * MAP.oldRow,
1798
+ MAP.lane - 20
1799
+ );
1800
+ toggle.dataset.lane = lane.key;
1801
+ list.appendChild(toggle);
1802
+ }
1803
+ return list;
1804
+ }
1805
+
1806
+ /** Courbe d'une boîte à l'autre : des bords qui se font face, ou par la droite dans une même colonne. */
1807
+ function wirePath(from, to) {
1808
+ const ay = from.y + from.h / 2;
1809
+ const by = to.y + to.h / 2;
1810
+ if (from.x + from.w <= to.x || to.x + to.w <= from.x) {
1811
+ const [left, right, ly, ry] = from.x < to.x ? [from, to, ay, by] : [to, from, by, ay];
1812
+ const x1 = left.x + left.w;
1813
+ const x2 = right.x;
1814
+ const dx = Math.max(24, (x2 - x1) / 2);
1815
+ return `M${x1} ${ly} C${x1 + dx} ${ly} ${x2 - dx} ${ry} ${x2} ${ry}`;
1816
+ }
1817
+ const x = from.x + from.w;
1818
+ return `M${x} ${ay} C${x + 40} ${ay} ${x + 40} ${by} ${x} ${by}`;
1819
+ }
1820
+
1821
+ function wire(svg, d, className) {
1822
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1823
+ path.setAttribute("d", d);
1824
+ path.setAttribute("class", className);
1825
+ svg.appendChild(path);
1826
+ return path;
1827
+ }
1828
+
1829
+ function drawWires(svg) {
1830
+ for (const link of state.links) {
1831
+ const [a, b] = link.mailboxes.map((end) => end.mailbox);
1832
+ const from = linksView.rects.get(a);
1833
+ const to = linksView.rects.get(b);
1834
+ if (!from || !to) continue;
1835
+ const active = linksView.selected === a || linksView.selected === b;
1836
+ wire(svg, wirePath(from, to), active ? "links-wire is-active" : "links-wire");
1837
+ }
1838
+ }
1839
+
1840
+ /** Poignée d'une boîte : la tirer jusqu'à une autre boîte les lie. */
1841
+ function dragHandle(box) {
1842
+ const handle = node("span", "links-handle");
1843
+ handle.title = "Tirer jusqu'à une autre boîte pour les lier";
1844
+ handle.addEventListener("click", (event) => event.stopPropagation());
1845
+ handle.addEventListener("pointerdown", (event) => {
1846
+ event.preventDefault();
1847
+ event.stopPropagation();
1848
+ startLinkDrag(event, box.id, handle);
1849
+ });
1850
+ return handle;
1851
+ }
1852
+
1853
+ function canvasPoint(event) {
1854
+ const rect = el.linksCanvas.getBoundingClientRect();
1855
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
1856
+ }
1857
+
1858
+ /** Délai de survol au bout duquel « + N autres » se déplie pendant un glissé. */
1859
+ const SPRING_MS = 600;
1860
+
1861
+ function startLinkDrag(event, boxId, handle) {
1862
+ const svg = el.linksCanvas.querySelector(".links-wires");
1863
+ const from = linksView.rects.get(boxId);
1864
+ if (!svg || !from) return;
1865
+ const start = { x: from.x + from.w, y: from.y + from.h / 2 };
1866
+ let path = wire(svg, "", "links-wire is-drawing");
1867
+ let point = start;
1868
+ let target = null;
1869
+ let spring = null;
1870
+ linksView.drag = { from: boxId };
1871
+ el.links.classList.add("is-linking");
1872
+ handle.closest("[data-box]")?.classList.add("is-drag-source");
1873
+ handle.setPointerCapture?.(event.pointerId);
1874
+
1875
+ const draw = () => {
1876
+ const dx = Math.max(24, Math.abs(point.x - start.x) / 2);
1877
+ path.setAttribute("d", `M${start.x} ${start.y} C${start.x + dx} ${start.y} ${point.x - dx} ${point.y} ${point.x} ${point.y}`);
1878
+ };
1879
+ const setTarget = (next) => {
1880
+ if (target === next) return;
1881
+ target?.classList.remove("is-drop-target");
1882
+ target = next;
1883
+ target?.classList.add("is-drop-target");
1884
+ };
1885
+ // Une liste repliée se déplie sous le fil : la carte est redessinée, le fil retracé.
1886
+ const springOpen = (toggle) => {
1887
+ const lane = toggle && !linksView.showOld.has(toggle.dataset.lane) ? toggle.dataset.lane : null;
1888
+ if (spring?.lane === lane) return;
1889
+ clearTimeout(spring?.timer);
1890
+ spring = null;
1891
+ if (!lane) return;
1892
+ const timer = setTimeout(() => {
1893
+ spring = null;
1894
+ linksView.showOld.add(lane);
1895
+ renderLinksMap();
1896
+ target = null;
1897
+ path = wire(el.linksCanvas.querySelector(".links-wires"), "", "links-wire is-drawing");
1898
+ draw();
1899
+ }, SPRING_MS);
1900
+ spring = { lane, timer };
1901
+ };
1902
+
1903
+ const onMove = (move) => {
1904
+ point = canvasPoint(move);
1905
+ draw();
1906
+ const hit = document.elementFromPoint(move.clientX, move.clientY);
1907
+ const box = hit?.closest("[data-box]");
1908
+ setTarget(box && box.dataset.box !== boxId ? box : null);
1909
+ springOpen(hit?.closest(".links-old-more[data-lane]") || null);
1910
+ };
1911
+ const onUp = (up) => {
1912
+ window.removeEventListener("pointermove", onMove);
1913
+ window.removeEventListener("pointerup", onUp);
1914
+ clearTimeout(spring?.timer);
1915
+ el.links.classList.remove("is-linking");
1916
+ linksView.drag = null;
1917
+ const target = document.elementFromPoint(up.clientX, up.clientY)?.closest("[data-box]");
1918
+ const to = target?.dataset.box;
1919
+ if (to && to !== boxId) createLink(boxId, to);
1920
+ else renderLinksMap();
1921
+ };
1922
+ window.addEventListener("pointermove", onMove);
1923
+ window.addEventListener("pointerup", onUp);
1924
+ }
1925
+
1926
+ /** Crayon d'une boîte : ouvre la saisie de son nom. */
1927
+ function renameHandle(box) {
1928
+ const pencil = node("span", "links-rename");
1929
+ pencil.title = "Renommer · ou double-clic sur la boîte";
1930
+ pencil.appendChild(icon("pencil"));
1931
+ pencil.addEventListener("click", (event) => {
1932
+ event.stopPropagation();
1933
+ startRename(box.id);
1934
+ });
1935
+ return pencil;
1936
+ }
1937
+
1938
+ /** Écart maximal entre deux clics sur une même boîte pour ouvrir la saisie de son nom. */
1939
+ const DOUBLE_CLICK_MS = 400;
1940
+
1941
+ /**
1942
+ * Un clic choisit la boîte, un second clic rapproché ouvre la saisie de son nom. Le
1943
+ * double-clic natif ne sert pas : le premier clic redessine la carte, et le second tombe
1944
+ * sur un autre élément.
1945
+ */
1946
+ function clickLinkBox(boxId) {
1947
+ const now = Date.now();
1948
+ const last = linksView.lastClick;
1949
+ if (last && last.box === boxId && now - last.at < DOUBLE_CLICK_MS) {
1950
+ linksView.lastClick = null;
1951
+ startRename(boxId);
1952
+ return;
1953
+ }
1954
+ linksView.lastClick = { box: boxId, at: now };
1955
+ selectLinkBox(boxId);
1956
+ }
1957
+
1958
+ function startRename(boxId) {
1959
+ linksView.renaming = boxId;
1960
+ linksView.renameError = "";
1961
+ if (linksView.selected !== boxId) {
1962
+ linksView.selected = boxId;
1963
+ linksView.armedUnlink = null;
1964
+ linksView.error = "";
1965
+ }
1966
+ renderLinksMap();
1967
+ renderLinksPanel();
1968
+ }
1969
+
1970
+ function cancelRename() {
1971
+ linksView.renaming = null;
1972
+ linksView.renameError = "";
1973
+ renderLinksMap();
1974
+ }
1975
+
1976
+ /**
1977
+ * Champ de saisie posé sur le nom de la boîte : sur la ligne du nom d'une carte, sur
1978
+ * toute la ligne d'une boîte ancienne. Entrée ou la perte du focus enregistre, Échap annule.
1979
+ */
1980
+ function renameField(boxId) {
1981
+ const box = state.directory.get(boxId);
1982
+ const rect = linksView.rects.get(boxId);
1983
+ if (!box || !rect) return null;
1984
+ const field = document.createDocumentFragment();
1985
+ const input = document.createElement("input");
1986
+ input.type = "text";
1987
+ input.className = linksView.renameError ? "links-rename-input is-invalid" : "links-rename-input";
1988
+ input.maxLength = 200;
1989
+ input.value = box.display_name || "";
1990
+ input.placeholder = "Nom de la boîte";
1991
+ input.setAttribute("aria-label", `Nom de la boîte ${boxId}`);
1992
+ if (rect.kind === "card") placed(input, rect.x + 36, rect.y + 4, rect.w - 46, 20);
1993
+ else placed(input, rect.x, rect.y, rect.w, rect.h);
1994
+
1995
+ let done = false;
1996
+ const finish = (save) => {
1997
+ if (done) return;
1998
+ done = true;
1999
+ if (save) commitRename(boxId, input.value);
2000
+ else cancelRename();
2001
+ };
2002
+ input.addEventListener("keydown", (event) => {
2003
+ if (event.key === "Enter") {
2004
+ event.preventDefault();
2005
+ finish(true);
2006
+ } else if (event.key === "Escape") {
2007
+ event.preventDefault();
2008
+ event.stopPropagation();
2009
+ finish(false);
2010
+ }
2011
+ });
2012
+ input.addEventListener("blur", () => finish(true));
2013
+ input.addEventListener("click", (event) => event.stopPropagation());
2014
+ field.appendChild(input);
2015
+
2016
+ const hint = node(
2017
+ "div",
2018
+ linksView.renameError ? "links-rename-hint is-error" : "links-rename-hint",
2019
+ linksView.renameError || "Entrée valider · Échap annuler"
2020
+ );
2021
+ field.appendChild(placed(hint, rect.x, rect.y + rect.h + 4));
2022
+ return field;
2023
+ }
2024
+
2025
+ /**
2026
+ * Enregistre le nom saisi. La carte l'affiche aussitôt ; un refus du serveur rend l'ancien
2027
+ * nom et rouvre la saisie avec le motif. Un nom vide efface celui de la boîte.
2028
+ */
2029
+ async function commitRename(boxId, value) {
2030
+ const box = state.directory.get(boxId);
2031
+ const name = value.trim();
2032
+ linksView.renaming = null;
2033
+ linksView.renameError = "";
2034
+ if (!box || name === (box.display_name || "")) {
2035
+ renderLinksMap();
2036
+ return;
2037
+ }
2038
+ state.directory.set(boxId, { ...box, display_name: name || null });
2039
+ renderLinksMap();
2040
+ renderLinksPanel();
2041
+ try {
2042
+ await patch(`/admin/mailboxes/${encodeURIComponent(boxId)}`, { display_name: name || null });
2043
+ } catch (error) {
2044
+ state.directory.set(boxId, box);
2045
+ linksView.renaming = boxId;
2046
+ linksView.renameError = error.message;
2047
+ renderLinksMap();
2048
+ renderLinksPanel();
2049
+ return;
2050
+ }
2051
+ await refreshLinks();
2052
+ }
2053
+
2054
+ function selectLinkBox(boxId) {
2055
+ linksView.selected = linksView.selected === boxId ? null : boxId;
2056
+ linksView.armedUnlink = null;
2057
+ linksView.error = "";
2058
+ renderLinksMap();
2059
+ renderLinksPanel();
2060
+ }
2061
+
2062
+ async function createLink(first, second) {
2063
+ linksView.error = "";
2064
+ try {
2065
+ await post("/admin/links", { mailboxes: [first, second], note: linksView.note.trim() || null });
2066
+ linksView.note = "";
2067
+ const note = el.linksPanel.querySelector("#links-note");
2068
+ if (note) note.value = "";
2069
+ } catch (error) {
2070
+ linksView.error = error.message;
2071
+ }
2072
+ await refreshLinks();
2073
+ }
2074
+
2075
+ async function deleteLink(linkId) {
2076
+ if (linksView.armedUnlink !== linkId) {
2077
+ linksView.armedUnlink = linkId;
2078
+ renderLinksPanel();
2079
+ return;
2080
+ }
2081
+ linksView.armedUnlink = null;
2082
+ linksView.error = "";
2083
+ try {
2084
+ await remove(`/admin/links/${encodeURIComponent(linkId)}`);
2085
+ } catch (error) {
2086
+ linksView.error = error.message;
2087
+ }
2088
+ await refreshLinks();
2089
+ }
2090
+
2091
+ // Volet ---------------------------------------------------------------------
2092
+
2093
+ /**
2094
+ * Volet de la boîte choisie. Il n'est reconstruit qu'au changement de boîte : ses champs
2095
+ * de saisie gardent leur contenu et le focus pendant les rafraîchissements, seules les
2096
+ * listes sont redessinées.
2097
+ */
2098
+ function renderLinksPanel() {
2099
+ const id = linksView.selected;
2100
+ if (!id) {
2101
+ linksView.panelFor = null;
2102
+ const empty = node("div", "empty");
2103
+ empty.appendChild(node("span", "empty-title", "Aucune boîte choisie"));
2104
+ empty.appendChild(document.createTextNode("Choisissez une boîte sur la carte pour voir ses liaisons et en créer."));
2105
+ el.linksPanel.replaceChildren(empty, deliveryNote());
2106
+ return;
2107
+ }
2108
+ if (linksView.panelFor !== id) buildLinksPanel(id);
2109
+ fillLinksPanel(id);
2110
+ }
2111
+
2112
+ function buildLinksPanel(id) {
2113
+ linksView.panelFor = id;
2114
+ linksView.search = "";
2115
+ linksView.showAllCandidates = false;
2116
+ const panel = document.createDocumentFragment();
2117
+
2118
+ const head = node("div", "links-panel-head");
2119
+ head.appendChild(node("span", "section-label", "Boîte sélectionnée"));
2120
+ const close = node("button", "btn btn--icon btn--ghost");
2121
+ close.type = "button";
2122
+ close.title = "Fermer";
2123
+ close.setAttribute("aria-label", "Désélectionner la boîte");
2124
+ close.appendChild(icon("close"));
2125
+ close.addEventListener("click", () => selectLinkBox(id));
2126
+ head.appendChild(close);
2127
+ panel.appendChild(head);
2128
+
2129
+ panel.appendChild(node("div", "links-panel-box"));
2130
+
2131
+ const linksSection = node("div", "links-panel-section");
2132
+ linksSection.appendChild(node("h3", null, "Liaisons"));
2133
+ linksSection.appendChild(node("ul", "links-panel-links"));
2134
+ panel.appendChild(linksSection);
2135
+
2136
+ const linkTo = node("div", "links-panel-section");
2137
+ const title = node("div", "links-panel-title");
2138
+ title.appendChild(node("h3", null, "Lier à…"));
2139
+ title.appendChild(node("span", "muted", "rang de création"));
2140
+ linkTo.appendChild(title);
2141
+
2142
+ const note = document.createElement("input");
2143
+ note.type = "text";
2144
+ note.id = "links-note";
2145
+ note.className = "links-input";
2146
+ note.maxLength = 500;
2147
+ note.placeholder = "Motif transmis aux deux agents (facultatif)";
2148
+ note.value = linksView.note;
2149
+ note.addEventListener("input", () => {
2150
+ linksView.note = note.value;
2151
+ });
2152
+ linkTo.appendChild(note);
2153
+
2154
+ const search = node("label", "links-search");
2155
+ search.appendChild(icon("search"));
2156
+ const input = document.createElement("input");
2157
+ input.type = "search";
2158
+ input.placeholder = "Agent ou nom de boîte…";
2159
+ input.addEventListener("input", () => {
2160
+ linksView.search = input.value.trim().toLowerCase();
2161
+ fillLinksPanel(id);
2162
+ });
2163
+ search.appendChild(input);
2164
+ linkTo.appendChild(search);
2165
+
2166
+ linkTo.appendChild(node("p", "links-error"));
2167
+ linkTo.appendChild(node("ul", "links-candidates"));
2168
+ panel.appendChild(linkTo);
2169
+ panel.appendChild(deliveryNote());
2170
+
2171
+ el.linksPanel.replaceChildren(panel);
2172
+ }
2173
+
2174
+ function deliveryNote() {
2175
+ const note = node("p", "links-delivery");
2176
+ note.appendChild(icon("info"));
2177
+ note.appendChild(
2178
+ document.createTextNode(
2179
+ "Chaque agent reçoit la liaison à sa prochaine connexion (whoami), ou aussitôt si ses notifications sont armées."
2180
+ )
2181
+ );
2182
+ return note;
2183
+ }
2184
+
2185
+ function fillLinksPanel(id) {
2186
+ const box = state.directory.get(id);
2187
+ const now = Date.now();
2188
+ const boxes = linkableBoxes();
2189
+ const ranks = creationRanks(boxes, now);
2190
+
2191
+ const summary = el.linksPanel.querySelector(".links-panel-box");
2192
+ summary.replaceChildren();
2193
+ const identity = node("div", "links-panel-identity");
2194
+ identity.appendChild(agentAvatar(id, "avatar--lg"));
2195
+ const names = node("div", "agent-text");
2196
+ names.appendChild(node("div", "links-panel-agent", agentLabel(id)));
2197
+ const address = node("div", "party-addr");
2198
+ address.appendChild(node("span", null, box.display_name || "boîte sans nom"));
2199
+ address.appendChild(node("span", "mono", `· ${id}`));
2200
+ address.appendChild(copyButton(id, "Copier l'adresse"));
2201
+ names.appendChild(address);
2202
+ identity.appendChild(names);
2203
+ summary.appendChild(identity);
2204
+ const rank = ranks.get(id);
2205
+ summary.appendChild(
2206
+ node(
2207
+ "p",
2208
+ "links-panel-meta",
2209
+ [`Créée à ${formatClock(box.created_at)}`, `il y a ${formatAge(ageOf(box, now))}`, rank ? `rang #${rank}` : null]
2210
+ .filter(Boolean)
2211
+ .join(" · ")
2212
+ )
2213
+ );
2214
+ if (box.description) summary.appendChild(node("p", "links-panel-meta", box.description));
2215
+
2216
+ const own = state.links.filter((link) => link.mailboxes.some((end) => end.mailbox === id));
2217
+ const list = el.linksPanel.querySelector(".links-panel-links");
2218
+ list.replaceChildren();
2219
+ if (own.length === 0) list.appendChild(node("li", "links-panel-none", "Aucune liaison."));
2220
+ for (const link of own) list.appendChild(linkRow(link, id));
2221
+
2222
+ el.linksPanel.querySelector(".links-error").textContent = linksView.error;
2223
+
2224
+ const linked = linkIndex().get(id) || new Set();
2225
+ const search = linksView.search;
2226
+ const candidates = boxes
2227
+ .filter((other) => other.id !== id && !linked.has(other.id))
2228
+ .filter(
2229
+ (other) =>
2230
+ !search ||
2231
+ [other.agent_name, other.display_name, other.id].some((value) => (value || "").toLowerCase().includes(search))
2232
+ )
2233
+ .sort(byCreation);
2234
+ const recent = candidates.filter((other) => isRecent(other, now));
2235
+ const old = candidates.filter((other) => !isRecent(other, now));
2236
+
2237
+ const container = el.linksPanel.querySelector(".links-candidates");
2238
+ container.replaceChildren();
2239
+ if (candidates.length === 0) {
2240
+ container.appendChild(node("li", "links-panel-none", search ? "Aucune boîte ne correspond." : "Aucune autre boîte à lier."));
2241
+ return;
2242
+ }
2243
+ for (const other of recent) container.appendChild(candidateRow(id, other, ranks.get(other.id), now));
2244
+ if (old.length > 0) {
2245
+ container.appendChild(
2246
+ node("li", "links-candidates-sep", `Plus de ${formatOffset(linksView.windowMinutes).slice(1)}`)
2247
+ );
2248
+ const shown = linksView.showAllCandidates ? old : old.slice(0, MAP.oldCandidates);
2249
+ for (const other of shown) container.appendChild(candidateRow(id, other, null, now));
2250
+ if (shown.length < old.length) {
2251
+ const item = node("li");
2252
+ item.appendChild(
2253
+ button("btn btn--link", `Afficher les ${old.length - shown.length} autres`, () => {
2254
+ linksView.showAllCandidates = true;
2255
+ fillLinksPanel(id);
2256
+ })
2257
+ );
2258
+ container.appendChild(item);
2259
+ }
2260
+ }
2261
+ }
2262
+
2263
+ function linkRow(link, selfId) {
2264
+ const item = node("li", "links-panel-link");
2265
+ const otherId = link.mailboxes.find((end) => end.mailbox !== selfId).mailbox;
2266
+ const other = state.directory.get(otherId);
2267
+
2268
+ const top = node("div", "links-panel-link-top");
2269
+ top.appendChild(agentAvatar(otherId, "avatar--sm"));
2270
+ const text = node("div", "agent-text");
2271
+ text.appendChild(node("div", "agent-name", agentLabel(otherId)));
2272
+ text.appendChild(node("div", "agent-box", other ? other.display_name || otherId : otherId));
2273
+ top.appendChild(text);
2274
+
2275
+ const armed = linksView.armedUnlink === link.link_id;
2276
+ const unlink = node("button", armed ? "btn btn--danger links-unlink" : "btn btn--icon btn--ghost links-unlink");
2277
+ unlink.type = "button";
2278
+ unlink.title = armed ? "Confirmer la suppression de la liaison" : "Supprimer la liaison";
2279
+ unlink.setAttribute("aria-label", unlink.title);
2280
+ if (armed) unlink.textContent = "Confirmer";
2281
+ else unlink.appendChild(icon("unlink"));
2282
+ unlink.addEventListener("click", () => deleteLink(link.link_id));
2283
+ top.appendChild(unlink);
2284
+ item.appendChild(top);
2285
+
2286
+ if (link.note) item.appendChild(node("p", "links-panel-note", link.note));
2287
+
2288
+ const status = node("div", "links-panel-status");
2289
+ status.appendChild(node("span", "muted", `liée à ${formatClock(link.created_at)}`));
2290
+ for (const end of link.mailboxes) {
2291
+ const who = agentLabel(end.mailbox);
2292
+ status.appendChild(
2293
+ end.seen_at ? tag("tag tag--ok", `reçue par ${who}`, "check") : tag("tag tag--warn", `en attente : ${who}`, "clock")
2294
+ );
2295
+ }
2296
+ item.appendChild(status);
2297
+ return item;
2298
+ }
2299
+
2300
+ function candidateRow(selfId, other, rank, now) {
2301
+ const item = node("li", "links-candidate");
2302
+ if (rank) item.appendChild(node("span", rank <= 3 ? "links-rank is-top" : "links-rank", String(rank)));
2303
+ else item.appendChild(node("span", "links-rank is-old", "·"));
2304
+ item.appendChild(agentAvatar(other.id, "avatar--sm"));
2305
+ const text = node("div", "agent-text");
2306
+ text.appendChild(node("div", "agent-name", agentLabel(other.id)));
2307
+ const age = ageOf(other, now);
2308
+ const meta = node(
2309
+ "div",
2310
+ "agent-box",
2311
+ rank
2312
+ ? `${other.display_name || shortAddress(other.id)} · ${formatClock(other.created_at)} · ${formatAge(age)}`
2313
+ : `${other.display_name || shortAddress(other.id)} · ${formatShort(other.created_at)}`
2314
+ );
2315
+ if (rank && age <= FRESH_MINUTES * 60_000) meta.classList.add("is-fresh");
2316
+ text.appendChild(meta);
2317
+ item.appendChild(text);
2318
+ item.title = boxTitle(other.id);
2319
+ item.appendChild(button("btn links-link", "Lier", () => createLink(selfId, other.id)));
2320
+ return item;
2321
+ }
2322
+
2323
+ // --- Purge des boîtes inactives --------------------------------------------
2324
+
2325
+ /**
2326
+ * Ce que la fenêtre de nettoyage a sous la main : à quel titre les boîtes sont
2327
+ * supprimables (`empty` ou une date), celles que le serveur a désignées, celles qui
2328
+ * restent cochées, et si la suppression a déjà été demandée une fois. Le second clic est
2329
+ * ce qui la déclenche.
2330
+ */
2331
+ const purge = { mode: null, before: null, mailboxes: [], selected: new Set(), armed: false };
2332
+
2333
+ function openPurge() {
2334
+ const month = new Date(Date.now() - 30 * 86_400_000);
2335
+ const today = new Date().toISOString().slice(0, 10);
2336
+ el.purgeBefore.value = month.toISOString().slice(0, 10);
2337
+ el.purgeBefore.max = today;
2338
+ el.purgeCreatedBefore.value = month.toISOString().slice(0, 10);
2339
+ el.purgeCreatedBefore.max = today;
2340
+ resetPurge();
2341
+ el.purge.showModal();
2342
+ }
2343
+
2344
+ /**
2345
+ * Ouvre la fenêtre sur une seule boîte, celle qu'on vient de désigner dans la colonne.
2346
+ *
2347
+ * Rien à demander au serveur : l'annuaire vient d'être lu, et ce qu'il en dit est ce que
2348
+ * la ligne cliquée affiche. La liste est donc dressée sur place, avec la même mise en
2349
+ * forme et la même confirmation que les nettoyages en lot.
2350
+ */
2351
+ function openPurgeFor(box) {
2352
+ openPurge();
2353
+ purge.mode = "one";
2354
+ purge.mailboxes = [
2355
+ {
2356
+ id: box.id,
2357
+ agent_name: box.agent_name,
2358
+ display_name: box.display_name,
2359
+ last_activity_at: box.last_activity_at,
2360
+ messages: box.sent + box.received,
2361
+ },
2362
+ ];
2363
+ purge.selected = new Set([box.id]);
2364
+ renderPurgePreview();
2365
+ }
2366
+
2367
+ /** Toute modification remet la confirmation à zéro : on ne supprime que ce qu'on vient de voir. */
2368
+ function resetPurge() {
2369
+ purge.mode = null;
2370
+ purge.before = null;
2371
+ purge.mailboxes = [];
2372
+ purge.selected = new Set();
2373
+ purge.armed = false;
2374
+ el.purgeResult.replaceChildren();
2375
+ el.purgeConfirm.disabled = true;
2376
+ el.purgeConfirm.textContent = "Supprimer";
2377
+ }
2378
+
2379
+ /** Boîtes par lesquelles rien n'est jamais passé, sans considération de date. */
2380
+ async function previewEmpty() {
2381
+ await previewPurge("empty", () => api("/admin/mailboxes/empty"));
2382
+ }
2383
+
2384
+ /** Boîtes qui ont servi, mais que plus rien n'a touchées depuis la date choisie. */
2385
+ async function previewStale() {
2386
+ const before = el.purgeBefore.value;
2387
+ if (!before) return;
2388
+ await previewPurge("stale", () => api("/admin/mailboxes/stale", { before }), before);
2389
+ }
2390
+
2391
+ /** Boîtes ouvertes avant la date choisie, qu'elles servent encore ou non. */
2392
+ async function previewCreated() {
2393
+ const before = el.purgeCreatedBefore.value;
2394
+ if (!before) return;
2395
+ await previewPurge("created", () => api("/admin/mailboxes/created-before", { created_before: before }), before);
2396
+ }
2397
+
2398
+ async function previewPurge(mode, load, before = null) {
2399
+ resetPurge();
2400
+ try {
2401
+ const { mailboxes } = await load();
2402
+ purge.mode = mode;
2403
+ purge.before = before;
2404
+ purge.mailboxes = mailboxes;
2405
+ purge.selected = new Set(mailboxes.map((mailbox) => mailbox.id));
2406
+ renderPurgePreview();
2407
+ } catch (error) {
2408
+ el.purgeResult.replaceChildren(node("p", "purge-error", `Erreur : ${error.message}`));
2409
+ }
2410
+ }
2411
+
2412
+ function renderPurgePreview() {
2413
+ const container = document.createDocumentFragment();
2414
+ const count = purge.mailboxes.length;
2415
+ // Le critère est rappelé sous les yeux de qui va confirmer : les quatre suppressions
2416
+ // remplissent la même liste, et elles ne portent pas sur les mêmes boîtes. Les deux
2417
+ // critères datés se distinguent par leur seule formulation : « depuis » regarde
2418
+ // l'activité, « créée avant » regarde l'ouverture de la boîte.
2419
+ const dated =
2420
+ purge.mode === "created"
2421
+ ? ` créée avant le ${formatDay(purge.before)}`
2422
+ : ` sans activité depuis le ${formatDay(purge.before)}`;
2423
+ const criterion = purge.mode === "empty" ? "vide" : purge.mode === "one" ? "" : dated;
2424
+
2425
+ if (count === 0) {
2426
+ container.appendChild(node("p", "purge-empty", `Aucune boîte ${criterion.trim()}.`));
2427
+ el.purgeResult.replaceChildren(container);
2428
+ return;
2429
+ }
2430
+
2431
+ container.appendChild(
2432
+ node(
2433
+ "p",
2434
+ "purge-count",
2435
+ purge.mode === "one"
2436
+ ? "Cette boîte, et tout ce que personne d'autre ne pourrait plus lire :"
2437
+ : purge.mode === "empty"
2438
+ ? `${plural(count, "boîte vide", "boîtes vides")}.`
2439
+ : purge.mode === "created"
2440
+ ? `${plural(count, "boîte créée", "boîtes créées")} avant le ${formatDay(purge.before)}.`
2441
+ : `${plural(count, "boîte")}${dated}.`
2442
+ )
2443
+ );
2444
+
2445
+ const list = node("ul", "purge-list");
2446
+ for (const mailbox of purge.mailboxes) {
2447
+ const item = node("li", "purge-item");
2448
+ const label = node("label", "purge-line");
2449
+
2450
+ const box = document.createElement("input");
2451
+ box.type = "checkbox";
2452
+ box.checked = true;
2453
+ box.addEventListener("change", () => {
2454
+ if (box.checked) purge.selected.add(mailbox.id);
2455
+ else purge.selected.delete(mailbox.id);
2456
+ // Décocher change ce qui sera supprimé : la confirmation déjà armée ne vaut plus.
2457
+ purge.armed = false;
2458
+ updatePurgeButton();
2459
+ });
2460
+ label.appendChild(box);
2461
+
2462
+ const text = node("span", "purge-text");
2463
+ text.appendChild(node("span", "purge-name", mailbox.display_name || mailbox.agent_name || "boîte sans nom"));
2464
+ text.appendChild(node("span", "mono muted", mailbox.id));
2465
+ const activity = mailbox.last_activity_at ? `dernière activité ${formatDay(mailbox.last_activity_at)}` : "jamais utilisée";
2466
+ text.appendChild(node("span", "purge-meta", `${plural(mailbox.messages, "message")} · ${activity}`));
2467
+ label.appendChild(text);
2468
+
2469
+ item.appendChild(label);
2470
+ list.appendChild(item);
2471
+ }
2472
+ container.appendChild(list);
2473
+
2474
+ el.purgeResult.replaceChildren(container);
2475
+ updatePurgeButton();
2476
+ }
2477
+
2478
+ function updatePurgeButton() {
2479
+ const count = purge.selected.size;
2480
+ el.purgeConfirm.disabled = count === 0;
2481
+ el.purgeConfirm.textContent = purge.armed
2482
+ ? `Confirmer : supprimer ${plural(count, "boîte")}`
2483
+ : `Supprimer ${plural(count, "boîte")}`;
2484
+ }
2485
+
2486
+ async function confirmPurge() {
2487
+ if (purge.selected.size === 0 || !purge.mode) return;
2488
+
2489
+ // Premier clic : la suppression est armée, pas faite. Rien ne part sans un second clic.
2490
+ if (!purge.armed) {
2491
+ purge.armed = true;
2492
+ updatePurgeButton();
2493
+ return;
2494
+ }
2495
+
2496
+ el.purgeConfirm.disabled = true;
2497
+ const deleted = [...purge.selected];
2498
+ try {
2499
+ // Une boîte désignée à la main s'efface par son adresse : il n'y a pas de critère à
2500
+ // revérifier, l'utilisateur vient de la montrer du doigt.
2501
+ const removed =
2502
+ purge.mode === "one"
2503
+ ? await remove(`/admin/mailboxes/${encodeURIComponent(deleted[0])}`)
2504
+ : await post("/admin/mailboxes/purge", {
2505
+ // Le critère repart avec les adresses : c'est lui que le serveur revérifie.
2506
+ ...(purge.mode === "empty"
2507
+ ? { empty: true }
2508
+ : purge.mode === "created"
2509
+ ? { created_before: purge.before }
2510
+ : { before: purge.before }),
2511
+ mailboxes: deleted,
2512
+ });
2513
+ resetPurge();
2514
+ el.purgeResult.replaceChildren(
2515
+ node(
2516
+ "p",
2517
+ "purge-done",
2518
+ `${plural(removed.mailboxes, "boîte")} supprimée(s), ${plural(removed.messages, "message")} et ${plural(removed.threads, "fil")} avec.`
2519
+ )
2520
+ );
2521
+ // La console montre encore ce qui n'existe plus : elle se relit entièrement, et son
2522
+ // filtre est relâché s'il portait sur une boîte qui vient de disparaître.
2523
+ if (state.selection.kind === "box" && deleted.includes(state.selection.key)) {
2524
+ state.selection = { kind: "all", key: null };
2525
+ }
2526
+ state.threadId = null;
2527
+ await refresh();
2528
+ } catch (error) {
2529
+ purge.armed = false;
2530
+ updatePurgeButton();
2531
+ el.purgeResult.appendChild(node("p", "purge-error", `Erreur : ${error.message}`));
2532
+ }
2533
+ }
2534
+
2535
+ // --- Écouteurs -------------------------------------------------------------
2536
+
2537
+ el.purgeOpen.addEventListener("click", openPurge);
2538
+ el.purgeClose.addEventListener("click", () => el.purge.close());
2539
+ el.purgeCancel.addEventListener("click", () => el.purge.close());
2540
+ el.purgeBefore.addEventListener("change", resetPurge);
2541
+ el.purgeCreatedBefore.addEventListener("change", resetPurge);
2542
+ el.purgePreview.addEventListener("click", previewStale);
2543
+ el.purgePreviewCreated.addEventListener("click", previewCreated);
2544
+ el.purgePreviewEmpty.addEventListener("click", previewEmpty);
2545
+ el.purgeConfirm.addEventListener("click", confirmPurge);
2546
+
2547
+ el.refresh.addEventListener("click", () => refresh({ keepThread: true }));
2548
+
2549
+ el.tabs.addEventListener("click", (event) => {
2550
+ const tab = event.target.closest(".tab");
2551
+ if (tab) setView(tab.dataset.view);
2552
+ });
2553
+
2554
+ el.linksWindow.addEventListener("change", () => {
2555
+ linksView.windowMinutes = Number(el.linksWindow.value);
2556
+ saveLinksWindow();
2557
+ renderLinksView();
2558
+ });
2559
+
2560
+ // Échap relâche la boîte choisie sur la carte, sauf depuis un champ du volet.
2561
+ document.addEventListener("keydown", (event) => {
2562
+ if (event.key !== "Escape" || state.view !== "links" || !linksView.selected) return;
2563
+ if (event.target.closest && event.target.closest("input, select, dialog")) return;
2564
+ selectLinkBox(linksView.selected);
2565
+ });
2566
+
2567
+ // Le bouton reste en place une fois la colonne repliée : il est le seul moyen de la
2568
+ // rouvrir. Seul ce qu'il annonce change.
2569
+ el.collapse.addEventListener("click", () => {
2570
+ state.collapsed = !state.collapsed;
2571
+ el.console.classList.toggle("is-collapsed", state.collapsed);
2572
+ const label = state.collapsed ? "Déplier la liste des agents" : "Replier la liste des agents";
2573
+ el.collapse.title = state.collapsed ? "Déplier" : "Replier";
2574
+ el.collapse.setAttribute("aria-label", label);
2575
+ el.collapse.setAttribute("aria-expanded", String(!state.collapsed));
2576
+ });
2577
+
2578
+ el.chips.addEventListener("click", (event) => {
2579
+ const chip = event.target.closest(".chip");
2580
+ if (!chip) return;
2581
+ state.filter = chip.dataset.filter;
2582
+ for (const other of el.chips.querySelectorAll(".chip")) {
2583
+ other.classList.toggle("is-active", other === chip);
2584
+ }
2585
+ refresh({ keepThread: true });
2586
+ });
2587
+
2588
+ let searchTimer = null;
2589
+ el.search.addEventListener("input", (event) => {
2590
+ state.search = event.target.value.trim();
2591
+ clearTimeout(searchTimer);
2592
+ searchTimer = setTimeout(() => refresh({ keepThread: true }), 250);
2593
+ });
2594
+
2595
+ el.tokenReveal.addEventListener("click", () => {
2596
+ const shown = el.token.type === "text";
2597
+ el.token.type = shown ? "password" : "text";
2598
+ el.tokenReveal.setAttribute("aria-label", shown ? "Afficher le jeton" : "Masquer le jeton");
2599
+ el.token.focus();
2600
+ });
2601
+
2602
+ el.loginForm.addEventListener("submit", async (event) => {
2603
+ event.preventDefault();
2604
+ el.tokenError.textContent = "";
2605
+ el.tokenField.classList.remove("is-invalid");
2606
+ const token = el.token.value.trim();
2607
+ if (!token) return;
2608
+ const response = await fetch("/ui/login", {
2609
+ method: "POST",
2610
+ headers: { "content-type": "application/json" },
2611
+ credentials: "same-origin",
2612
+ body: JSON.stringify({ token }),
2613
+ }).catch(() => null);
2614
+ // Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
2615
+ el.token.value = "";
2616
+ el.token.type = "password";
2617
+ if (!response || !response.ok) {
2618
+ el.tokenField.classList.add("is-invalid");
2619
+ el.tokenError.textContent =
2620
+ response && response.status === 429
2621
+ ? "Trop de tentatives, réessayez dans quelques minutes."
2622
+ : "Jeton refusé. Vérifiez MESSENGER_ADMIN_TOKEN côté serveur.";
2623
+ return;
2624
+ }
2625
+ refresh();
2626
+ });
2627
+
2628
+ el.logout.addEventListener("click", async () => {
2629
+ await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
2630
+ state.selection = { kind: "all", key: null };
2631
+ state.expanded = new Set();
2632
+ state.showEmpty = new Set();
2633
+ state.threadId = null;
2634
+ state.openMessages = new Set();
2635
+ state.directory = new Map();
2636
+ state.groups = [];
2637
+ state.links = [];
2638
+ linksView.selected = null;
2639
+ linksView.panelFor = null;
2640
+ el.linksCanvas.replaceChildren();
2641
+ el.linksPanel.replaceChildren();
2642
+ el.agents.replaceChildren();
2643
+ el.threads.replaceChildren();
2644
+ renderEmptyReader();
2645
+ setLoggedIn(false);
2646
+ });
2647
+
2648
+ setInterval(() => {
2649
+ if (el.autoRefresh.checked && el.login.hidden) refresh({ keepThread: true });
2650
+ }, REFRESH_MS);
2651
+
2652
+ el.loginOrigin.textContent = `MCP Messenger · ${window.location.host}`;
2653
+ loadLinksWindow();
2654
+ renderEmptyReader();
2655
+ refresh();