Commit 55899937 authored by Kourser's avatar Kourser
Browse files

Saisie hors jeu, notifications sonores, et destinataire de réponse

Écriture joueur fermée hors période de jeu. Le PlayerGuard bloquait déjà après la
clôture ; il applique maintenant une liste BLANCHE de statuts jouables (RUNNING,
PAUSED). Une liste blanche plutôt qu'une liste noire : un statut ajouté plus tard
sera bloqué par défaut, ce qui est le sens prudent. Les messages diffèrent selon
« pas encore commencé » et « c'est fini » — les deux appellent des réactions
opposées de la part du joueur.

PAUSED reste ouvert : une pause sert à régler un point d'animation, pas à geler la
cellule au milieu d'une phrase.

Deux exemptions, chacune pour une raison précise : le questionnaire de RETEX se
remplit APRÈS la clôture, et la vérification des accès se confirme AVANT le
démarrage. Sans la seconde, ce lot aurait cassé le comcheck — la liste blanche a
rendu le conflit visible.

Côté joueur, la saisie fermée réutilise le chemin `readOnly` construit pour « voir
comme » : il masquait déjà chaque affordance d'écriture, il suffisait de l'élargir.
Bandeau d'attente distinct du bandeau d'observation.

Notifications. Signal sonore et pastille de compteur à l'arrivée d'un mail ou d'un
message de chat, dans la navigation joueur. Le son se coupe depuis le header, à côté
du sélecteur de thème, côté joueur comme côté console ; la préférence est retenue par
navigateur. `ping()` lit la préférence directement plutôt que de la recevoir en
contexte : les émetteurs sont dispersés et n'ont pas tous accès à un fournisseur.

Destinataire de réponse. Répondre à un mail reçu EN DIRECT visait « Animation », y
compris entre deux joueurs : le gestionnaire temps réel forçait `fromCharacterId` à
null, et l'événement diffusé ne portait pas cette information. Le champ est ajouté à
`MailNewEvent` et renseigné aux trois émissions (composition joueur, réponse de
l'animation, inject e-mail). Après rechargement le bug disparaissait, ce qui explique
qu'il ait pu passer inaperçu.

Chat en pleine hauteur : il avait un simple `min-height`, donc occupait le haut de la
page et laissait la moitié basse vide.

La réponse de l'animation aux messages joueurs était déjà livrée en bdef3c32, avec son
signal sonore.

Non compilé ni exécuté (pas de toolchain Node dans l'environnement utilisé).

Co-Authored-By: Claude (RCA)
parent 6d775a7b
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -451,6 +451,8 @@ export class InjectsService {
        recipientId: r.id,
        messageId: created.id,
        from: senderName,
        // Personnage expediteur de l'inject : permet a « Repondre » de le viser.
        fromCharacterId: inject.senderCharId,
        subject: sub(payload.subject),
        body: sub(payload.body),
        origin: 'INJECT',
+2 −0
Original line number Diff line number Diff line
@@ -262,6 +262,7 @@ export class MailboxService {
          recipientId: r.id,
          messageId: created.id,
          from: authorLabel,
          fromCharacterId: me.characterId,
          subject: dto.subject,
          body: dto.body,
          origin: 'PARTICIPANT',
@@ -355,6 +356,7 @@ export class MailboxService {
      recipientId: r.id,
      messageId: created.id,
      from: authorLabel,
      fromCharacterId: target.character?.id ?? null,
      subject,
      body,
      origin: 'INJECT',
+25 −8
Original line number Diff line number Diff line
@@ -19,11 +19,21 @@ import {
const READ_METHODS = new Set(['GET', 'HEAD']);

/**
 * Ecritures encore permises apres la cloture de l'exercice.
 * Le questionnaire de RETEX se remplit APRES coup : le fermer avec le reste
 * viderait de son sens la collecte proposee au moment de la cloture.
 * Ecritures permises hors periode de jeu, chacune pour une raison precise :
 * - le questionnaire de RETEX se remplit APRES la cloture, c'est meme la qu'on le
 *   propose aux joueurs ;
 * - la verification des acces se confirme AVANT le demarrage, c'est tout son objet.
 * Les fermer avec le reste viderait ces deux briques de leur sens.
 */
const ALLOWED_AFTER_CLOSE = ['/play/retex/answers'];
const ALLOWED_OUTSIDE_PLAY = ['/play/retex/answers', '/play/comcheck/confirm'];

/**
 * Statuts pendant lesquels un joueur peut ecrire. Liste BLANCHE volontaire : un
 * statut ajoute plus tard sera bloque par defaut, ce qui est le sens prudent.
 * PAUSED reste ouvert — une pause sert a regler un point d'animation, pas a geler
 * la cellule au milieu d'une phrase.
 */
const WRITABLE_STATUSES = new Set(['RUNNING', 'PAUSED']);

/**
 * Exige une session joueur valide et attache `req.player`.
@@ -69,14 +79,21 @@ export class PlayerGuard implements CanActivate {
      );
    }

    // Exercice cloture : plus aucune ecriture joueur, hors RETEX. La verification
    // Hors periode de jeu : plus aucune ecriture joueur, hors RETEX. La verification
    // ne porte que sur les ecritures — les lectures ne paient pas de requete.
    if (!isRead && !ALLOWED_AFTER_CLOSE.some((suffix) => req.path.endsWith(suffix))) {
    if (!isRead && !ALLOWED_OUTSIDE_PLAY.some((suffix) => req.path.endsWith(suffix))) {
      const exercise = await this.prisma
        .forTenant(p.tid)
        .exercise.findFirst({ where: { id: p.eid }, select: { status: true } });
      if (exercise?.status === 'CLOSED' || exercise?.status === 'ARCHIVED') {
        throw new ForbiddenException("L'exercice est clôturé : la saisie est terminée");
      const status = exercise?.status;
      if (status && !WRITABLE_STATUSES.has(status)) {
        // Message distinct : « pas encore commence » et « c'est fini » demandent
        // des reactions opposees de la part du joueur.
        throw new ForbiddenException(
          status === 'CLOSED' || status === 'ARCHIVED'
            ? "L'exercice est clôturé : la saisie est terminée"
            : "L'exercice n'a pas encore démarré : la saisie n'est pas ouverte",
        );
      }
    }

+11 −0
Original line number Diff line number Diff line
@@ -7,6 +7,7 @@ import { KitsLibrary } from './KitsLibrary';
import { RetexTemplates } from './RetexTemplates';
import { Organisation } from './Organisation';
import { useTheme } from './theme';
import { useSoundPref } from './sound';
import { useT } from './i18n';

type Section = 'exercises' | 'kits' | 'retex-templates' | 'organisation' | 'users';
@@ -19,6 +20,7 @@ export function Dashboard({
  onLogout: () => void;
}): JSX.Element {
  const { theme, toggle } = useTheme();
  const sound = useSoundPref();
  const { t, lang, setLang } = useT();
  const isAdmin = me.role === 'TENANT_ADMIN';
  const [section, setSection] = useState<Section>('exercises');
@@ -70,6 +72,15 @@ export function Dashboard({
                EN
              </button>
            </div>
            <button
              className="icon-btn"
              onClick={sound.toggle}
              title={sound.enabled ? t('Couper le son') : t('Rétablir le son')}
              aria-label={sound.enabled ? t('Couper le son') : t('Rétablir le son')}
              aria-pressed={!sound.enabled}
            >
              {sound.enabled ? '🔔' : '🔕'}
            </button>
            <button
              className="icon-btn"
              onClick={toggle}
+69 −15
Original line number Diff line number Diff line
@@ -19,6 +19,8 @@ import { Directory } from './Directory';
import { ComcheckBanner } from './Comcheck';
import { RetexForm } from './RetexQuestionnaire';
import { clearImpersonation, setImpersonation } from './impersonation';
import { useSoundPref } from './sound';
import { ping } from './notify';

type State =
  | { kind: 'loading' }
@@ -46,6 +48,7 @@ export function Player({
}): JSX.Element {
  const { t, lang, setLang } = useT();
  const { theme, toggle } = useTheme();
  const sound = useSoundPref();
  const [state, setState] = useState<State>({ kind: 'loading' });
  const [view, setView] = useState<
    'mail' | 'chat' | 'social' | 'news' | 'docs' | 'directory' | 'log' | 'retex'
@@ -56,6 +59,13 @@ export function Player({
  const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
  /** Annonce de cloture : null tant que l'exercice tourne. */
  const [closed, setClosed] = useState<{ retexOpen: boolean } | null>(null);
  /** Statut suivi en direct : il conditionne l'ouverture de la saisie. */
  const [status, setStatus] = useState<string>('DRAFT');
  /** Messages de chat arrives alors que le joueur n'etait pas sur l'onglet. */
  const [chatUnread, setChatUnread] = useState(0);
  /** Vue courante lue depuis les gestionnaires temps reel, sans peremption de cloture. */
  const viewRef = useRef(view);
  viewRef.current = view;
  const [openDoc, setOpenDoc] = useState<{
    name: string;
    body: string;
@@ -84,6 +94,7 @@ export function Player({
        const session = token ? await api.playResolve(token) : await api.playMe();
        if (cancelled) return;
        setState({ kind: 'ready', session });
        setStatus(session.exercise.status);
        // Lien ouvert apres la cloture : l'annonce doit s'afficher tout de suite.
        if (session.exercise.status === 'CLOSED') setClosed({ retexOpen: false });
        setInbox(await api.playMailbox('inbox'));
@@ -104,7 +115,10 @@ export function Player({
                    messageId: m.messageId ?? rid,
                    from: m.from,
                    fromParticipantId: null,
                    fromCharacterId: null,
                    // Reprend l'expediteur diffuse : le mettre a null faisait
                    // repondre a « Animation » tout message recu en direct,
                    // y compris entre deux joueurs.
                    fromCharacterId: m.fromCharacterId ?? null,
                    to: [],
                    subject: m.subject,
                    body: m.body ?? '',
@@ -118,9 +132,17 @@ export function Player({
                  ...prev,
                ],
          );
          ping();
        });
        socket.on(RT.chatNew, () => {
          // Compte pour la pastille de navigation ; le detail des canaux est gere
          // dans Chat, qui a son propre compteur par canal.
          if (viewRef.current !== 'chat') setChatUnread((n) => n + 1);
          ping();
        });
        socket.on(RT.retexOpen, (e: { open: boolean }) => setRetexOpen(e.open));
        socket.on(RT.exerciseStatus, (e: { status: string; retexOpen?: boolean }) => {
          setStatus(e.status);
          if (e.status !== 'CLOSED') return;
          // La collecte peut avoir ete ouverte du meme geste : on l'annonce ici,
          // sans attendre l'evenement retex:open qui arrive separement.
@@ -171,6 +193,13 @@ export function Player({
  }

  const { session } = state;
  /*
   * La saisie est fermee avant le demarrage et apres la cloture. On reutilise le
   * chemin `readOnly` deja construit pour « voir comme » : il masque deja chaque
   * affordance d'ecriture, il suffit de l'elargir plutot que de le doubler.
   */
  const notStarted = status === 'DRAFT' || status === 'READY';
  const writeBlocked = readOnly || notStarted || status === 'CLOSED' || status === 'ARCHIVED';
  const list = folder === 'inbox' ? inbox : sent;
  const selected = list.find((m) => m.id === selectedId) ?? null;
  const unread = inbox.filter((m) => !m.readAt).length;
@@ -187,7 +216,7 @@ export function Player({
    setDraft(null);
    // En « voir comme », on ne marque rien comme lu : « qui a lu quoi » est une
    // observation de l'exercice, l'animateur ne doit pas la fausser en regardant.
    if (!readOnly && folder === 'inbox' && !item.readAt) {
    if (!writeBlocked && folder === 'inbox' && !item.readAt) {
      setInbox((prev) => prev.map((m) => (m.id === item.id ? { ...m, readAt: new Date().toISOString() } : m)));
      try {
        await api.playMarkRead(item.id);
@@ -237,6 +266,14 @@ export function Player({

  return (
    <div className="player" data-skin={session.exercise.playerSkin}>
      {notStarted && !readOnly && (
        <div className="wait-banner">
{t('L’exercice n’a pas encore démarré.')}{' '}
          <span className="muted">
            {t('Vous pouvez explorer votre espace ; l’envoi s’ouvrira au lancement.')}
          </span>
        </div>
      )}
      {readOnly ? (
        <div className="observe-banner">
          👁 {t('VOUS OBSERVEZ L’ESPACE DE')} <b>{observerLabel ?? session.character.name}</b>{' '}
@@ -251,7 +288,7 @@ export function Player({
        <div>
          <b>{session.character.name}</b>
          {session.character.title ? <span className="muted"> · {session.character.title}</span> : null}
          {readOnly ? null : editingProfile ? (
          {writeBlocked ? null : editingProfile ? (
            <span className="profile-edit">
              <input
                value={nameDraft}
@@ -290,6 +327,15 @@ export function Player({
        </div>
        <div className="player-bar-right">
          <span className="muted">{session.exercise.name}</span>
          <button
            className="icon-btn"
            onClick={sound.toggle}
            title={sound.enabled ? t('Couper le son') : t('Rétablir le son')}
            aria-label={sound.enabled ? t('Couper le son') : t('Rétablir le son')}
            aria-pressed={!sound.enabled}
          >
            {sound.enabled ? '🔔' : '🔕'}
          </button>
          <button
            className="icon-btn"
            onClick={toggle}
@@ -354,9 +400,17 @@ export function Player({
      <nav className="player-nav">
        <button className={view === 'mail' ? 'active' : ''} onClick={() => setView('mail')}>
          {t('Messagerie')}
          {unread > 0 && <span className="badge">{unread}</span>}
        </button>
        <button className={view === 'chat' ? 'active' : ''} onClick={() => setView('chat')}>
        <button
          className={view === 'chat' ? 'active' : ''}
          onClick={() => {
            setChatUnread(0);
            setView('chat');
          }}
        >
          {t('Chat')}
          {chatUnread > 0 && <span className="badge">{chatUnread}</span>}
        </button>
        <button className={view === 'social' ? 'active' : ''} onClick={() => setView('social')}>
          {t('Réseaux sociaux')}
@@ -399,7 +453,7 @@ export function Player({
            selfParticipantId={session.participant.id}
            // « Écrire » et « Discuter » créent du contenu : masqués en observation.
            onMessage={
              readOnly
              writeBlocked
                ? undefined
                : (characterId) => {
                    setSelectedId(null);
@@ -413,7 +467,7 @@ export function Player({
                  }
            }
            onChat={
              readOnly
              writeBlocked
                ? undefined
                : async (pid) => {
                    try {
@@ -433,8 +487,8 @@ export function Player({
        <div className="log-view">
          <MainCourante
            load={api.playLog}
            add={readOnly ? undefined : (input) => api.playAddLog(input)}
            update={readOnly ? undefined : (id, patch) => api.playUpdateLog(id, patch)}
            add={writeBlocked ? undefined : (input) => api.playAddLog(input)}
            update={writeBlocked ? undefined : (id, patch) => api.playUpdateLog(id, patch)}
          />
        </div>
      )}
@@ -451,8 +505,8 @@ export function Player({
          <Chat
            loadChannels={api.playChannels}
            loadMessages={api.playChannelMessages}
            post={readOnly ? undefined : api.playPostMessage}
            onCreateDm={readOnly ? undefined : api.playCreateDm}
            post={writeBlocked ? undefined : api.playPostMessage}
            onCreateDm={writeBlocked ? undefined : api.playCreateDm}
            loadContacts={api.playContacts}
            initialChannelId={chatInitial}
          />
@@ -464,9 +518,9 @@ export function Player({
          <Social
            load={api.playSocial}
            accountLabel={session.participant.displayName ?? session.character.name}
            accounts={readOnly ? [] : socialAccounts}
            publish={readOnly ? undefined : api.playPublishPost}
            reply={readOnly ? undefined : api.playReplyToPost}
            accounts={writeBlocked ? [] : socialAccounts}
            publish={writeBlocked ? undefined : api.playPublishPost}
            reply={writeBlocked ? undefined : api.playReplyToPost}
          />
        </div>
      )}
@@ -480,7 +534,7 @@ export function Player({
          <div className="gm-search">🔍 {t('Rechercher dans les messages')}</div>
        </div>
        <div className="mail-folders">
          {!readOnly && (
          {!writeBlocked && (
            <button className="btn primary compose-btn" onClick={startCompose}>
{t('Nouveau message')}
            </button>
@@ -565,7 +619,7 @@ export function Player({
                  ))}
                </div>
              )}
              {!readOnly && folder === 'inbox' && (
              {!writeBlocked && folder === 'inbox' && (
                <div className="reader-actions">
                  <button className="btn" onClick={() => startReply(selected)}>
                    {t('Répondre')}
Loading