discovery-media-player 0.1.0
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/CONTRAT.md +515 -0
- package/LICENSE +661 -0
- package/LICENSE-MIT +21 -0
- package/README.md +152 -0
- package/bin/__tests__/serve.test.js +84 -0
- package/bin/serve.js +115 -0
- package/context/__tests__/storage.test.js +99 -0
- package/context/standalone.js +224 -0
- package/context/storage.js +230 -0
- package/package.json +72 -0
- package/server/brands.js +44 -0
- package/server/browser.generated.js +7 -0
- package/server/handler.js +2657 -0
- package/server/presentations.js +319 -0
- package/server/shared.generated.js +93 -0
- package/server/shares.js +275 -0
- package/src/__tests__/bridge.test.ts +108 -0
- package/src/__tests__/chat.test.ts +138 -0
- package/src/__tests__/live.test.ts +211 -0
- package/src/__tests__/presentation-content.test.ts +132 -0
- package/src/__tests__/presentation-state.test.ts +81 -0
- package/src/__tests__/tracking.test.ts +217 -0
- package/src/__tests__/viewer.test.ts +133 -0
- package/src/bridge.ts +141 -0
- package/src/chat.ts +103 -0
- package/src/index.ts +14 -0
- package/src/live.ts +225 -0
- package/src/presentation-content.ts +109 -0
- package/src/presentation-state.ts +93 -0
- package/src/tracking.ts +250 -0
- package/src/viewer.ts +109 -0
- package/supabase/init.sql +242 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
clampZoom,
|
|
4
|
+
clampPage,
|
|
5
|
+
progressPercent,
|
|
6
|
+
arrowState,
|
|
7
|
+
fitWidth,
|
|
8
|
+
averageColor,
|
|
9
|
+
isImageDocument,
|
|
10
|
+
MIN_ZOOM,
|
|
11
|
+
MAX_ZOOM,
|
|
12
|
+
PRERENDER_MARGIN,
|
|
13
|
+
CURRENT_PAGE_MARGIN,
|
|
14
|
+
} from "../viewer";
|
|
15
|
+
|
|
16
|
+
describe("visionneuse — zoom", () => {
|
|
17
|
+
it("reste dans les bornes utilisables", () => {
|
|
18
|
+
expect(clampZoom(10)).toBe(MAX_ZOOM);
|
|
19
|
+
expect(clampZoom(0.1)).toBe(MIN_ZOOM);
|
|
20
|
+
expect(clampZoom(1)).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Sans arrondi, les clics successifs dérivent : 1 − 0.2 = 0.7999999999999999.
|
|
24
|
+
it("ne dérive pas au fil des clics", () => {
|
|
25
|
+
let z = 1;
|
|
26
|
+
for (let i = 0; i < 5; i++) z = clampZoom(z - 0.2);
|
|
27
|
+
expect(z).toBe(MIN_ZOOM);
|
|
28
|
+
let up = MIN_ZOOM;
|
|
29
|
+
for (let i = 0; i < 20; i++) up = clampZoom(up + 0.2);
|
|
30
|
+
expect(up).toBe(MAX_ZOOM);
|
|
31
|
+
expect(clampZoom(clampZoom(1 - 0.2))).toBe(0.8);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("survit à une valeur absurde", () => {
|
|
35
|
+
for (const junk of [NaN, undefined, null, "abc"]) {
|
|
36
|
+
expect(clampZoom(junk as unknown as number)).toBe(MIN_ZOOM);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("visionneuse — pagination", () => {
|
|
42
|
+
it("ramène toute page dans les bornes du document", () => {
|
|
43
|
+
expect(clampPage(0, 10)).toBe(1);
|
|
44
|
+
expect(clampPage(-3, 10)).toBe(1);
|
|
45
|
+
expect(clampPage(99, 10)).toBe(10);
|
|
46
|
+
expect(clampPage("4", 10)).toBe(4);
|
|
47
|
+
expect(clampPage(undefined, 10)).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Document pas encore mesuré : on ne doit ni diviser par zéro ni renvoyer 0.
|
|
51
|
+
it("tient avant que le document soit mesuré", () => {
|
|
52
|
+
expect(clampPage(5, 0)).toBe(1);
|
|
53
|
+
expect(progressPercent(1, 0)).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("calcule une progression entière", () => {
|
|
57
|
+
expect(progressPercent(1, 4)).toBe(25);
|
|
58
|
+
expect(progressPercent(4, 4)).toBe(100);
|
|
59
|
+
expect(progressPercent(1, 3)).toBe(33);
|
|
60
|
+
expect(progressPercent(99, 10)).toBe(100);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("désactive les flèches aux extrémités", () => {
|
|
64
|
+
expect(arrowState(1, 5)).toEqual({ prevDisabled: true, nextDisabled: false });
|
|
65
|
+
expect(arrowState(3, 5)).toEqual({ prevDisabled: false, nextDisabled: false });
|
|
66
|
+
expect(arrowState(5, 5)).toEqual({ prevDisabled: false, nextDisabled: true });
|
|
67
|
+
// Document non mesuré : « suivant » reste ouvert, sinon on bloquerait au chargement.
|
|
68
|
+
expect(arrowState(1, 0)).toEqual({ prevDisabled: true, nextDisabled: false });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Confondre ces deux marges faisait considérer comme « courante » une page encore sous le pli
|
|
72
|
+
// → l'audience était en retard d'une page sur le présentateur.
|
|
73
|
+
it("distingue la marge de pré-rendu de la bande de page courante", () => {
|
|
74
|
+
expect(PRERENDER_MARGIN).toBe("500px 0px");
|
|
75
|
+
expect(CURRENT_PAGE_MARGIN).toBe("-48% 0px -48% 0px");
|
|
76
|
+
expect(PRERENDER_MARGIN).not.toBe(CURRENT_PAGE_MARGIN);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("visionneuse — largeur d'une page", () => {
|
|
81
|
+
it("en défilement, suit la largeur du cadre et le zoom", () => {
|
|
82
|
+
expect(fitWidth({ containerWidth: 900 })).toBe(900);
|
|
83
|
+
expect(fitWidth({ containerWidth: 900, zoom: 2 })).toBe(1800);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("ne descend jamais sous une largeur lisible", () => {
|
|
87
|
+
expect(fitWidth({ containerWidth: 100 })).toBe(280);
|
|
88
|
+
expect(fitWidth({ containerWidth: 0 })).toBe(900); // cadre pas encore mesuré → repli
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Sans la borne par la hauteur, la page débordait sous la feuille de conversation.
|
|
92
|
+
it("en mode une-page, tient dans la hauteur disponible", () => {
|
|
93
|
+
const large = fitWidth({ containerWidth: 2000, containerHeight: 1000, onePage: true, aspect: 1.35 });
|
|
94
|
+
expect(large).toBeCloseTo((1000 - 28) / 1.35, 5);
|
|
95
|
+
expect(large).toBeLessThan(2000);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("retire du calcul ce qui recouvre le document", () => {
|
|
99
|
+
const sans = fitWidth({ containerWidth: 2000, containerHeight: 1000, onePage: true });
|
|
100
|
+
const avecFeuille = fitWidth({ containerWidth: 2000, containerHeight: 1000, onePage: true, overlap: 300 });
|
|
101
|
+
const avecBandeau = fitWidth({ containerWidth: 2000, containerHeight: 1000, onePage: true, reserve: 200 });
|
|
102
|
+
expect(avecFeuille).toBeLessThan(sans);
|
|
103
|
+
expect(avecBandeau).toBeLessThan(sans);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Panneau presque plein écran : mieux vaut une page trop haute qu'un timbre-poste illisible.
|
|
107
|
+
it("abandonne la contrainte de hauteur quand elle deviendrait absurde", () => {
|
|
108
|
+
expect(fitWidth({ containerWidth: 800, containerHeight: 200, onePage: true, overlap: 190 })).toBe(800);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("n'agrandit jamais une page au-delà du zoom demandé", () => {
|
|
112
|
+
expect(fitWidth({ containerWidth: 400, containerHeight: 5000, onePage: true })).toBe(400);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("visionneuse — divers", () => {
|
|
117
|
+
it("moyenne une bande de pixels", () => {
|
|
118
|
+
expect(averageColor([255, 0, 0, 255, 255, 0, 0, 255])).toBe("rgb(255,0,0)");
|
|
119
|
+
expect(averageColor([0, 0, 0, 255, 255, 255, 255, 255])).toBe("rgb(128,128,128)");
|
|
120
|
+
expect(averageColor([])).toBe("rgb(0,0,0)"); // canvas vide : pas de division par zéro
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Le type MIME n'est pas toujours renvoyé par le stockage : le nom fait foi.
|
|
124
|
+
it("reconnaît une image à son nom, pas à son type déclaré", () => {
|
|
125
|
+
for (const n of ["plan.png", "vue.JPG", "a.jpeg", "b.webp", "c.gif", "d.avif", "e.png?v=2"]) {
|
|
126
|
+
expect(isImageDocument(n), n).toBe(true);
|
|
127
|
+
}
|
|
128
|
+
for (const n of ["dossier.pdf", "note.txt", "", null, "image.png.pdf"]) {
|
|
129
|
+
expect(isImageDocument(n), String(n)).toBe(false);
|
|
130
|
+
}
|
|
131
|
+
expect(isImageDocument(null, "https://x/y/photo.jpg")).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// ⚠️ CE FICHIER EST SOUS MIT, pas AGPL — délibérément, et c'est le seul.
|
|
4
|
+
// C'est le contrat que l'application hôte doit importer pour parler au player. Le placer sous la
|
|
5
|
+
// licence du cœur obligerait un hôte à ouvrir son propre code pour avoir le droit d'échanger dix
|
|
6
|
+
// messages avec une iframe : la licence deviendrait un péage à l'intégration, ce qui n'est pas ce
|
|
7
|
+
// qu'on protège. On protège le player, pas ceux qui s'y branchent.
|
|
8
|
+
|
|
9
|
+
// Contrat postMessage entre le PLAYER (iframe) et l'APPLICATION HÔTE qui l'affiche.
|
|
10
|
+
//
|
|
11
|
+
// Le player vit dans une iframe : il possède le document, la barre d'outils et la présentation,
|
|
12
|
+
// mais tout ce qui exige le jeton de session de l'hôte (inviter l'équipe, passer la main, ouvrir
|
|
13
|
+
// la modale de partage) doit remonter à l'application. Ce fichier est le SEUL endroit où le
|
|
14
|
+
// format de ces messages est décrit — les deux côtés l'importent.
|
|
15
|
+
//
|
|
16
|
+
// Il était auparavant écrit à la main des deux côtés : dix types de messages dupliqués, non
|
|
17
|
+
// typés, validés par des `String(d.slug || "")` sans borne. Deux bugs en sont venus (validation
|
|
18
|
+
// d'origine trop stricte qui bloquait la réception en production ; fermeture du viewer avant le
|
|
19
|
+
// retrait de présence, qui laissait un participant fantôme).
|
|
20
|
+
//
|
|
21
|
+
// VALIDATION PAR TYPE, PAS PAR ORIGINE — délibéré. Un contrôle strict de `e.origin` bloquait la
|
|
22
|
+
// réception en production (l'iframe et l'app ne partagent pas toujours la même origine perçue).
|
|
23
|
+
// Les actions transportées sont inoffensives : elles ouvrent une modale ou ferment une vue, et
|
|
24
|
+
// tout ce qui touche aux données est revérifié côté serveur avec le JWT. Le `slug` est en
|
|
25
|
+
// revanche borné ici (charset + longueur) : c'est la seule donnée qui traverse la frontière.
|
|
26
|
+
|
|
27
|
+
/** Préfixe historique des messages sur le fil. Neutralisable d'un seul endroit le jour de l'ouverture. */
|
|
28
|
+
const WIRE_PREFIX = "3dd-doc-";
|
|
29
|
+
|
|
30
|
+
/** Slugs de partage et de présentation : `randomBytes(9).toString("base64url")` → 12 caractères. */
|
|
31
|
+
const SLUG_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
32
|
+
|
|
33
|
+
/** Messages émis par le player vers l'application hôte. */
|
|
34
|
+
export type PlayerMessage =
|
|
35
|
+
| { type: "close" }
|
|
36
|
+
| { type: "share" }
|
|
37
|
+
| { type: "embed-ready" }
|
|
38
|
+
// ⚠️ Le silence ne suffit pas. `embed-ready` absent peut vouloir dire deux choses OPPOSÉES :
|
|
39
|
+
// le player n'est pas là (instance absente, en panne, en cours de déploiement) — ou le player
|
|
40
|
+
// REFUSE d'afficher (partage révoqué, mur d'accès, greffon manquant en fail-closed). Un hôte
|
|
41
|
+
// qui replie sur le lecteur du navigateur transformerait alors un refus en ouverture.
|
|
42
|
+
// Signalé par un hôte en cours d'intégration. `reason` reste indicatif : la décision est déjà prise.
|
|
43
|
+
| { type: "embed-denied"; reason: string }
|
|
44
|
+
| { type: "present-left" }
|
|
45
|
+
| { type: "present-denied" }
|
|
46
|
+
| { type: "present-invite"; slug: string }
|
|
47
|
+
| { type: "present-handover"; slug: string }
|
|
48
|
+
| { type: "present-switch"; slug: string };
|
|
49
|
+
|
|
50
|
+
/** Messages émis par l'application hôte vers le player. */
|
|
51
|
+
export type HostMessage = { type: "handover-done" };
|
|
52
|
+
|
|
53
|
+
const PLAYER_PLAIN = ["close", "share", "embed-ready", "present-left", "present-denied"] as const;
|
|
54
|
+
const PLAYER_WITH_REASON = ["embed-denied"] as const;
|
|
55
|
+
const PLAYER_WITH_SLUG = ["present-invite", "present-handover", "present-switch"] as const;
|
|
56
|
+
const HOST_PLAIN = ["handover-done"] as const;
|
|
57
|
+
|
|
58
|
+
type WireMessage = { type: string; slug?: string; reason?: string };
|
|
59
|
+
|
|
60
|
+
/** Forme transportée sur le fil (préfixée), pour un message typé. */
|
|
61
|
+
export function toWire(msg: PlayerMessage | HostMessage): WireMessage {
|
|
62
|
+
const wire: WireMessage = { type: WIRE_PREFIX + msg.type };
|
|
63
|
+
if ("slug" in msg && msg.slug) wire.slug = msg.slug;
|
|
64
|
+
if ("reason" in msg && msg.reason) wire.reason = String(msg.reason).slice(0, 40);
|
|
65
|
+
return wire;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parse<T extends { type: string }>(
|
|
69
|
+
data: unknown,
|
|
70
|
+
plain: readonly string[],
|
|
71
|
+
withSlug: readonly string[],
|
|
72
|
+
): T | null {
|
|
73
|
+
if (!data || typeof data !== "object") return null;
|
|
74
|
+
const raw = (data as { type?: unknown }).type;
|
|
75
|
+
if (typeof raw !== "string" || !raw.startsWith(WIRE_PREFIX)) return null;
|
|
76
|
+
const type = raw.slice(WIRE_PREFIX.length);
|
|
77
|
+
if (plain.includes(type)) return { type } as T;
|
|
78
|
+
if (PLAYER_WITH_REASON.includes(type as never)) {
|
|
79
|
+
const reason = (data as { reason?: unknown }).reason;
|
|
80
|
+
// Motif borné et sans surprise : il traverse une frontière et finit parfois à l'écran.
|
|
81
|
+
return { type, reason: /^[a-z-]{1,40}$/.test(String(reason || "")) ? String(reason) : "unknown" } as unknown as T;
|
|
82
|
+
}
|
|
83
|
+
if (!withSlug.includes(type)) return null;
|
|
84
|
+
const slug = (data as { slug?: unknown }).slug;
|
|
85
|
+
// Un message à slug SANS slug valide est rejeté : mieux vaut ne rien ouvrir qu'ouvrir sur du vide.
|
|
86
|
+
if (typeof slug !== "string" || !SLUG_RE.test(slug)) return null;
|
|
87
|
+
return { type, slug } as unknown as T;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Côté HÔTE : lit un message reçu du player. `null` si ce n'en est pas un. */
|
|
91
|
+
export function parsePlayerMessage(data: unknown): PlayerMessage | null {
|
|
92
|
+
return parse<PlayerMessage>(data, PLAYER_PLAIN, PLAYER_WITH_SLUG);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Côté PLAYER : lit un message reçu de l'hôte. `null` si ce n'en est pas un. */
|
|
96
|
+
export function parseHostMessage(data: unknown): HostMessage | null {
|
|
97
|
+
return parse<HostMessage>(data, HOST_PLAIN, []);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Runtime ────────────────────────────────────────────────────────────────────────────────────
|
|
101
|
+
// Tout est en `try` : un player qui ne parvient pas à parler à son hôte doit continuer à afficher
|
|
102
|
+
// le document. Perdre le bouton « Partager » est ennuyeux, perdre le document est inacceptable.
|
|
103
|
+
|
|
104
|
+
/** Côté PLAYER : envoie un message à l'application hôte. */
|
|
105
|
+
export function sendToHost(msg: PlayerMessage, target?: Window | null): void {
|
|
106
|
+
try {
|
|
107
|
+
const win = target || (typeof window !== "undefined" ? window.parent : null);
|
|
108
|
+
if (win) win.postMessage(toWire(msg), "*");
|
|
109
|
+
} catch { /* l'hôte est peut-être absent (page publique) : sans effet */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Côté HÔTE : envoie un message au player. */
|
|
113
|
+
export function sendToPlayer(frame: HTMLIFrameElement | null | undefined, msg: HostMessage): void {
|
|
114
|
+
try {
|
|
115
|
+
frame?.contentWindow?.postMessage(toWire(msg), "*");
|
|
116
|
+
} catch { /* iframe déjà détruite */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Côté HÔTE : écoute les messages du player. Renvoie la fonction de désabonnement. */
|
|
120
|
+
export function onPlayerMessage(cb: (msg: PlayerMessage) => void, win?: Window): () => void {
|
|
121
|
+
const target = win || (typeof window !== "undefined" ? window : null);
|
|
122
|
+
if (!target) return () => {};
|
|
123
|
+
const handler = (e: MessageEvent) => {
|
|
124
|
+
const msg = parsePlayerMessage(e.data);
|
|
125
|
+
if (msg) cb(msg);
|
|
126
|
+
};
|
|
127
|
+
target.addEventListener("message", handler);
|
|
128
|
+
return () => target.removeEventListener("message", handler);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Côté PLAYER : écoute les messages de l'hôte. Renvoie la fonction de désabonnement. */
|
|
132
|
+
export function onHostMessage(cb: (msg: HostMessage) => void, win?: Window): () => void {
|
|
133
|
+
const target = win || (typeof window !== "undefined" ? window : null);
|
|
134
|
+
if (!target) return () => {};
|
|
135
|
+
const handler = (e: MessageEvent) => {
|
|
136
|
+
const msg = parseHostMessage(e.data);
|
|
137
|
+
if (msg) cb(msg);
|
|
138
|
+
};
|
|
139
|
+
target.addEventListener("message", handler);
|
|
140
|
+
return () => target.removeEventListener("message", handler);
|
|
141
|
+
}
|
package/src/chat.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Rendu des messages du chat de présentation : d'un message à son fragment HTML.
|
|
2
|
+
//
|
|
3
|
+
// Ces fonctions produisent une CHAÎNE, pas des nœuds — c'est ce qui les rend testables, et c'est
|
|
4
|
+
// aussi ce qui les rend dangereuses : tout ce qui n'est pas échappé ici devient du balisage
|
|
5
|
+
// exécutable chez tous les spectateurs. Un message contenant du balisage a déjà produit un XSS
|
|
6
|
+
// stocké sur la page audience. Les tests vérifient donc non seulement la chaîne produite, mais ce
|
|
7
|
+
// que le NAVIGATEUR en fait réellement une fois insérée (harnais jsdom).
|
|
8
|
+
//
|
|
9
|
+
// Règle sans exception : **toute** donnée venant d'un participant — nom, corps, citation, nom de
|
|
10
|
+
// pièce jointe, URL, emoji de réaction — passe par `escapeHtml` ou `formatMessageBody`.
|
|
11
|
+
|
|
12
|
+
import { escapeHtml, formatMessageBody, avatarHtml, isMine, canModerate, reactorId, type ChatMessage, type Me } from "./live";
|
|
13
|
+
|
|
14
|
+
export interface Attachment {
|
|
15
|
+
kind?: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChatMessageFull extends ChatMessage {
|
|
21
|
+
reply_to?: number | string | null;
|
|
22
|
+
reply_name?: string;
|
|
23
|
+
reply_text?: string;
|
|
24
|
+
edited?: boolean;
|
|
25
|
+
is_presenter?: boolean;
|
|
26
|
+
attachment?: Attachment | null;
|
|
27
|
+
reactions?: Record<string, string[]> | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChatRenderContext {
|
|
31
|
+
me: Me | null | undefined;
|
|
32
|
+
/** Balisage de l'icône « réagir » — fourni par l'hôte, jamais construit ici. */
|
|
33
|
+
reactIcon?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Pastilles de réaction. L'emoji vient du réseau : il est échappé comme le reste. */
|
|
37
|
+
export function renderReactions(msg: ChatMessageFull | null | undefined, me: Me | null | undefined): string {
|
|
38
|
+
const reactions = (msg && msg.reactions) || {};
|
|
39
|
+
const mineId = reactorId(me);
|
|
40
|
+
let out = "";
|
|
41
|
+
for (const emoji in reactions) {
|
|
42
|
+
const reactors = reactions[emoji] || [];
|
|
43
|
+
if (!reactors.length) continue;
|
|
44
|
+
const mine = reactors.indexOf(mineId) >= 0;
|
|
45
|
+
out += `<button class="re-chip${mine ? " on" : ""}" data-e="${escapeHtml(emoji)}">${escapeHtml(emoji)} ${reactors.length}</button>`;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pièce jointe : vignette d'image, ou carte PDF dont la vignette est peinte après coup. */
|
|
51
|
+
export function renderAttachment(attachment: Attachment | null | undefined): string {
|
|
52
|
+
if (!attachment || !attachment.url) return "";
|
|
53
|
+
const url = escapeHtml(attachment.url);
|
|
54
|
+
if (attachment.kind === "image") {
|
|
55
|
+
return `<a class=cm-att href="${url}" target=_blank rel=noreferrer><img src="${url}" alt="" loading=lazy></a>`;
|
|
56
|
+
}
|
|
57
|
+
const label = escapeHtml(attachment.name || "Document.pdf");
|
|
58
|
+
return `<a class=cm-att-pdf href="${url}" target=_blank rel=noreferrer data-pdf="${url}"><span class=cm-att-ph></span><span class=cm-pdflabel>${label}</span></a>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Boutons d'action. « Modifier » n'apparaît que sur ses propres messages ; « Supprimer » aussi, sauf pour le présentateur. */
|
|
62
|
+
export function renderActions(msg: ChatMessageFull, ctx: ChatRenderContext): string {
|
|
63
|
+
const mine = isMine(msg, ctx.me);
|
|
64
|
+
let actions = `<button class=cm-react title="Réagir">${ctx.reactIcon || ""}</button><button class=cm-reply title="Répondre">↩</button>`;
|
|
65
|
+
if (mine) actions += '<button class=cm-edit title="Modifier">✎</button>';
|
|
66
|
+
if (mine || canModerate(ctx.me)) actions += '<button class=cm-del-btn title="Supprimer">🗑</button>';
|
|
67
|
+
return actions;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Contenu interne d'un message, prêt à être posé dans son conteneur. */
|
|
71
|
+
export function renderMessage(msg: ChatMessageFull, ctx: ChatRenderContext): string {
|
|
72
|
+
// Un message supprimé ne laisse RIEN filtrer de son contenu d'origine — ni corps, ni citation,
|
|
73
|
+
// ni pièce jointe. La suppression est une suppression, pas un masquage visuel.
|
|
74
|
+
if (msg.deleted) {
|
|
75
|
+
return '<span class=a></span><span class=b><div class="txt cm-del">Message supprimé</div></span>';
|
|
76
|
+
}
|
|
77
|
+
const quote = msg.reply_to
|
|
78
|
+
? `<div class=cm-q><b>${escapeHtml(msg.reply_name || "")}</b> ${escapeHtml(msg.reply_text || "")}</div>`
|
|
79
|
+
: "";
|
|
80
|
+
const edited = msg.edited ? ' <span class=cm-ed>(modifié)</span>' : "";
|
|
81
|
+
const body = msg.body ? `<div class=txt>${formatMessageBody(msg.body)}${edited}</div>` : "";
|
|
82
|
+
const presenterTag = msg.is_presenter ? ' <span class=tag>présentateur</span>' : "";
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
`<span class=a>${avatarHtml(msg.author_avatar, msg.author_name)}</span>` +
|
|
86
|
+
`<span class=b><div class=who><b>${escapeHtml(msg.author_name || "Invité")}</b>${presenterTag}</div>` +
|
|
87
|
+
quote +
|
|
88
|
+
body +
|
|
89
|
+
renderAttachment(msg.attachment) +
|
|
90
|
+
`<div class=cm-re>${renderReactions(msg, ctx.me)}</div></span>` +
|
|
91
|
+
`<span class=cm-act>${renderActions(msg, ctx)}</span>`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Classes du conteneur d'un message — état visuel dérivé, jamais saisi par l'auteur. */
|
|
96
|
+
export function messageClassName(msg: ChatMessageFull, me: Me | null | undefined, mentioned: boolean): string {
|
|
97
|
+
return (
|
|
98
|
+
"cm" +
|
|
99
|
+
(isMine(msg, me) ? " me" : "") +
|
|
100
|
+
(msg.deleted ? " isdel" : "") +
|
|
101
|
+
(mentioned ? " mentioned" : "")
|
|
102
|
+
);
|
|
103
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Point d'entrée du code navigateur du player.
|
|
2
|
+
//
|
|
3
|
+
// esbuild regroupe ce fichier en un IIFE exposé sous `window.Player` (cf. player/build/bundle.mjs).
|
|
4
|
+
// La visionneuse — encore écrite en template literal dans api/doc.js — appelle donc
|
|
5
|
+
// `Player.bridge.…` et `Player.tracking.…`. Chaque brique reste un espace de noms distinct :
|
|
6
|
+
// c'est ce qui permet de les extraire une par une sans renommer les précédentes.
|
|
7
|
+
|
|
8
|
+
export * as bridge from "./bridge";
|
|
9
|
+
export * as tracking from "./tracking";
|
|
10
|
+
export * as live from "./live";
|
|
11
|
+
export * as chat from "./chat";
|
|
12
|
+
export * as presentation from "./presentation-content";
|
|
13
|
+
export * as viewer from "./viewer";
|
|
14
|
+
export * as presentationState from "./presentation-state";
|
package/src/live.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Cœur logique de la présentation en direct : identité des participants, dédoublonnage de la
|
|
2
|
+
// présence, échappement et mise en forme des messages, prédicats de modération et de notification.
|
|
3
|
+
//
|
|
4
|
+
// PÉRIMÈTRE : uniquement ce qui ne touche PAS au DOM. Le rendu (messages, panneau, dialogues,
|
|
5
|
+
// vignettes PDF) reste pour l'instant dans le template de api/doc.js — il demandera un harnais
|
|
6
|
+
// DOM pour être testé, et c'est un chantier distinct. Ce qui est ici est ce qui a réellement
|
|
7
|
+
// cassé par le passé, et donc ce qui mérite des tests en premier :
|
|
8
|
+
//
|
|
9
|
+
// - la présence dédoublonnée : sans identité stable, un participant reconnecté apparaissait
|
|
10
|
+
// DEUX fois (« je me vois deux fois ») ;
|
|
11
|
+
// - l'échappement : un titre ou un message contenant du balisage a déjà produit un XSS stocké
|
|
12
|
+
// sur la page audience ;
|
|
13
|
+
// - l'identité persistée : c'est elle qui permet de recoller les statistiques de présence
|
|
14
|
+
// d'un visiteur anonyme d'une session à l'autre.
|
|
15
|
+
|
|
16
|
+
/** Stockage persistant minimal. `localStorage` lève en navigation privée → tout est en `try`. */
|
|
17
|
+
export interface KeyValueStore {
|
|
18
|
+
getItem(key: string): string | null;
|
|
19
|
+
setItem(key: string, value: string): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ⚠️ Ces clés sont un CONTRAT avec les navigateurs déjà passés sur une présentation : les changer
|
|
23
|
+
// fait perdre l'identité anonyme (statistiques de présence recollées) et la propriété des messages
|
|
24
|
+
// (le droit de modifier/supprimer les siens). Neutralisation du préfixe = au moment de l'ouverture.
|
|
25
|
+
export const STORAGE_KEYS = {
|
|
26
|
+
attendeeKey: "3dd-present-attkey",
|
|
27
|
+
authorToken: "3dd-present-authtoken",
|
|
28
|
+
chatMuted: "3dd-present-mute",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export interface Participant {
|
|
32
|
+
uid?: string;
|
|
33
|
+
email?: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
avatar?: string;
|
|
36
|
+
role?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Me {
|
|
40
|
+
email?: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
role?: string;
|
|
43
|
+
member?: boolean;
|
|
44
|
+
avatar?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ChatMessage {
|
|
48
|
+
id?: number | string;
|
|
49
|
+
author_email?: string;
|
|
50
|
+
author_name?: string;
|
|
51
|
+
author_avatar?: string;
|
|
52
|
+
body?: string;
|
|
53
|
+
deleted?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Échappement et mise en forme ───────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** Échappe pour insertion dans du HTML. Tout texte venant d'un participant passe par ici. */
|
|
59
|
+
export function escapeHtml(value: unknown): string {
|
|
60
|
+
return String(value == null ? "" : value)
|
|
61
|
+
.replace(/&/g, "&")
|
|
62
|
+
.replace(/</g, "<")
|
|
63
|
+
.replace(/>/g, ">")
|
|
64
|
+
.replace(/"/g, """);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Initiales d'un nom, pour l'avatar de repli. */
|
|
68
|
+
export function initials(name?: string): string {
|
|
69
|
+
const parts = (name || "?").trim().split(/\s+/);
|
|
70
|
+
return (((parts[0] || "")[0] || "?") + ((parts[1] || "")[0] || "")).toUpperCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Avatar : l'image si elle existe, sinon les initiales. Les deux sont échappées. */
|
|
74
|
+
export function avatarHtml(url?: string, name?: string): string {
|
|
75
|
+
return url ? `<img src="${escapeHtml(url)}" alt="">` : escapeHtml(initials(name));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Corps d'un message, prêt à insérer : échappé D'ABORD, puis enrichi de liens et de mentions.
|
|
80
|
+
* L'ordre est vital — enrichir avant d'échapper rendrait le balisage de l'auteur exécutable.
|
|
81
|
+
* Seuls `http(s)://` sont transformés en liens : pas de `javascript:` ni de `data:`.
|
|
82
|
+
*/
|
|
83
|
+
export function formatMessageBody(body: unknown): string {
|
|
84
|
+
let out = escapeHtml(body);
|
|
85
|
+
out = out.replace(
|
|
86
|
+
/(https?:\/\/[^\s<]+)/g,
|
|
87
|
+
(url) => `<a href="${url}" target=_blank rel=noreferrer class=cm-link>${url}</a>`,
|
|
88
|
+
);
|
|
89
|
+
out = out.replace(
|
|
90
|
+
/(^|\s)@([\p{L}0-9_'.-]+)/gu,
|
|
91
|
+
(_all, before: string, handle: string) => `${before}<span class=cm-mention>@${handle}</span>`,
|
|
92
|
+
);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Présence ───────────────────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Aplatit l'état de présence Realtime en une liste SANS DOUBLON.
|
|
100
|
+
*
|
|
101
|
+
* La clé de présence du canal contient un identifiant tiré au sort à chaque chargement de page :
|
|
102
|
+
* un participant qui se reconnecte y apparaît donc deux fois. On collapse sur une identité
|
|
103
|
+
* STABLE (`uid`, sinon email, sinon nom) — c'est ce qui a corrigé « je me vois deux fois ».
|
|
104
|
+
* En cas de collision, la métadonnée du présentateur l'emporte : c'est elle qui porte le rôle.
|
|
105
|
+
*/
|
|
106
|
+
export function flattenPresence(state: Record<string, Participant[]> | null | undefined): Participant[] {
|
|
107
|
+
const byIdentity: Record<string, Participant> = {};
|
|
108
|
+
const order: string[] = [];
|
|
109
|
+
for (const key in state || {}) {
|
|
110
|
+
for (const member of (state || {})[key] || []) {
|
|
111
|
+
// Participant sans aucune identité : on ne peut pas le dédoublonner, on le garde tel quel
|
|
112
|
+
// plutôt que de fondre des inconnus distincts en un seul.
|
|
113
|
+
const identity = String(member.uid || member.email || member.name || "").toLowerCase()
|
|
114
|
+
|| `_${key}_${order.length}`;
|
|
115
|
+
if (!byIdentity[identity]) { byIdentity[identity] = member; order.push(identity); }
|
|
116
|
+
else if (member.role === "presenter") byIdentity[identity] = member;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return order.map((id) => byIdentity[id]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Identité ───────────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function readStore(store: KeyValueStore | null | undefined, key: string): string | null {
|
|
125
|
+
try { return store ? store.getItem(key) : null; } catch { return null; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function writeStore(store: KeyValueStore | null | undefined, key: string, value: string): void {
|
|
129
|
+
try { store?.setItem(key, value); } catch { /* navigation privée, quota : sans effet */ }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Clé stable d'un participant pour les statistiques de présence : son email s'il est membre,
|
|
134
|
+
* sinon un identifiant de navigateur persistant. Sans lui, chaque rechargement compterait
|
|
135
|
+
* comme un nouveau participant.
|
|
136
|
+
*/
|
|
137
|
+
export function attendeeKey(
|
|
138
|
+
me: Me | null | undefined,
|
|
139
|
+
store?: KeyValueStore | null,
|
|
140
|
+
fallbackId = "anon",
|
|
141
|
+
randomId: () => string = () => Math.random().toString(36).slice(2, 10),
|
|
142
|
+
): string {
|
|
143
|
+
if (me?.email) return String(me.email).toLowerCase();
|
|
144
|
+
try {
|
|
145
|
+
if (!store) return `anon-${fallbackId}`;
|
|
146
|
+
const existing = store.getItem(STORAGE_KEYS.attendeeKey);
|
|
147
|
+
if (existing) return existing;
|
|
148
|
+
const created = `anon-${randomId()}`;
|
|
149
|
+
store.setItem(STORAGE_KEYS.attendeeKey, created);
|
|
150
|
+
return created;
|
|
151
|
+
} catch {
|
|
152
|
+
// Stockage indisponible (navigation privée, quota) : la clé ne survivra pas au rechargement,
|
|
153
|
+
// le participant comptera pour deux visites. Dégradation acceptée, jamais une erreur.
|
|
154
|
+
return `anon-${fallbackId}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Jeton d'auteur : c'est lui qui prouve « ce message est le mien » pour le modifier ou le
|
|
160
|
+
* supprimer. Persistant, propre au navigateur, jamais envoyé par le serveur.
|
|
161
|
+
*/
|
|
162
|
+
export function authorToken(
|
|
163
|
+
store?: KeyValueStore | null,
|
|
164
|
+
randomId: () => string = () => Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2),
|
|
165
|
+
): string {
|
|
166
|
+
const existing = readStore(store, STORAGE_KEYS.authorToken);
|
|
167
|
+
if (existing) return existing;
|
|
168
|
+
const created = randomId();
|
|
169
|
+
writeStore(store, STORAGE_KEYS.authorToken, created);
|
|
170
|
+
return created;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Identifiant utilisé pour marquer ses propres réactions emoji. */
|
|
174
|
+
export function reactorId(me: Me | null | undefined): string {
|
|
175
|
+
return String((me && (me.email || me.name)) || "").toLowerCase();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Prédicats ──────────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/** Ce message est-il de moi ? Par email si connu, sinon par nom entre anonymes. */
|
|
181
|
+
export function isMine(msg: ChatMessage | null | undefined, me: Me | null | undefined): boolean {
|
|
182
|
+
if (!msg || !me) return false;
|
|
183
|
+
if (msg.author_email && me.email) return msg.author_email === me.email;
|
|
184
|
+
if (!msg.author_email && !me.email) return msg.author_name === me.name;
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Seul le présentateur modère. Le rôle vient du serveur (control_token valide), pas du client. */
|
|
189
|
+
export function canModerate(me: Me | null | undefined): boolean {
|
|
190
|
+
return !!(me && me.role === "presenter");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Suis-je cité ? On compare au PRÉNOM, comme dans le chat de l'application. */
|
|
194
|
+
export function isMentioned(msg: ChatMessage | null | undefined, me: Me | null | undefined): boolean {
|
|
195
|
+
if (!me || !me.name) return false;
|
|
196
|
+
const firstName = (me.name.split(/\s+/)[0] || "").toLowerCase();
|
|
197
|
+
if (!firstName) return false;
|
|
198
|
+
return String((msg && msg.body) || "").toLowerCase().includes(`@${firstName}`) && !isMine(msg, me);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Notifications de chat ──────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Faut-il compter ce message comme non lu ?
|
|
205
|
+
* `historyLoaded` est la garde décisive : sans elle, l'historique rechargé au join ferait
|
|
206
|
+
* apparaître un badge de dizaines de messages déjà vus.
|
|
207
|
+
*/
|
|
208
|
+
export function shouldNotify(input: {
|
|
209
|
+
msg: ChatMessage | null | undefined;
|
|
210
|
+
me: Me | null | undefined;
|
|
211
|
+
historyLoaded: boolean;
|
|
212
|
+
chatHidden: boolean;
|
|
213
|
+
}): boolean {
|
|
214
|
+
const { msg, me, historyLoaded, chatHidden } = input;
|
|
215
|
+
if (!historyLoaded) return false;
|
|
216
|
+
if (!msg || msg.deleted) return false;
|
|
217
|
+
if (isMine(msg, me)) return false;
|
|
218
|
+
return chatHidden;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Libellé du badge de non-lus : au-delà de 9, on écrit « 9+ ». */
|
|
222
|
+
export function unreadLabel(count: number): string {
|
|
223
|
+
if (!count || count <= 0) return "";
|
|
224
|
+
return count > 9 ? "9+" : String(count);
|
|
225
|
+
}
|