Commit b2e098bd authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Un message direct qui n'avertit personne, et le texte d'un salon privé qui part à tous

Signalé à l'écran : la personne qui reçoit un message direct n'a aucune
notification. La dépêche partait bien — aucun écran ne l'écoutait. Il fallait
ouvrir « Messages directs » pour découvrir qu'on avait été appelé, ce qui pendant
une crise revient à ne pas l'être.

En cherchant à qui adresser la notification, j'ai trouvé plus grave.

── Le flux ne filtrait rien

Il transmettait chaque dépêche à chaque abonné de la crise, sans aucun filtre. Or
la charge d'un message de salon porte son **texte**. Le contenu d'un salon privé
partait donc à tous les membres de la crise par WebSocket, alors que la liste des
salons le masque, que la recherche s'y arrête, et que le produit promet le
contraire. Rien ne se voyait à l'écran : le client ignorait simplement les
dépêches d'un salon qu'il ne connaissait pas. Ignorer n'est pas ne pas recevoir.

Même chose pour les messages directs : tout le monde apprenait que deux personnes
se parlaient, avec l'identifiant de leur conversation — ce qu'un message direct
est précisément censé ne pas dire.

Les dépêches sont désormais adressées. Le tri se fait à la publication, où l'on
sait qui a le droit de voir, et non à la réception : une règle appliquée au bord
serait une règle que le prochain abonné oublierait. Six publications de salon, la
frappe en cours, et les directs. Une dépêche sans destinataire reste diffusée à
toute la crise, ce qui est juste pour ce que toute la crise peut lire.

── Les conversations flottantes

En bas à droite, plusieurs à la fois : pendant une crise on parle à trois
personnes en même temps. Elles vivent dans la coque, comme le panneau de
visioconférence, et pour la même raison — changer d'écran ne doit pas fermer une
conversation. Un message qui arrive ouvre sa fenêtre repliée : on est averti sans
être interrompu. Quatre au plus, la plus ancienne cède la place ; au-delà elles se
chevauchent et l'on n'en lit aucune.

Réduire et fermer sont deux gestes distincts : confondre les deux fait perdre le
fil qu'on voulait seulement mettre de côté.

Et un défaut que j'ai introduit puis corrigé, signalé à l'écran comme « la croix
ne fonctionne pas ». Elle fonctionnait, deux fois par seconde. Les rappels du
dock étaient recréés à chaque rendu et reconstruisaient le tableau des fenêtres
depuis la valeur capturée au rendu précédent : l'effet « dépliée, on est en train
de lire » en dépendait, repartait à chaque rendu, posait un tableau neuf,
provoquait un rendu — une boucle, qui défaisait la fermeture aussitôt.

Le correctif tient en une règle : cet état ne se pose jamais depuis une valeur,
toujours depuis une fonction du précédent, et la mise à jour rend le **même**
tableau quand rien ne change, ce qui arrête React net. La référence que j'avais
introduite pour contourner le problème disparaît avec lui. Un contrôle statique
interdit désormais la forme de code fautive : si quelqu'un réintroduit une pose
depuis une valeur, la suite rougit avant l'écran.

── Nommer quelqu'un

Là aussi la moitié existait : la colonne « mentions », son index, le champ de
l'événement, la projection et la route étaient là depuis l'origine. Seul le client
ne les envoyait jamais, et la dépêche ne les portait pas — écrire « @Maëlle » ne
nommait donc personne.

Taper « @ » propose les mêmes personnes que pour porter une action, au clavier
comme à la souris. Le texte garde le nom en clair et l'identifiant part à côté :
« @[Maëlle](uuid) » rendrait illisible le journal qui fait foi. Effacer le nom
après l'avoir choisi n'avertit plus personne.

« @tous », « @all », « @ici », « @here », « @canal », « @channel » interpellent le
salon — reconnus sur le texte, parce qu'un signal qui demande de cocher une case
en plus d'écrire le mot est un signal qu'on oublie. « @allocation » n'interpelle
personne.

Être nommé se signale même dans le salon qu'on a sous les yeux : c'est justement
le cas où l'on regarde ailleurs dans la page. La pastille est distincte du
compteur de non-lus — « douze messages » ne dit pas « on t'a demandé quelque
chose ».

Huit garanties neuves.

Ce qui manque encore, et que j'ai signalé à part : sans service worker ni Web
Push, tout cela s'arrête à l'onglet ouvert. EX-18 demande « un canal dédié et non
désactivable pour la mobilisation » ; le mobile a son heurtoir, le navigateur
n'a rien.

Ces fichiers portent aussi les styles de la main courante et la propriété que
son écran attend, qui viennent avec le lot précédent.

Co-Authored-By: Claude (RCA)
parent 1232a865
Loading
Loading
Loading
Loading
Loading
+18 −0
Changes for apps/api/src/adaptateurs/diffusion.ts: 18 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -20,6 +20,24 @@ export interface Depeche {
  seq: number | null;
  genre: string;
  charge: Record<string, unknown>;
  /**
   * À qui cette dépêche est destinée (EF-707a).
   *
   * Absente ou nulle : à toute la crise, ce qui convient à ce que toute la
   * crise peut voir. Renseignée : à ces comptes et à eux seuls.
   *
   * Ce champ répare une divulgation. Le flux transmettait chaque dépêche à
   * chaque abonné de la crise, sans filtre — or la charge d'un message de salon
   * porte son **texte**. Un salon privé diffusait donc son contenu à tout le
   * monde, alors que la liste des salons le masque et que le produit promet le
   * contraire. Rien ne le montrait à l'écran : le client ignorait simplement
   * les dépêches d'un salon qu'il ne connaissait pas.
   *
   * Le tri se fait à la publication, non à la réception : c'est l'appelant qui
   * sait qui a le droit de voir, et une règle appliquée au bord serait une règle
   * que le prochain abonné oublierait.
   */
  destinataires?: string[] | null | undefined;
}

type Abonne = (d: Depeche) => void;
+56 −0
Changes for apps/api/src/domaine/chat.ts: 56 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -163,6 +163,49 @@ export async function fermerSalon(
 * transforme la liste des canaux en carte des conversations qu'on ne devrait
 * pas savoir tenues.
 */
/**
 * Les mots qui interpellent tout le salon (EF-707b).
 *
 * La convention est celle que tout le monde connaît : les écrire suffit. Elle
 * est reconnue sur le texte plutôt que déclarée à part, parce qu'un signal qui
 * demande de cocher une case en plus d'écrire le mot est un signal qu'on
 * oublie — et qu'un « @tous » écrit sans effet serait pire que pas de « @tous »
 * du tout.
 */
const INTERPELLATIONS = ["@tous", "@all", "@ici", "@here", "@canal", "@channel"];

export function interpelleTous(texte: string): boolean {
  const bas = texte.toLowerCase();
  return INTERPELLATIONS.some((m) => {
    const i = bas.indexOf(m);
    if (i === -1) return false;
    // Suivi d'une lettre, ce n'est pas le mot : « @allocation » n'interpelle
    // personne.
    const suivant = bas[i + m.length];
    return suivant === undefined || !/[a-z0-9_]/.test(suivant);
  });
}

/**
 * À qui une dépêche de salon peut partir (EF-707a).
 *
 * Nulle pour un salon ouvert : la dépêche part à toute la crise, ce qui est
 * juste puisque tout le monde peut lire le salon. La liste des membres pour un
 * salon privé — sans quoi son texte partait à tout le monde, le client se
 * contentant de l'ignorer.
 */
export async function audienceSalon(
  sql: Sql, criseId: string, salonId: string,
): Promise<string[] | null> {
  const [s] = await sql<{ prive: boolean }[]>`
    select prive from salon where crise_id = ${criseId} and id = ${salonId}`;
  if (!s || !s.prive) return null;
  const membres = await sql<{ compte_id: string }[]>`
    select compte_id from salon_membre
     where crise_id = ${criseId} and salon_id = ${salonId}`;
  return membres.map((m) => m.compte_id);
}

export async function salons(
  sql: Sql, criseId: string, compteId: string, celluleId?: string,
): Promise<Salon[]> {
@@ -233,7 +276,16 @@ export async function envoyer(
    charge: {
      message_id: messageId, salon_id: e.salonId, texte: e.texte,
      auteur_id: ctx.acteurId, repond_a: e.repondA ?? null,
      // Les mentions voyagent avec la dépêche : la colonne et l'événement les
      // portaient depuis l'origine, mais le temps réel non — le client ne
      // pouvait donc pas savoir qu'on venait de le nommer.
      mentions: e.mentions ?? [],
      // « @tous » n'est pas stocké comme quarante mentions : c'est un mot du
      // texte, reconnu ici, et signalé le temps de la dépêche. L'inscrire
      // autrement demanderait de figer une audience qui change.
      tous: interpelleTous(e.texte),
    },
    destinataires: await audienceSalon(sql, e.criseId, e.salonId),
  });
  return resultat;
}
@@ -271,6 +323,7 @@ export async function editer(
  await publier({
    criseId, seq, genre: "message.edite",
    charge: { message_id: messageId, salon_id: m.salon_id, texte },
    destinataires: await audienceSalon(sql, criseId, m.salon_id),
  });
  return seq;
}
@@ -302,6 +355,7 @@ export async function supprimer(
  await publier({
    criseId, seq, genre: "message.supprime",
    charge: { message_id: messageId, salon_id: m.salon_id },
    destinataires: await audienceSalon(sql, criseId, m.salon_id),
  });
  return seq;
}
@@ -318,6 +372,7 @@ export async function epingler(
  await publier({
    criseId, seq: null, genre: "message.epingle",
    charge: { message_id: messageId, salon_id: m.salon_id, epingle },
    destinataires: await audienceSalon(sql, criseId, m.salon_id),
  });
}

@@ -332,6 +387,7 @@ export async function reagir(
  await publier({
    criseId, seq: null, genre: "message.reaction",
    charge: { message_id: messageId, salon_id: m.salon_id, emoji, ajoutee, compte_id: ctx.acteurId },
    destinataires: await audienceSalon(sql, criseId, m.salon_id),
  });
}

+20 −1
Changes for apps/api/src/domaine/directs.ts: 20 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -126,9 +126,28 @@ export async function envoyer(

  // Diffusé pour le temps réel, jamais journalisé : la dépêche ne porte pas de
  // numéro de séquence, puisqu'il n'y a pas de séquence.
  //
  // Adressée aux deux participants, et à eux seuls. Elle partait à toute la
  // crise : rien ne s'affichait chez les autres, mais tout le monde apprenait
  // que ces deux-là se parlaient — ce qu'un message direct est précisément
  // censé ne pas dire.
  const participants = await sql<{ compte_id: string }[]>`
    select compte_id from conversation_participant where conversation_id = ${conversationId}`;
  // Le nom de qui écrit : sans lui, la fenêtre qui s'ouvre chez l'autre n'a pas
  // de titre, et « Message direct » ne dit pas de qui.
  const [auteur] = await sql<{ nom: string }[]>`
    select nom from compte where id = ${auteurId}`;
  await publier({
    criseId: m!.crise_id, seq: null, genre: "message_direct.envoye",
    charge: { conversation_id: conversationId, message_id: m!.id, auteur_id: auteurId },
    charge: {
      conversation_id: conversationId, message_id: m!.id, auteur_id: auteurId,
      auteur_nom: auteur?.nom ?? "",
      // Assez pour signaler, sans porter le texte : une notification n'a pas
      // besoin du contenu, et le contenu se relit par la route qui vérifie
      // qu'on participe.
      apercu: texte.slice(0, 120),
    },
    destinataires: participants.map((p) => p.compte_id),
  });
  return m!.id;
}
+13 −2
Changes for apps/api/src/routes/chat.ts: 13 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -294,7 +294,15 @@ export function enregistrerRoutesChat(app: FastifyInstance, sql: Sql): void {
      if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(charge));
    };

    const desabonner = canal.abonner(id, (d: Depeche) => envoyer({ t: "depeche", ...d }));
    const desabonner = canal.abonner(id, (d: Depeche) => {
      // Une dépêche adressée ne part qu'à ses destinataires. Sans ce filtre, le
      // texte d'un salon privé partait chez tout le monde — le client se
      // contentait de l'ignorer, ce qui n'est pas la même chose que ne pas le
      // recevoir.
      if (d.destinataires && !d.destinataires.includes(compte.id)) return;
      const { destinataires: _, ...visible } = d;
      envoyer({ t: "depeche", ...visible });
    });

    const annoncerPresence = async () => {
      await canal.marquerPresent(id, { compte_id: compte.id, nom: compte.nom, depuis: Date.now() });
@@ -326,9 +334,12 @@ export function enregistrerRoutesChat(app: FastifyInstance, sql: Sql): void {
        }

        if (ordre.t === "saisie" && ordre.salonId) {
          // Éphémère par nature : rien de tout cela n'entre au journal.
          // Éphémère par nature : rien de tout cela n'entre au journal. Adressé
          // tout de même — savoir qui écrit dans un salon privé est déjà une
          // information sur ce salon.
          await canal.publier({
            criseId: id, seq: null, genre: "saisie",
            destinataires: await chat.audienceSalon(sql, id, ordre.salonId),
            charge: {
              salon_id: ordre.salonId, compte_id: compte.id, nom: compte.nom,
              actif: ordre.actif !== false,
+39 −0
Changes for apps/api/src/verification/chat.ts: 39 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -453,6 +453,45 @@ export async function verifierChat(
  r.verifie("EF-707", "la recherche d'un non-membre ne remonte rien du canal privé",
    fuite.length === 0, `${fuite.length} extrait(s)`);

  // ── Le flux ne porte pas un salon privé hors de son audience (EF-707a) ───
  //
  // La divulgation réparée ici ne se voyait pas à l'écran : le flux transmettait
  // chaque dépêche à chaque abonné de la crise, sans filtre, et la charge d'un
  // message porte son **texte**. Le contenu d'un salon privé partait donc chez
  // tout le monde ; le client se contentait de l'ignorer, ce qui n'est pas la
  // même chose que ne pas le recevoir.
  r.verifie("EF-707a", "un salon privé adresse ses dépêches à ses seuls membres",
    (await chat.audienceSalon(sql, crise.criseId, prive))?.join() === compteId,
    JSON.stringify(await chat.audienceSalon(sql, crise.criseId, prive)));
  r.verifie("EF-707a", "un salon ouvert n'adresse rien : toute la crise peut le lire",
    (await chat.audienceSalon(sql, crise.criseId, thematique)) === null);

  // ── Nommer quelqu'un (EF-707b) ───────────────────────────────────────────
  //
  // La colonne, l'index, l'événement et la route portaient les mentions depuis
  // l'origine ; le client ne les envoyait jamais, et la dépêche ne les portait
  // pas. Écrire « @Maëlle » ne nommait donc personne.
  r.verifie("EF-707b", "« @tous » interpelle le salon",
    chat.interpelleTous("Point dans dix minutes @tous") === true);
  r.verifie("EF-707b", "et ses variantes usuelles aussi",
    ["@all", "@ici", "@here", "@canal", "@channel"]
      .every((m) => chat.interpelleTous(`urgent ${m}`)));
  r.verifie("EF-707b", "mais un mot qui commence pareil, non — « @allocation » "
    + "n'interpelle personne",
    chat.interpelleTous("dossier @allocation à relire") === false);

  const nomme = await chat.envoyer(sql, {
    criseId: crise.criseId, salonId: thematique,
    texte: "@Autre Compte peux-tu confirmer ?", mentions: [autre.compteId],
  }, ctx);
  const [avecMention] = await sql<{ mentions: string[] }[]>`
    select mentions from message where id = (
      select (charge->>'message_id')::uuid from evenement
       where crise_id = ${crise.criseId} and seq = ${nomme.seq})`;
  r.verifie("EF-707b", "la mention est inscrite avec le message",
    avecMention?.mentions.join() === autre.compteId,
    JSON.stringify(avecMention?.mentions));

  // Rouvrir : la porte se rouvre, le passé ne bouge pas.
  await chat.definirAcces(sql, crise.criseId, prive, false, [], ctx);
  r.verifie("EF-701", "rouvert, le canal redevient visible de toute la crise",
Loading