Commit d2a11173 authored by Kourser's avatar Kourser
Browse files

Correctifs : main courante masquée, MP invisibles, auto-défilement, heure des posts

Main courante devenait noire et inutilisable dès la première entrée : collision de
classe CSS introduite avec le mur de crise. La main courante utilisait déjà `.wall`
pour l'heure murale ; `.wall { position: fixed; inset: 0; background: … }` en faisait
un calque opaque plein écran interceptant les clics. La classe préexistait, la racine
du mur devient `crisis-wall`.

Messages privés invisibles côté destinataire. La liste des canaux n'était rafraîchie
qu'au montage et après une création de MP par soi-même, et un message visant un canal
non sélectionné était silencieusement ignoré. Un message pour un canal inconnu
déclenche désormais un rafraîchissement, et chaque canal porte son compteur de
non-lus. Signal sonore ajouté, synthétisé en WebAudio : aucun fichier, aucune requête
sortante, le confinement reste entier.

Auto-défilement du fil de chat sur le dernier message.

Heure des publications : heure murale dans le fil joueur, T+ conservé dans la console.
L'animateur raisonne en temps d'exercice, le joueur voit une plateforme réaliste.

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

Co-Authored-By: Claude (RCA)
parent d7aa0886
Loading
Loading
Loading
Loading
+29 −1
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import type { ApiChatMessage, MailContact } from '@cythin/shared';
import { ApiError } from './api';
import { connectSocket, formatClock, RT } from './socket';
import { ping } from './notify';
import { useT } from './i18n';

interface ChannelItem {
@@ -38,8 +39,14 @@ export function Chat({
  const [newChannel, setNewChannel] = useState('');
  const [contacts, setContacts] = useState<MailContact[]>([]);
  const [error, setError] = useState<string | null>(null);
  /** Messages non lus par canal, pour le canal qui n'est pas à l'écran. */
  const [unread, setUnread] = useState<Record<string, number>>({});
  const selectedRef = useRef<string | null>(null);
  selectedRef.current = selectedId;
  /** Canaux connus localement : sert à détecter un canal qui vient d'être créé. */
  const knownRef = useRef<Set<string>>(new Set());
  knownRef.current = new Set(channels.map((c) => c.id));
  const bottomRef = useRef<HTMLDivElement>(null);

  const refreshChannels = useCallback(async () => {
    const chans = await loadChannels();
@@ -54,7 +61,18 @@ export function Chat({
    socket.on(RT.chatNew, (m: ApiChatMessage) => {
      if (m.channelId === selectedRef.current) {
        setMessages((prev) => (prev.some((x) => x.id === m.id) ? prev : [...prev, m]));
        return;
      }
      // Message pour un canal absent de la liste locale : c'est un fil qu'on vient
      // d'ouvrir avec nous. Sans ce rafraîchissement, l'onglet n'apparaissait jamais
      // et le message restait invisible jusqu'à ce qu'on écrive soi-même.
      if (!knownRef.current.has(m.channelId)) {
        void refreshChannels().catch(() => {
          /* la prochaine arrivée retentera */
        });
      }
      setUnread((prev) => ({ ...prev, [m.channelId]: (prev[m.channelId] ?? 0) + 1 }));
      ping();
    });
    return () => {
      socket.disconnect();
@@ -64,8 +82,15 @@ export function Chat({
  useEffect(() => {
    if (!selectedId) return;
    void loadMessages(selectedId).then(setMessages);
    // Ouvrir un canal vaut lecture : son compteur repart à zéro.
    setUnread((prev) => (prev[selectedId] ? { ...prev, [selectedId]: 0 } : prev));
  }, [selectedId, loadMessages]);

  // Auto-défilement : on suit toujours le dernier message du fil affiché.
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ block: 'end' });
  }, [messages]);

  useEffect(() => {
    if (loadContacts) void loadContacts().then(setContacts).catch(() => setContacts([]));
  }, [loadContacts]);
@@ -103,11 +128,12 @@ export function Chat({
        {channels.map((c) => (
          <button
            key={c.id}
            className={`chan ${selectedId === c.id ? 'active' : ''}`}
            className={`chan ${selectedId === c.id ? 'active' : ''} ${unread[c.id] ? 'has-unread' : ''}`}
            onClick={() => setSelectedId(c.id)}
          >
            <span className="h">{c.kind === 'DM' ? '@' : '#'}</span>
            {c.name}
            {unread[c.id] ? <span className="badge">{unread[c.id]}</span> : null}
          </button>
        ))}
        {channels.length === 0 && <p className="muted small">{t('Aucun canal.')}</p>}
@@ -148,6 +174,8 @@ export function Chat({
          ))}
          {selectedId && messages.length === 0 && <p className="muted">{t('Aucun message.')}</p>}
          {!selectedId && <p className="muted">{t('Sélectionnez un canal.')}</p>}
          {/* Ancre d'auto-défilement : toujours le bas du fil. */}
          <div ref={bottomRef} />
        </div>
        {error && <p className="err">{error}</p>}
        {post && selectedId && (
+3 −3
Original line number Diff line number Diff line
@@ -120,7 +120,7 @@ export function DisplayWall({ token }: { token: string }): JSX.Element {

  if (error) {
    return (
      <div className="wall wall-standalone">
      <div className="crisis-wall">
        <div className="wall-empty">
          <div>
            <h1>{t('Lien invalide')}</h1>
@@ -133,7 +133,7 @@ export function DisplayWall({ token }: { token: string }): JSX.Element {
  }
  if (!session) {
    return (
      <div className="wall wall-standalone">
      <div className="crisis-wall">
        <div className="wall-empty">{t('Ouverture du mur…')}</div>
      </div>
    );
@@ -227,7 +227,7 @@ function Wall({
  const shown = MODULES.filter((m) => enabled.includes(m.key));

  return (
    <div className="wall wall-standalone">
    <div className="crisis-wall">
      <header className="wall-bar">
        <span className="wall-sim">{t('SIMULATION')}</span>
        <span className="wall-ex">{snapshot?.data.exercise.name ?? ''}</span>
+5 −5
Original line number Diff line number Diff line
@@ -16,7 +16,7 @@ import type {
  SocialNetwork,
} from '@cythin/shared';
import { api, ApiError, type PostInput } from './api';
import { connectSocket, formatClock, RT } from './socket';
import { connectSocket, formatClock, formatWallTime, RT } from './socket';
import { useT } from './i18n';

// Contenu decoratif simule (ambiance de la plateforme, hors donnees de l'exercice).
@@ -380,7 +380,7 @@ function ReplyThread({ p }: { p: ApiSocialPost }): JSX.Element | null {
            <div className="post-reply-id">
              <b>{r.authorName}</b>
              <span className="muted">
                @{r.authorHandle.replace(/^@+/, '')} · {formatClock(r.atExerciseSec)}
                @{r.authorHandle.replace(/^@+/, '')} · {formatWallTime(r.createdAt)}
              </span>
            </div>
            <div>{r.body}</div>
@@ -455,7 +455,7 @@ function PostCard({ p }: { p: ApiSocialPost }): JSX.Element {
        <div className="ig-comments muted">
          {t('Voir les')} {commentCount} {t('commentaires')}
        </div>
        <div className="ig-time muted small">T+{formatClock(p.atExerciseSec)}</div>
        <div className="ig-time muted small">{formatWallTime(p.createdAt)}</div>
        <ReplyThread p={p} />
      </article>
    );
@@ -469,12 +469,12 @@ function PostCard({ p }: { p: ApiSocialPost }): JSX.Element {
          <div className="pn">
            {p.authorName}
            {p.network === 'X' ? (
              <span className="muted"> @{handle} · {formatClock(p.atExerciseSec)}</span>
              <span className="muted"> @{handle} · {formatWallTime(p.createdAt)}</span>
            ) : null}
          </div>
          {p.network === 'LINKEDIN' && (
            <div className="pmeta muted">
              {handle} · {formatClock(p.atExerciseSec)} · 🌐
              {handle} · {formatWallTime(p.createdAt)} · 🌐
            </div>
          )}
        </div>

apps/web/src/notify.ts

0 → 100644
+45 −0
Original line number Diff line number Diff line
/**
 * Signal sonore discret pour une arrivée qui réclame l'attention (message privé,
 * message de joueur vers l'animation, appel à préparer).
 *
 * Synthétisé par WebAudio plutôt que chargé comme fichier : aucun asset, aucune
 * requête, et le bac à sable reste hermétique. Les navigateurs exigent un geste
 * utilisateur préalable pour jouer du son — sans lui, l'appel échoue en silence,
 * ce qui est le comportement voulu : le signal visuel reste, lui, toujours présent.
 */

let ctx: AudioContext | null = null;

function audio(): AudioContext | null {
  try {
    ctx ??= new AudioContext();
    return ctx;
  } catch {
    return null;
  }
}

/** Bip court en deux notes. Sans effet si l'audio est indisponible ou bloqué. */
export function ping(): void {
  const ac = audio();
  if (!ac) return;
  try {
    if (ac.state === 'suspended') void ac.resume();
    const now = ac.currentTime;
    for (const [i, freq] of [880, 1170].entries()) {
      const osc = ac.createOscillator();
      const gain = ac.createGain();
      osc.type = 'sine';
      osc.frequency.value = freq;
      // Enveloppe courte : un signal, pas une alarme.
      gain.gain.setValueAtTime(0, now + i * 0.09);
      gain.gain.linearRampToValueAtTime(0.09, now + i * 0.09 + 0.01);
      gain.gain.linearRampToValueAtTime(0, now + i * 0.09 + 0.08);
      osc.connect(gain).connect(ac.destination);
      osc.start(now + i * 0.09);
      osc.stop(now + i * 0.09 + 0.09);
    }
  } catch {
    /* audio refuse : le signal visuel suffit */
  }
}
+10 −0
Original line number Diff line number Diff line
@@ -34,6 +34,16 @@ export function connectSocket(): Socket {
  return io({ withCredentials: true, ...(playSession ? { auth: { playSession } } : {}) });
}

/**
 * Heure murale HH:MM d'un horodatage ISO. Ce que voit un joueur sur une
 * plateforme simulee : l'heure du moment, pas le temps ecoule d'exercice.
 */
export function formatWallTime(iso: string): string {
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return '--:--';
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}

/** Formate un nombre de secondes en T+HH:MM:SS. */
export function formatClock(totalSeconds: number): string {
  const s = Math.max(0, Math.floor(totalSeconds));
Loading