@imenam/mcp-messenger 1.0.19 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -9
- package/dist/src/server/app.js +17 -0
- package/dist/src/server/ui/app.js +202 -27
- package/dist/src/server/ui/index.html +11 -2
- package/dist/src/server/ui/styles.css +109 -2
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -385,7 +385,8 @@ correspondants et l'état de lecture :
|
|
|
385
385
|
la colonne l'écrit donc ainsi. Chaque agent est **replié par défaut** et se déplie d'un
|
|
386
386
|
chevron sur ses boîtes ; une boîte que son agent n'a pas nommée s'affiche à son adresse,
|
|
387
387
|
la seule chose qui la distingue alors de ses sœurs. Cliquer l'agent filtre les fils sur
|
|
388
|
-
**toutes ses sessions**, cliquer une boîte sur **cette seule session**.
|
|
388
|
+
**toutes ses sessions**, cliquer une boîte sur **cette seule session**. Recliquer ne
|
|
389
|
+
relâche rien : on revient à la vue générale par « Tous les agents », en tête de colonne. La colonne se
|
|
389
390
|
replie sur les seuls avatars d'agents quand la place manque, son bouton de repli restant
|
|
390
391
|
en place : c'est lui qui la rouvre.
|
|
391
392
|
|
|
@@ -401,8 +402,17 @@ correspondants et l'état de lecture :
|
|
|
401
402
|
identiques sont départagées par l'adresse pour que l'ordre ne bouge pas d'un
|
|
402
403
|
rafraîchissement à l'autre ;
|
|
403
404
|
- **la liste des fils**, du plus récemment actif au plus ancien, chacun résumé par son
|
|
404
|
-
nom, ses correspondants, son dernier message et sa priorité la plus haute.
|
|
405
|
-
|
|
405
|
+
nom, ses correspondants, son dernier message et sa priorité la plus haute. Les deux
|
|
406
|
+
correspondants y sont d'abord **deux pastilles côte à côte**, séparées et non superposées
|
|
407
|
+
— leurs initiales doivent rester lisibles. **Ces pastilles sont celles des agents, pas
|
|
408
|
+
des boîtes** : dans une liste, ce qu'on cherche du regard est le projet, et deux sessions
|
|
409
|
+
d'un même agent portent donc la même. Ce qui les distingue, le nom de la boîte, se lit au
|
|
410
|
+
survol, avec le projet, l'adresse et la mention de celle qui a écrit en dernier. Le
|
|
411
|
+
lecteur du fil suit la même règle, en tête de chaque message ;
|
|
412
|
+
- **un en-tête qui dit sur quoi la liste est filtrée** : sans filtre elle s'intitule
|
|
413
|
+
« Fils », et dès qu'un agent est choisi c'est lui le sujet — sa pastille en grand, son
|
|
414
|
+
nom en titre, et en dessous ce que le filtre recouvre, toutes ses sessions ou une seule ;
|
|
415
|
+
- **trois puces** au-dessus de la liste : tous, non lus, priorité haute ;
|
|
406
416
|
- **le lecteur du fil** : la conversation entière dans l'ordre, un bloc par message, avec
|
|
407
417
|
qui écrit à qui — en clair, pas en adresses — et une pastille disant si le destinataire
|
|
408
418
|
l'a lu. **Le corps d'un message est rendu comme du markdown** : titres, listes,
|
|
@@ -459,7 +469,13 @@ chose :**
|
|
|
459
469
|
touchées depuis. **Une boîte créée après cette date est épargnée, même vide** — une
|
|
460
470
|
session qui vient de démarrer et n'a pas encore parlé n'est pas une boîte à l'abandon.
|
|
461
471
|
|
|
462
|
-
|
|
472
|
+
S'y ajoute la suppression d'**une boîte en particulier** : chaque ligne de la colonne de
|
|
473
|
+
gauche porte une corbeille, révélée au survol ou au clavier. Elle n'efface rien d'elle-même,
|
|
474
|
+
elle ouvre la même fenêtre sur cette seule boîte, avec ce qu'elle contient sous les yeux.
|
|
475
|
+
C'est la seule des trois qui ne demande aucun critère : l'adresse vient d'être montrée du
|
|
476
|
+
doigt, il n'y a rien à revérifier.
|
|
477
|
+
|
|
478
|
+
Toutes se font en deux temps, parce qu'elles ne se rattrapent pas :
|
|
463
479
|
|
|
464
480
|
1. **L'aperçu** liste les boîtes concernées, chacune avec son nom, son adresse, son nombre
|
|
465
481
|
de messages et sa dernière activité. Chaque ligne se décoche.
|
|
@@ -545,6 +561,7 @@ la console (cookie), qui elle n'ouvre rien d'autre.
|
|
|
545
561
|
| `GET` | `/admin/mailboxes/stale` | Boîtes sans activité depuis `before`, avec ce que chacune contient |
|
|
546
562
|
| `GET` | `/admin/mailboxes/empty` | Boîtes par lesquelles rien n'est jamais passé, sans critère de date |
|
|
547
563
|
| `POST` | `/admin/mailboxes/purge` | Supprime les boîtes nommées (`mailboxes`, plus `before` **ou** `empty: true`) — refusé si l'une d'elles ne répond plus au critère |
|
|
564
|
+
| `DELETE` | `/admin/mailboxes/:id` | Supprime une boîte désignée, quel que soit son état — 404 si l'adresse est inconnue |
|
|
548
565
|
| `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
|
|
549
566
|
|
|
550
567
|
---
|
|
@@ -568,8 +585,8 @@ la console (cookie), qui elle n'ouvre rien d'autre.
|
|
|
568
585
|
rendu en markdown par construction de nœuds, jamais par assignation de balisage, et la
|
|
569
586
|
CSP interdit tout script tiers.
|
|
570
587
|
- La console ne peut ni écrire ni modifier un message. Ses seules commandes destructrices
|
|
571
|
-
|
|
572
|
-
|
|
588
|
+
suppriment des boîtes : réservées au jeton d'administration, confirmées deux fois, et
|
|
589
|
+
revérifiées par le serveur quand elles portent sur un lot.
|
|
573
590
|
- Derrière un reverse proxy TLS, exposer le serveur en `https://` et donner cette URL aux
|
|
574
591
|
agents via `MESSENGER_SERVER_URL`.
|
|
575
592
|
|
|
@@ -610,9 +627,9 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
|
|
|
610
627
|
|
|
611
628
|
### Tests
|
|
612
629
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
nominatif d'une boîte qui ne répond plus au critère, la reprise de la boîte d'une session interrompue (clé de session de
|
|
630
|
+
273 tests couvrant : les suppressions de boîtes commandées depuis la console — une boîte
|
|
631
|
+
désignée à la main, les vides, celles sans activité depuis une date — avec leur aperçu,
|
|
632
|
+
leur double confirmation et le refus nominatif d'une boîte qui ne répond plus au critère, la reprise de la boîte d'une session interrompue (clé de session de
|
|
616
633
|
l'hôte, boîte orpheline, boîte d'une session vivante jamais reprise), le rendu markdown des messages et l'aplatissement des aperçus (dont
|
|
617
634
|
l'innocuité du rendu : ni HTML interprété, ni lien vers un autre schéma qu'`http`, `https`
|
|
618
635
|
ou `mailto`), le stockage et l'isolation des boîtes, la validation de configuration,
|
package/dist/src/server/app.js
CHANGED
|
@@ -264,6 +264,23 @@ export function createApp({ store, config }) {
|
|
|
264
264
|
logger.warn(`admin purge: ${removed.mailboxes} mailbox(es) deleted, ${reason}`);
|
|
265
265
|
res.json({ before: input.before ?? null, empty: input.empty ?? false, ...removed });
|
|
266
266
|
});
|
|
267
|
+
/**
|
|
268
|
+
* Suppression d'une boîte désignée à la main, quel que soit son état.
|
|
269
|
+
*
|
|
270
|
+
* Pas de critère à revérifier ici, et c'est voulu : les deux nettoyages en lot se
|
|
271
|
+
* protègent d'une liste qui aurait vieilli entre l'aperçu et la confirmation, alors que
|
|
272
|
+
* celle-ci porte sur une boîte que l'on vient de montrer du doigt. La boîte doit
|
|
273
|
+
* exister — supprimer une adresse inconnue serait un geste sans effet présenté comme un
|
|
274
|
+
* succès.
|
|
275
|
+
*/
|
|
276
|
+
admin.delete("/mailboxes/:id", (req, res) => {
|
|
277
|
+
const id = normalizeAgentId(String(req.params.id));
|
|
278
|
+
if (!store.getAgent(id))
|
|
279
|
+
throw new MessengerError(`mailbox not found: ${id}`, 404);
|
|
280
|
+
const removed = store.deleteMailboxes([id]);
|
|
281
|
+
logger.warn(`admin delete: mailbox ${id} deleted (${removed.messages} message(s) erased)`);
|
|
282
|
+
res.json({ mailbox: id, ...removed });
|
|
283
|
+
});
|
|
267
284
|
app.use("/admin", admin);
|
|
268
285
|
// Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
|
|
269
286
|
// contrôle par route (identité de l'agent) vient ensuite.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Console de supervision : les routes /admin du serveur, en lecture — à
|
|
2
|
-
// près, les
|
|
3
|
-
// détruisent. Elles
|
|
4
|
-
//
|
|
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
5
|
//
|
|
6
6
|
// Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
|
|
7
7
|
// jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
|
|
@@ -26,6 +26,7 @@ const ICONS = {
|
|
|
26
26
|
close: ["M18 6 6 18", "m6 6 12 12"],
|
|
27
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
28
|
chevron: ["m9 18 6-6-6-6"],
|
|
29
|
+
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"],
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
const state = {
|
|
@@ -81,6 +82,8 @@ const el = {
|
|
|
81
82
|
agents: document.getElementById("agents"),
|
|
82
83
|
agentsFoot: document.getElementById("agents-foot"),
|
|
83
84
|
collapse: document.getElementById("collapse"),
|
|
85
|
+
threadsAvatar: document.getElementById("threads-avatar"),
|
|
86
|
+
threadsName: document.getElementById("threads-name"),
|
|
84
87
|
threadsScope: document.getElementById("threads-scope"),
|
|
85
88
|
// Le panneau, pas la liste qu'il contient : c'est lui que le séparateur redimensionne.
|
|
86
89
|
threadsPane: document.querySelector(".threads"),
|
|
@@ -234,6 +237,31 @@ function avatar(agentId, extraClass) {
|
|
|
234
237
|
return avatarFor(agentId, label, title, extraClass, !who.registered);
|
|
235
238
|
}
|
|
236
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Pastille de l'agent, et non de la boîte.
|
|
242
|
+
*
|
|
243
|
+
* Dans une liste, ce qui se cherche du regard est le projet : deux sessions d'un même
|
|
244
|
+
* agent portent donc la même pastille, et c'est voulu — ce qui les distingue, le nom de
|
|
245
|
+
* la boîte, se lit au survol. La pastille de boîte reste celle du lecteur et du volet,
|
|
246
|
+
* là où l'on regarde une session en particulier.
|
|
247
|
+
*/
|
|
248
|
+
function agentAvatar(agentId, extraClass, title) {
|
|
249
|
+
const who = party(agentId);
|
|
250
|
+
const seed = who.agentName || agentId;
|
|
251
|
+
const label = who.agentName || who.box || shortAddress(agentId);
|
|
252
|
+
return avatarFor(seed, label, title ?? boxTitle(agentId), extraClass, !who.registered);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Ce qu'une pastille dit au survol : le projet, la session, l'adresse. */
|
|
256
|
+
function boxTitle(agentId) {
|
|
257
|
+
const who = party(agentId);
|
|
258
|
+
return [
|
|
259
|
+
who.agentName || "agent sans nom",
|
|
260
|
+
who.box || (who.registered ? "boîte sans nom" : "boîte jamais connectée"),
|
|
261
|
+
agentId,
|
|
262
|
+
].join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
237
265
|
// --- Dates -----------------------------------------------------------------
|
|
238
266
|
|
|
239
267
|
function formatDate(iso) {
|
|
@@ -284,13 +312,22 @@ async function api(path, params) {
|
|
|
284
312
|
return response.json();
|
|
285
313
|
}
|
|
286
314
|
|
|
287
|
-
/** Envoi d'une action à la console.
|
|
315
|
+
/** Envoi d'une action à la console. Les seules qui existent suppriment : elles passent par ici. */
|
|
288
316
|
async function post(path, body) {
|
|
317
|
+
return action("POST", path, body);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Suppression d'une ressource nommée par son adresse : pas de corps, l'URL suffit. */
|
|
321
|
+
async function remove(path) {
|
|
322
|
+
return action("DELETE", path, null);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function action(method, path, body) {
|
|
289
326
|
const response = await fetch(path, {
|
|
290
|
-
method
|
|
327
|
+
method,
|
|
291
328
|
headers: { "content-type": "application/json", accept: "application/json" },
|
|
292
329
|
credentials: "same-origin",
|
|
293
|
-
body: JSON.stringify(body),
|
|
330
|
+
body: body === null ? undefined : JSON.stringify(body),
|
|
294
331
|
});
|
|
295
332
|
if (response.status === 401) {
|
|
296
333
|
setLoggedIn(false);
|
|
@@ -409,9 +446,16 @@ function isSelected(kind, key) {
|
|
|
409
446
|
return state.selection.kind === kind && state.selection.key === key;
|
|
410
447
|
}
|
|
411
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Choisit ce que la liste des fils montre.
|
|
451
|
+
*
|
|
452
|
+
* Recliquer sur la sélection courante ne la relâche pas : cliquer un agent veut dire
|
|
453
|
+
* « montre-moi cet agent », et le redire ne peut pas vouloir dire le contraire. On revient
|
|
454
|
+
* à la vue générale par « Tous les agents », en tête de colonne, là où on l'attend.
|
|
455
|
+
*/
|
|
412
456
|
function select(kind, key) {
|
|
413
|
-
|
|
414
|
-
state.selection =
|
|
457
|
+
if (isSelected(kind, key)) return;
|
|
458
|
+
state.selection = { kind, key };
|
|
415
459
|
state.threadId = null;
|
|
416
460
|
state.messageId = null;
|
|
417
461
|
el.sheet.hidden = true;
|
|
@@ -603,10 +647,37 @@ function boxRow(box) {
|
|
|
603
647
|
if (box.unread > 0) row.appendChild(node("span", "count count--accent", String(box.unread)));
|
|
604
648
|
row.title = `${box.id}\n${plural(box.sent, "envoyé")} · ${plural(box.received, "reçu")}`;
|
|
605
649
|
row.addEventListener("click", () => select("box", box.id));
|
|
606
|
-
|
|
650
|
+
|
|
651
|
+
// La ligne et son bouton de suppression sont deux boutons voisins, jamais imbriqués :
|
|
652
|
+
// un bouton dans un bouton n'est pas du HTML, et le clic n'y serait pas dissociable.
|
|
653
|
+
const line = node("div", "box-line");
|
|
654
|
+
line.appendChild(row);
|
|
655
|
+
line.appendChild(deleteBoxButton(box));
|
|
656
|
+
item.appendChild(line);
|
|
607
657
|
return item;
|
|
608
658
|
}
|
|
609
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Suppression d'une boîte, à la main.
|
|
662
|
+
*
|
|
663
|
+
* Le bouton n'efface rien : il ouvre la fenêtre de nettoyage sur cette seule boîte, avec
|
|
664
|
+
* ce qu'elle contient sous les yeux et la même double confirmation que les nettoyages en
|
|
665
|
+
* lot. Un geste destructeur à un clic d'une liste ne peut pas être plus court que ça.
|
|
666
|
+
*/
|
|
667
|
+
function deleteBoxButton(box) {
|
|
668
|
+
const label = `Supprimer la boîte ${boxLabel(box)}`;
|
|
669
|
+
const remove = node("button", "box-delete");
|
|
670
|
+
remove.type = "button";
|
|
671
|
+
remove.title = label;
|
|
672
|
+
remove.setAttribute("aria-label", label);
|
|
673
|
+
remove.appendChild(icon("trash"));
|
|
674
|
+
remove.addEventListener("click", (event) => {
|
|
675
|
+
event.stopPropagation();
|
|
676
|
+
openPurgeFor(box);
|
|
677
|
+
});
|
|
678
|
+
return remove;
|
|
679
|
+
}
|
|
680
|
+
|
|
610
681
|
/** Nombre de fils affiché en pied de colonne, conservé entre deux rendus locaux. */
|
|
611
682
|
function currentThreadCount() {
|
|
612
683
|
return state.threadCount || 0;
|
|
@@ -635,7 +706,7 @@ function selectionLabel() {
|
|
|
635
706
|
|
|
636
707
|
function renderThreads(threads) {
|
|
637
708
|
state.threadCount = threads.length;
|
|
638
|
-
|
|
709
|
+
renderThreadsHead(threads.length);
|
|
639
710
|
|
|
640
711
|
el.threads.replaceChildren();
|
|
641
712
|
if (threads.length === 0) {
|
|
@@ -648,6 +719,43 @@ function renderThreads(threads) {
|
|
|
648
719
|
for (const thread of threads) el.threads.appendChild(threadRow(thread));
|
|
649
720
|
}
|
|
650
721
|
|
|
722
|
+
/**
|
|
723
|
+
* En-tête de la liste : ce sur quoi elle est filtrée, en grand.
|
|
724
|
+
*
|
|
725
|
+
* Sans filtre, elle s'intitule « Fils » et rien d'autre. Dès qu'un agent est choisi, c'est
|
|
726
|
+
* lui le sujet : sa pastille et son nom prennent la place du titre, et la ligne en dessous
|
|
727
|
+
* dit ce que le filtre recouvre — toutes ses sessions, ou une seule.
|
|
728
|
+
*/
|
|
729
|
+
function renderThreadsHead(count) {
|
|
730
|
+
const { kind, key } = state.selection;
|
|
731
|
+
const fils = plural(count, "fil");
|
|
732
|
+
|
|
733
|
+
if (kind === "all") {
|
|
734
|
+
el.threadsAvatar.hidden = true;
|
|
735
|
+
el.threadsAvatar.replaceChildren();
|
|
736
|
+
el.threadsName.textContent = "Fils";
|
|
737
|
+
el.threadsScope.textContent = `Toutes les boîtes · ${fils}`;
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Une boîte est d'abord une session de son agent : c'est donc l'agent qui s'affiche,
|
|
742
|
+
// et le nom de la boîte qui précise de quelle session il s'agit.
|
|
743
|
+
const address = kind === "box" ? key : (state.groups.find((g) => g.key === key)?.boxes[0] || {}).id;
|
|
744
|
+
const who = address ? party(address) : null;
|
|
745
|
+
const sessions = kind === "agent" ? (state.groups.find((g) => g.key === key)?.boxes.length ?? 0) : 0;
|
|
746
|
+
const box = kind === "box" ? state.directory.get(key) : null;
|
|
747
|
+
|
|
748
|
+
el.threadsAvatar.hidden = false;
|
|
749
|
+
el.threadsAvatar.replaceChildren(
|
|
750
|
+
address ? agentAvatar(address, "avatar--lg") : avatarFor(key, key, key, "avatar--lg", false)
|
|
751
|
+
);
|
|
752
|
+
el.threadsName.textContent = kind === "agent" ? key : who?.agentName || selectionLabel();
|
|
753
|
+
el.threadsScope.textContent =
|
|
754
|
+
kind === "agent"
|
|
755
|
+
? `${plural(sessions, "session")} · ${fils}`
|
|
756
|
+
: `${box ? boxLabel(box) : shortAddress(key)} · ${fils}`;
|
|
757
|
+
}
|
|
758
|
+
|
|
651
759
|
function emptyReason() {
|
|
652
760
|
if (state.search) return "Aucun fil ne correspond à cette recherche.";
|
|
653
761
|
if (state.filter === "unread") return "Tout est lu de ce côté.";
|
|
@@ -672,7 +780,7 @@ function threadRow(thread) {
|
|
|
672
780
|
top.appendChild(node("span", "thread-time", formatShort(thread.last_message_at)));
|
|
673
781
|
row.appendChild(top);
|
|
674
782
|
|
|
675
|
-
row.appendChild(
|
|
783
|
+
row.appendChild(threadParticipants(thread));
|
|
676
784
|
|
|
677
785
|
const bottom = node("div", "thread-bottom");
|
|
678
786
|
bottom.appendChild(node("span", "thread-preview", thread.last_message_preview));
|
|
@@ -686,6 +794,29 @@ function threadRow(thread) {
|
|
|
686
794
|
return item;
|
|
687
795
|
}
|
|
688
796
|
|
|
797
|
+
/**
|
|
798
|
+
* Qui parle dans ce fil : les pastilles d'abord, les noms ensuite.
|
|
799
|
+
*
|
|
800
|
+
* Un fil lie exactement deux boîtes, la pastille suffit donc à les distinguer d'un coup
|
|
801
|
+
* d'œil, sans compter ni déborder. Chaque pastille garde la couleur de son adresse d'un
|
|
802
|
+
* écran à l'autre, et dit au survol tout ce qu'on peut vouloir savoir de la boîte : le
|
|
803
|
+
* projet d'où elle écrit, ce dont cette session s'occupe, son adresse, et si c'est elle
|
|
804
|
+
* qui a écrit en dernier.
|
|
805
|
+
*/
|
|
806
|
+
function threadParticipants(thread) {
|
|
807
|
+
const who = node("div", "thread-who");
|
|
808
|
+
|
|
809
|
+
const stack = node("div", "avatar-stack");
|
|
810
|
+
for (const id of thread.participants) {
|
|
811
|
+
const last = id === thread.last_message_from ? "\na écrit le dernier message" : "";
|
|
812
|
+
stack.appendChild(agentAvatar(id, "avatar--sm avatar--stack", `${boxTitle(id)}${last}`));
|
|
813
|
+
}
|
|
814
|
+
who.appendChild(stack);
|
|
815
|
+
|
|
816
|
+
who.appendChild(node("span", "thread-names", thread.participants.map((id) => party(id).name).join(" · ")));
|
|
817
|
+
return who;
|
|
818
|
+
}
|
|
819
|
+
|
|
689
820
|
// --- Lecteur du fil --------------------------------------------------------
|
|
690
821
|
|
|
691
822
|
function renderEmptyReader() {
|
|
@@ -714,7 +845,9 @@ function renderThread(data) {
|
|
|
714
845
|
|
|
715
846
|
const meta = node("div", "reader-meta");
|
|
716
847
|
const stack = node("div", "avatar-stack");
|
|
717
|
-
|
|
848
|
+
// Le projet d'abord : deux sessions d'un même agent portent la même pastille, le nom
|
|
849
|
+
// de la boîte se lisant au survol.
|
|
850
|
+
for (const id of participants) stack.appendChild(agentAvatar(id, "avatar--sm avatar--stack"));
|
|
718
851
|
meta.appendChild(stack);
|
|
719
852
|
meta.appendChild(
|
|
720
853
|
node(
|
|
@@ -745,7 +878,7 @@ function messageRow(message, isLast) {
|
|
|
745
878
|
else if (isLast) classes.push("is-last");
|
|
746
879
|
const row = node("div", classes.join(" "));
|
|
747
880
|
|
|
748
|
-
row.appendChild(
|
|
881
|
+
row.appendChild(agentAvatar(message.from));
|
|
749
882
|
|
|
750
883
|
const column = node("div", "message-body-col");
|
|
751
884
|
const head = node("div", "message-head");
|
|
@@ -1100,6 +1233,29 @@ function openPurge() {
|
|
|
1100
1233
|
el.purge.showModal();
|
|
1101
1234
|
}
|
|
1102
1235
|
|
|
1236
|
+
/**
|
|
1237
|
+
* Ouvre la fenêtre sur une seule boîte, celle qu'on vient de désigner dans la colonne.
|
|
1238
|
+
*
|
|
1239
|
+
* Rien à demander au serveur : l'annuaire vient d'être lu, et ce qu'il en dit est ce que
|
|
1240
|
+
* la ligne cliquée affiche. La liste est donc dressée sur place, avec la même mise en
|
|
1241
|
+
* forme et la même confirmation que les nettoyages en lot.
|
|
1242
|
+
*/
|
|
1243
|
+
function openPurgeFor(box) {
|
|
1244
|
+
openPurge();
|
|
1245
|
+
purge.mode = "one";
|
|
1246
|
+
purge.mailboxes = [
|
|
1247
|
+
{
|
|
1248
|
+
id: box.id,
|
|
1249
|
+
agent_name: box.agent_name,
|
|
1250
|
+
display_name: box.display_name,
|
|
1251
|
+
last_activity_at: box.last_activity_at,
|
|
1252
|
+
messages: box.sent + box.received,
|
|
1253
|
+
},
|
|
1254
|
+
];
|
|
1255
|
+
purge.selected = new Set([box.id]);
|
|
1256
|
+
renderPurgePreview();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1103
1259
|
/** Toute modification remet la confirmation à zéro : on ne supprime que ce qu'on vient de voir. */
|
|
1104
1260
|
function resetPurge() {
|
|
1105
1261
|
purge.mode = null;
|
|
@@ -1141,19 +1297,28 @@ async function previewPurge(mode, load, before = null) {
|
|
|
1141
1297
|
function renderPurgePreview() {
|
|
1142
1298
|
const container = document.createDocumentFragment();
|
|
1143
1299
|
const count = purge.mailboxes.length;
|
|
1144
|
-
// Le critère est rappelé sous les yeux de qui va confirmer : les
|
|
1145
|
-
// remplissent la même liste, et
|
|
1146
|
-
const since =
|
|
1147
|
-
const
|
|
1148
|
-
purge.mode === "empty" ? plural(count, "boîte vide", "boîtes vides") : `${plural(count, "boîte")}${since}`;
|
|
1300
|
+
// Le critère est rappelé sous les yeux de qui va confirmer : les trois suppressions
|
|
1301
|
+
// remplissent la même liste, et elles ne portent pas sur les mêmes boîtes.
|
|
1302
|
+
const since = ` sans activité depuis le ${formatDay(purge.before)}`;
|
|
1303
|
+
const criterion = purge.mode === "empty" ? "vide" : purge.mode === "one" ? "" : since;
|
|
1149
1304
|
|
|
1150
1305
|
if (count === 0) {
|
|
1151
|
-
container.appendChild(node("p", "purge-empty", `Aucune boîte ${
|
|
1306
|
+
container.appendChild(node("p", "purge-empty", `Aucune boîte ${criterion.trim()}.`));
|
|
1152
1307
|
el.purgeResult.replaceChildren(container);
|
|
1153
1308
|
return;
|
|
1154
1309
|
}
|
|
1155
1310
|
|
|
1156
|
-
container.appendChild(
|
|
1311
|
+
container.appendChild(
|
|
1312
|
+
node(
|
|
1313
|
+
"p",
|
|
1314
|
+
"purge-count",
|
|
1315
|
+
purge.mode === "one"
|
|
1316
|
+
? "Cette boîte, et tout ce que personne d'autre ne pourrait plus lire :"
|
|
1317
|
+
: purge.mode === "empty"
|
|
1318
|
+
? `${plural(count, "boîte vide", "boîtes vides")}.`
|
|
1319
|
+
: `${plural(count, "boîte")}${since}.`
|
|
1320
|
+
)
|
|
1321
|
+
);
|
|
1157
1322
|
|
|
1158
1323
|
const list = node("ul", "purge-list");
|
|
1159
1324
|
for (const mailbox of purge.mailboxes) {
|
|
@@ -1207,12 +1372,18 @@ async function confirmPurge() {
|
|
|
1207
1372
|
}
|
|
1208
1373
|
|
|
1209
1374
|
el.purgeConfirm.disabled = true;
|
|
1375
|
+
const deleted = [...purge.selected];
|
|
1210
1376
|
try {
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1377
|
+
// Une boîte désignée à la main s'efface par son adresse : il n'y a pas de critère à
|
|
1378
|
+
// revérifier, l'utilisateur vient de la montrer du doigt.
|
|
1379
|
+
const removed =
|
|
1380
|
+
purge.mode === "one"
|
|
1381
|
+
? await remove(`/admin/mailboxes/${encodeURIComponent(deleted[0])}`)
|
|
1382
|
+
: await post("/admin/mailboxes/purge", {
|
|
1383
|
+
// Le critère repart avec les adresses : c'est lui que le serveur revérifie.
|
|
1384
|
+
...(purge.mode === "empty" ? { empty: true } : { before: purge.before }),
|
|
1385
|
+
mailboxes: deleted,
|
|
1386
|
+
});
|
|
1216
1387
|
resetPurge();
|
|
1217
1388
|
el.purgeResult.replaceChildren(
|
|
1218
1389
|
node(
|
|
@@ -1221,7 +1392,11 @@ async function confirmPurge() {
|
|
|
1221
1392
|
`${plural(removed.mailboxes, "boîte")} supprimée(s), ${plural(removed.messages, "message")} et ${plural(removed.threads, "fil")} avec.`
|
|
1222
1393
|
)
|
|
1223
1394
|
);
|
|
1224
|
-
// La console montre encore ce qui n'existe plus : elle se relit entièrement
|
|
1395
|
+
// La console montre encore ce qui n'existe plus : elle se relit entièrement, et son
|
|
1396
|
+
// filtre est relâché s'il portait sur une boîte qui vient de disparaître.
|
|
1397
|
+
if (state.selection.kind === "box" && deleted.includes(state.selection.key)) {
|
|
1398
|
+
state.selection = { kind: "all", key: null };
|
|
1399
|
+
}
|
|
1225
1400
|
state.threadId = null;
|
|
1226
1401
|
state.messageId = null;
|
|
1227
1402
|
el.sheet.hidden = true;
|
|
@@ -168,8 +168,17 @@
|
|
|
168
168
|
|
|
169
169
|
<section class="threads">
|
|
170
170
|
<div class="threads-head">
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
<!--
|
|
172
|
+
L'en-tête dit sur quoi la liste est filtrée. Quand c'est un agent, il y tient la
|
|
173
|
+
place principale, pastille comprise : c'est le sujet de ce qu'on lit en dessous.
|
|
174
|
+
-->
|
|
175
|
+
<div class="threads-title">
|
|
176
|
+
<span class="threads-avatar" id="threads-avatar" hidden></span>
|
|
177
|
+
<div class="threads-heading">
|
|
178
|
+
<h2 id="threads-name">Fils</h2>
|
|
179
|
+
<p class="muted" id="threads-scope"></p>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
173
182
|
<div class="chips" id="chips">
|
|
174
183
|
<button type="button" class="chip is-active" data-filter="all">Tous</button>
|
|
175
184
|
<button type="button" class="chip" data-filter="unread">Non lus</button>
|
|
@@ -769,6 +769,54 @@ body {
|
|
|
769
769
|
padding: 7px 10px;
|
|
770
770
|
}
|
|
771
771
|
|
|
772
|
+
/* La ligne d'une boîte et son bouton de suppression, côte à côte. */
|
|
773
|
+
.box-line {
|
|
774
|
+
display: flex;
|
|
775
|
+
align-items: center;
|
|
776
|
+
gap: 2px;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
.box-line .box {
|
|
780
|
+
flex: 1;
|
|
781
|
+
min-width: 0;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/*
|
|
785
|
+
Le bouton ne se montre qu'au survol ou au clavier : une corbeille par ligne, affichée en
|
|
786
|
+
permanence, transformerait la colonne en champ de mines. Il reste dans le flux — révélé
|
|
787
|
+
par l'opacité et non par `display` — pour que la ligne ne change pas de largeur.
|
|
788
|
+
*/
|
|
789
|
+
.box-delete {
|
|
790
|
+
display: flex;
|
|
791
|
+
padding: 5px;
|
|
792
|
+
border: 0;
|
|
793
|
+
border-radius: var(--radius);
|
|
794
|
+
background: none;
|
|
795
|
+
color: var(--muted);
|
|
796
|
+
cursor: pointer;
|
|
797
|
+
opacity: 0;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.box-line:hover .box-delete,
|
|
801
|
+
.box-delete:focus-visible {
|
|
802
|
+
opacity: 1;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
.box-delete:hover {
|
|
806
|
+
background: var(--danger-soft);
|
|
807
|
+
color: var(--danger);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
.box-delete:focus-visible {
|
|
811
|
+
outline: 2px solid var(--accent);
|
|
812
|
+
outline-offset: -2px;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/* Colonne repliée : il n'y a plus de boîtes affichées, donc rien à supprimer. */
|
|
816
|
+
.is-collapsed .box-delete {
|
|
817
|
+
display: none;
|
|
818
|
+
}
|
|
819
|
+
|
|
772
820
|
.box .agent-name {
|
|
773
821
|
font-weight: 500;
|
|
774
822
|
font-size: 13px;
|
|
@@ -966,14 +1014,40 @@ body {
|
|
|
966
1014
|
padding: 16px 20px 12px;
|
|
967
1015
|
}
|
|
968
1016
|
|
|
1017
|
+
/* L'agent filtré est le sujet de la liste : sa pastille et son nom ouvrent le panneau. */
|
|
1018
|
+
.threads-title {
|
|
1019
|
+
display: flex;
|
|
1020
|
+
align-items: center;
|
|
1021
|
+
gap: 12px;
|
|
1022
|
+
min-width: 0;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
.threads-heading {
|
|
1026
|
+
min-width: 0;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
969
1029
|
.threads-head h2 {
|
|
970
1030
|
margin: 0;
|
|
971
|
-
font-size:
|
|
1031
|
+
font-size: 17px;
|
|
1032
|
+
line-height: 1.25;
|
|
1033
|
+
white-space: nowrap;
|
|
1034
|
+
overflow: hidden;
|
|
1035
|
+
text-overflow: ellipsis;
|
|
972
1036
|
}
|
|
973
1037
|
|
|
974
1038
|
.threads-head p {
|
|
975
1039
|
margin: 2px 0 0;
|
|
976
1040
|
font-size: 12px;
|
|
1041
|
+
white-space: nowrap;
|
|
1042
|
+
overflow: hidden;
|
|
1043
|
+
text-overflow: ellipsis;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
.avatar--lg {
|
|
1047
|
+
width: 40px;
|
|
1048
|
+
height: 40px;
|
|
1049
|
+
font-size: 15px;
|
|
1050
|
+
flex: 0 0 auto;
|
|
977
1051
|
}
|
|
978
1052
|
|
|
979
1053
|
.chips {
|
|
@@ -1100,15 +1174,48 @@ body {
|
|
|
1100
1174
|
color: var(--accent);
|
|
1101
1175
|
}
|
|
1102
1176
|
|
|
1177
|
+
/* Les pastilles des deux correspondants, puis leurs noms : on voit qui parle avant de lire. */
|
|
1103
1178
|
.thread-who {
|
|
1179
|
+
display: flex;
|
|
1180
|
+
align-items: center;
|
|
1181
|
+
gap: 8px;
|
|
1182
|
+
margin-top: 4px;
|
|
1183
|
+
min-width: 0;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
.thread-names {
|
|
1104
1187
|
font-size: 12px;
|
|
1105
1188
|
color: var(--muted);
|
|
1106
|
-
margin-top: 2px;
|
|
1107
1189
|
white-space: nowrap;
|
|
1108
1190
|
overflow: hidden;
|
|
1109
1191
|
text-overflow: ellipsis;
|
|
1110
1192
|
}
|
|
1111
1193
|
|
|
1194
|
+
/* Dans une liste, la pastille sert de repère, pas de portrait : elle est plus petite. */
|
|
1195
|
+
.thread-who .avatar--sm {
|
|
1196
|
+
width: 22px;
|
|
1197
|
+
height: 22px;
|
|
1198
|
+
font-size: 10px;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/*
|
|
1202
|
+
Ici les pastilles ne se chevauchent pas. Le chevauchement du lecteur dit « ces gens vont
|
|
1203
|
+
ensemble » quand la place manque ; dans une liste qui se survole, il mange les initiales,
|
|
1204
|
+
et ce sont justement elles qui identifient la boîte. L'anneau qui détachait les pastilles
|
|
1205
|
+
superposées n'a alors plus rien à détacher.
|
|
1206
|
+
*/
|
|
1207
|
+
.thread-who .avatar-stack {
|
|
1208
|
+
gap: 4px;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
.thread-who .avatar-stack .avatar + .avatar {
|
|
1212
|
+
margin-left: 0;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
.thread-who .avatar--stack {
|
|
1216
|
+
box-shadow: none;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1112
1219
|
.thread-bottom {
|
|
1113
1220
|
display: flex;
|
|
1114
1221
|
align-items: center;
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const APP_VERSION = "1.0.
|
|
1
|
+
export declare const APP_VERSION = "1.0.22";
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Généré par scripts/generate-version.js — ne pas modifier à la main.
|
|
2
|
-
export const APP_VERSION = "1.0.
|
|
2
|
+
export const APP_VERSION = "1.0.22";
|