discovery-media-player 0.1.24 → 0.1.26
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/package.json +1 -1
- package/server/handler.js +75 -11
- package/server/presentations.js +30 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pdf-viewer",
|
package/server/handler.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const crypto = require("crypto");
|
|
7
7
|
const { getShareBySlug, logView, upsertSession, createReshare, sendReshareEmail, upsertInternalSession,
|
|
8
8
|
createShare, revokeShare, setShareAuth, overview: docOverview, listSharesForDoc, listSessionsForDoc, internalStatsForDoc } = require("./shares");
|
|
9
|
-
const { createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
|
|
9
|
+
const { createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presenterKey, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
|
|
10
10
|
// CONTEXTE INJECTÉ : tout ce que le player emprunte à l'application hôte passe par ici — stockage,
|
|
11
11
|
// base, identité, limites, marque, journalisation — et rien d'autre. C'est la frontière qui permettra
|
|
12
12
|
// de brancher un second projet, puis d'ouvrir le cœur. Cf. api/_player-context.js.
|
|
@@ -233,7 +233,7 @@ const LIVE_PANEL = `<div class="chat hidden" id=chatPanel><div class=chat-grip i
|
|
|
233
233
|
// JS partagé : présence + chat via Supabase Realtime. Live.connect(slug, me) / Live.disconnect().
|
|
234
234
|
const LIVE_JS = `
|
|
235
235
|
var Live=(function(){
|
|
236
|
-
var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],seen={},msgEls={},msgData={},replyCtx=null,typers={},pdfCache={},_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
|
|
236
|
+
var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESKEY='',seen={},msgEls={},msgData={},replyCtx=null,typers={},pdfCache={},_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
|
|
237
237
|
try{ MUTED=localStorage.getItem('3dd-present-mute')==='1'; }catch(e){}
|
|
238
238
|
// Couper/rétablir les notifications du chat (cloche) : coupé = plus de ticker ni de pulse (badge silencieux gardé).
|
|
239
239
|
function applyMute(){ var b=document.getElementById('chatMute'); if(b){b.classList.toggle('muted',MUTED);b.title=MUTED?'Réactiver les notifications du chat':'Couper les notifications du chat';} setBadge(); }
|
|
@@ -276,7 +276,11 @@ var Live=(function(){
|
|
|
276
276
|
function attKey(me){ return Player.live.attendeeKey(me,_store(),MYID); }
|
|
277
277
|
// Heartbeat d'assistance → le serveur journalise qui suit, combien de temps, et les pages vues (via la page
|
|
278
278
|
// courante de la présentation). Envoyé à la connexion puis toutes les 25 s. Best-effort (silencieux).
|
|
279
|
-
|
|
279
|
+
// Le serveur ne croit plus 'isMember' ni 'isPresenter' sur parole : l'appartenance se prouve par
|
|
280
|
+
// le jeton d'acces de la session, le titre de presentateur par le control_token. Ce qui part d'ici
|
|
281
|
+
// n'est plus qu'une AFFIRMATION, et le serveur la remplace par ce qu'il a verifie.
|
|
282
|
+
function sendAttend(){ if(!SLUG||!ME)return; try{ var h={'Content-Type':'application/json'};var jw=accessToken();if(jw)h.Authorization='Bearer '+jw;
|
|
283
|
+
fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-attend',slug:SLUG,control:CONTROL,key:attKey(ME),name:ME.name||'',email:ME.email||'',avatar:ME.avatar||''})}); }catch(e){} }
|
|
280
284
|
var EMOJIS=['👍','❤️','😂','😮','👏','🎉'];
|
|
281
285
|
var RSVG='<svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round><circle cx=12 cy=12 r=9 /><path d="M8.5 14.5s1.4 1.7 3.5 1.7 3.5-1.7 3.5-1.7"/><line x1=9 y1=9.2 x2=9.01 y2=9.2 /><line x1=15 y1=9.2 x2=15.01 y2=9.2 /></svg>';
|
|
282
286
|
function esc(s){return Player.live.escapeHtml(s);}
|
|
@@ -294,7 +298,7 @@ var Live=(function(){
|
|
|
294
298
|
function isMentioned(m){return Player.live.isMentioned(m,ME);}
|
|
295
299
|
function renderPres(st){var l=flat(st),c=l.length;PRESENT=l;var e=document.getElementById('presCount');if(e)e.textContent=c;
|
|
296
300
|
var a=document.getElementById('presAvs');if(a)a.innerHTML=l.slice(0,4).map(function(m){return '<span class=pres-av>'+av(m.avatar,m.name)+'</span>';}).join('');
|
|
297
|
-
var p=document.getElementById('presList');if(p)p.innerHTML='<h5>'+c+' en ligne</h5>'+l.map(function(m){return '<div class=pres-item><span class=a>'+av(m.avatar,m.name)+'</span><span class=b><div class=n>'+esc(m.name||'Invité')+(m.
|
|
301
|
+
var p=document.getElementById('presList');if(p)p.innerHTML='<h5>'+c+' en ligne</h5>'+l.map(function(m){return '<div class=pres-item><span class=a>'+av(m.avatar,m.name)+'</span><span class=b><div class=n>'+esc(m.name||'Invité')+(m.uid&&m.uid===PRESKEY?' <span class=tag>présentateur</span>':'')+'</div>'+(m.email?'<div class=e>'+esc(m.email)+'</div>':'')+'</span></div>';}).join('');}
|
|
298
302
|
// Rendu d'un message → player/src/chat.ts (échappement testé sous jsdom : on y vérifie ce que
|
|
299
303
|
// le NAVIGATEUR fabrique, pas seulement la chaîne produite).
|
|
300
304
|
function renderRe(m){return Player.chat.renderReactions(m,ME);}
|
|
@@ -333,10 +337,11 @@ var Live=(function(){
|
|
|
333
337
|
function setReply(id){var m=msgData[id];if(!m||m.deleted)return;var nm=m.author_name||'Invité';replyCtx={id:+id,name:nm,text:(m.body||'').slice(0,120)};var el=document.getElementById('chatReply');if(el){el.style.display='flex';el.innerHTML='<span class=cq><b>'+esc(nm)+'</b> '+esc((m.body||'').slice(0,80))+'</span><button id=chatReplyX title=Annuler>×</button>';var x=document.getElementById('chatReplyX');if(x)x.addEventListener('click',clearReply);}var t=document.getElementById('chatText');if(t)t.focus();}
|
|
334
338
|
function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
|
|
335
339
|
function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
|
|
336
|
-
var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,
|
|
340
|
+
var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:t,authorToken:authToken()};
|
|
337
341
|
if(CONTROL)o.control=CONTROL;
|
|
338
342
|
if(replyCtx){o.replyTo=replyCtx.id;o.replyName=replyCtx.name;o.replyText=replyCtx.text;clearReply();}
|
|
339
|
-
|
|
343
|
+
var h1={'Content-Type':'application/json'};var j1=accessToken();if(j1)h1.Authorization='Bearer '+j1;
|
|
344
|
+
fetch('/api/doc',{method:'POST',headers:h1,body:JSON.stringify(o)})
|
|
340
345
|
.then(function(r){return r.json();})
|
|
341
346
|
.then(function(d){if(d&&d.ok&&d.message){addMsg(d.message);sendMsg(d.message);}})
|
|
342
347
|
.catch(function(){});}
|
|
@@ -349,9 +354,10 @@ var Live=(function(){
|
|
|
349
354
|
return sb.storage.from('present-attachments').uploadToSignedUrl(d.path,d.token,file).then(function(u){
|
|
350
355
|
if(u&&u.error)throw 0;
|
|
351
356
|
var i=document.getElementById('chatText'),cap=(i&&i.value||'').trim();if(i)i.value='';
|
|
352
|
-
var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,
|
|
357
|
+
var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:cap,authorToken:authToken(),attachment:{url:d.publicUrl,name:file.name,type:file.type,kind:d.kind}};
|
|
353
358
|
if(CONTROL)o.control=CONTROL;
|
|
354
|
-
|
|
359
|
+
var h2={'Content-Type':'application/json'};var j2=accessToken();if(j2)h2.Authorization='Bearer '+j2;
|
|
360
|
+
return fetch('/api/doc',{method:'POST',headers:h2,body:JSON.stringify(o)});
|
|
355
361
|
});
|
|
356
362
|
}).then(done).catch(function(){done();});}
|
|
357
363
|
function applyLock(v){LOCKED=!!v;var t=document.getElementById('chatText'),s=document.getElementById('chatSend');var can=!LOCKED||canMod();if(t){t.disabled=!can;t.placeholder=can?'Écrire un message…':'Chat en lecture seule';}if(s)s.disabled=!can;var lk=document.getElementById('chatLockBtn');if(lk)lk.classList.toggle('on',LOCKED);var no=document.getElementById('chatLocked');if(no)no.style.display=(LOCKED&&!canMod())?'block':'none';toggleSend();}
|
|
@@ -449,7 +455,7 @@ var Live=(function(){
|
|
|
449
455
|
var _relEtat=null,_relChat=null;
|
|
450
456
|
// Groupé : dix diffusions d'affilée ne doivent pas produire dix requêtes.
|
|
451
457
|
function relireEtat(){clearTimeout(_relEtat);_relEtat=setTimeout(function(){
|
|
452
|
-
relire('&state=1',function(d){if(d.state
|
|
458
|
+
relire('&state=1',function(d){if(d.state)etatDuServeur(d.state);});
|
|
453
459
|
},120);}
|
|
454
460
|
function relireChat(){clearTimeout(_relChat);_relChat=setTimeout(function(){
|
|
455
461
|
relire('&chat=1',function(d){
|
|
@@ -463,6 +469,21 @@ var Live=(function(){
|
|
|
463
469
|
});
|
|
464
470
|
},120);}
|
|
465
471
|
|
|
472
|
+
// ⚠️ LE TITRE VIENT D'ICI, PAS DE LA PRÉSENCE. La liste des participants tirait
|
|
473
|
+
// « présentateur » de la charge de présence, que chacun compose lui-même : un
|
|
474
|
+
// 'track({role:"presenter"})' suffisait à apparaître comme le présentateur devant toute
|
|
475
|
+
// l'audience, avec le nom et l'avatar de son choix. Le canal ne peut pas arbitrer ça — un
|
|
476
|
+
// participant légitime a le droit d'y écrire SA présence.
|
|
477
|
+
//
|
|
478
|
+
// Le serveur renvoie la CLÉ de celui qui a prouvé le control_token ; l'audience compare. Pas
|
|
479
|
+
// de clé, pas de titre : mieux vaut aucun titre qu'un titre usurpé.
|
|
480
|
+
function etatDuServeur(st){
|
|
481
|
+
if(typeof st.presenter_key!=='undefined'){var k=st.presenter_key||'';
|
|
482
|
+
if(k!==PRESKEY){PRESKEY=k;try{if(ch)renderPres(ch.presenceState());}catch(e){}}}
|
|
483
|
+
if(_onState)_onState(st);
|
|
484
|
+
}
|
|
485
|
+
relire('&state=1',function(d){if(d.state)etatDuServeur(d.state);});
|
|
486
|
+
|
|
466
487
|
// Le message reçu sert à savoir QU'IL SE PASSE quelque chose, et à notifier ; son CONTENU
|
|
467
488
|
// vient de la relecture. Sans ça, une notification pourrait afficher un texte forgé.
|
|
468
489
|
ch.on('broadcast',{event:'msg'},function(){relireChat();});
|
|
@@ -480,6 +501,10 @@ var Live=(function(){
|
|
|
480
501
|
}catch(e){}}
|
|
481
502
|
function disconnect(){try{clearInterval(_tyIv);}catch(e){}try{clearInterval(_atIv);}catch(e){}try{sendAttend();}catch(e){}try{if(ch){ch.untrack();ch.unsubscribe();ch=null;}}catch(e){}var pb=document.getElementById('presBtn');if(pb)pb.style.display='none';var cb=document.getElementById('chatBtn');if(cb)cb.style.display='none';var _fb=document.getElementById('chatFab');if(_fb)_fb.classList.remove('on');var pn=document.getElementById('chatPanel');if(pn)pn.classList.add('hidden');}
|
|
482
503
|
// Membre de l'équipe reconnu via la session app (MÊME ORIGINE, localStorage) → avatar + nom auto.
|
|
504
|
+
// Le jeton d'acces de la session locale, quand il y en a une. C'est la SEULE chose qui prouve au
|
|
505
|
+
// serveur qu'on est un membre ; 'member:true' dans la page ne prouve rien, il ne sert qu'a l'affichage.
|
|
506
|
+
function accessToken(){try{var raw=localStorage.getItem('3dd-supabase-auth');if(!raw)return '';var s=JSON.parse(raw);
|
|
507
|
+
return String((s&&(s.access_token||(s.currentSession&&s.currentSession.access_token)||(s.session&&s.session.access_token)))||'');}catch(e){return '';}}
|
|
483
508
|
function detectMember(){try{var raw=localStorage.getItem('3dd-supabase-auth');if(!raw)return null;var s=JSON.parse(raw);var u=s&&(s.user||(s.currentSession&&s.currentSession.user)||(s.session&&s.session.user));if(u&&u.email){var m=u.user_metadata||{};return{name:m.name||u.email,email:u.email,avatar:m.avatarUrl||'',member:true,role:'viewer'};}}catch(e){}return null;}
|
|
484
509
|
return {connect:connect,disconnect:disconnect,detectMember:detectMember,sendMap:sendMap,onMap:onMap,sendState:sendState,onState:onState};
|
|
485
510
|
})();`;
|
|
@@ -1207,6 +1232,24 @@ function adresseAppelant(req) {
|
|
|
1207
1232
|
return String((req.socket && req.socket.remoteAddress) || "").slice(0, 60);
|
|
1208
1233
|
}
|
|
1209
1234
|
|
|
1235
|
+
/**
|
|
1236
|
+
* Cet appelant est-il un MEMBRE de l'hôte, prouvé — pas déclaré ?
|
|
1237
|
+
*
|
|
1238
|
+
* ⚠️ L'appartenance décide de quelle POPULATION une lecture rejoint. « Ce client a lu douze
|
|
1239
|
+
* minutes » ne vaut que si un collègue relisant le document n'entre pas dans le même compte : c'est
|
|
1240
|
+
* la promesse que ce produit vend. Un booléen envoyé par le navigateur ne prouve rien — il est
|
|
1241
|
+
* choisi par qui l'envoie.
|
|
1242
|
+
*
|
|
1243
|
+
* Le jeton d'accès, lui, est vérifié par l'hôte. Absent ou invalide : pas membre. Jamais de repli
|
|
1244
|
+
* sur ce que l'appelant affirme, sinon la vérification ne sert qu'aux honnêtes.
|
|
1245
|
+
*/
|
|
1246
|
+
async function membreVerifie(req) {
|
|
1247
|
+
try {
|
|
1248
|
+
const u = await PLAYER.identity.verifyToken((req.headers && req.headers.authorization) || "");
|
|
1249
|
+
return !!(u && u.email);
|
|
1250
|
+
} catch { return false; }
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1210
1253
|
async function relayerFichier(res, r, disposition) {
|
|
1211
1254
|
if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
|
|
1212
1255
|
if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
|
|
@@ -2548,7 +2591,21 @@ async function handler(req, res) {
|
|
|
2548
2591
|
const ip = adresseAppelant(req) || "anon";
|
|
2549
2592
|
const allowed = await PLAYER.limits.allow(`patt:${ip}`, 1000, 3600);
|
|
2550
2593
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2551
|
-
|
|
2594
|
+
// ⚠️ CES DEUX BOOLÉENS ÉTAIENT CEUX DE L'APPELANT. `isMember` sépare la population interne
|
|
2595
|
+
// de celle des prospects — c'est la promesse même du produit — et `isPresenter` accorde le
|
|
2596
|
+
// titre dans la liste des participants. Les deux venaient du corps de la requête : un
|
|
2597
|
+
// prospect pouvait se compter comme collègue, et se donner le titre de présentateur.
|
|
2598
|
+
//
|
|
2599
|
+
// Ce qui distingue un présentateur d'un participant n'est pas ce qu'il affirme, c'est le
|
|
2600
|
+
// `control_token` — `present-chat` le vérifiait déjà pour le badge des messages, cette
|
|
2601
|
+
// route ne le faisait pas. Et l'appartenance se prouve par le jeton d'accès de la session :
|
|
2602
|
+
// cette route est un `fetch`, elle peut porter un en-tête (contrairement au suivi de
|
|
2603
|
+
// lecture, qui part par `sendBeacon` et signe donc dans le corps).
|
|
2604
|
+
const pres = await getPresentation(String(body.slug || ""));
|
|
2605
|
+
if (!pres) return jp(404, { ok: false });
|
|
2606
|
+
const estPresentateur = !!(body.control && require("crypto").createHash("sha256").update(String(body.control)).digest("hex") === pres.control_hash);
|
|
2607
|
+
const estMembre = await membreVerifie(req);
|
|
2608
|
+
const r = await recordAttendance(String(body.slug || ""), { key: body.key, name: body.name, email: body.email, avatar: body.avatar, isMember: estMembre, isPresenter: estPresentateur });
|
|
2552
2609
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
2553
2610
|
} catch { return jp(500, { ok: false }); }
|
|
2554
2611
|
}
|
|
@@ -2588,7 +2645,10 @@ async function handler(req, res) {
|
|
|
2588
2645
|
// participant pourrait poster un message usurpant le présentateur). Sert aussi au chat verrouillé.
|
|
2589
2646
|
const validControl = !!(body.control && require("crypto").createHash("sha256").update(String(body.control)).digest("hex") === pres.control_hash);
|
|
2590
2647
|
if (pres.chat_locked && !validControl) return jp(423, { ok: false, error: "locked" });
|
|
2591
|
-
|
|
2648
|
+
// `isMember` restait l'affirmation du client, alors que `isPresenter` était vérifié juste
|
|
2649
|
+
// au-dessus. Deux poids sur la même ligne : le badge « présentateur » se méritait, celui
|
|
2650
|
+
// de collègue se réclamait.
|
|
2651
|
+
const r = await addMessage(String(body.slug || ""), { name: body.name, email: body.email, avatar: body.avatar, isPresenter: validControl, isMember: await membreVerifie(req), body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment });
|
|
2592
2652
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
2593
2653
|
} catch { return jp(500, { ok: false }); }
|
|
2594
2654
|
}
|
|
@@ -2979,6 +3039,10 @@ async function handler(req, res) {
|
|
|
2979
3039
|
file_name: pres.file_name || null,
|
|
2980
3040
|
doc_title: pres.doc_title || null,
|
|
2981
3041
|
updated_at: pres.updated_at || null,
|
|
3042
|
+
// Qui porte le titre, d'après le serveur. La liste des participants le tirait de la charge
|
|
3043
|
+
// de présence, que chacun compose : il suffisait de s'annoncer présentateur pour l'être aux
|
|
3044
|
+
// yeux de tous. Une clé, pas un booléen — l'audience compare, elle ne croit pas.
|
|
3045
|
+
presenter_key: await presenterKey(String(q.present || "")),
|
|
2982
3046
|
} }));
|
|
2983
3047
|
return;
|
|
2984
3048
|
}
|
package/server/presentations.js
CHANGED
|
@@ -281,6 +281,29 @@ async function toggleReaction(slug, msgId, emoji, reactor) {
|
|
|
281
281
|
return { ok: true, message: premierPublic(maj) };
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* La clé du participant qui porte le titre de présentateur, telle que le SERVEUR la connaît.
|
|
286
|
+
*
|
|
287
|
+
* ⚠️ Elle existe parce que la liste des participants affichait le titre d'après la charge de
|
|
288
|
+
* PRÉSENCE, que chacun compose lui-même : `track({role:'presenter'})` suffisait à apparaître comme
|
|
289
|
+
* présentateur devant toute l'audience, avec le nom et l'avatar de son choix. Le canal ne peut pas
|
|
290
|
+
* arbitrer ça — un participant légitime a le droit d'y écrire sa présence.
|
|
291
|
+
*
|
|
292
|
+
* Le titre est désormais accordé ici, à partir de `is_presenter`, lui-même dérivé du `control_token`.
|
|
293
|
+
* Null quand personne ne l'a prouvé : mieux vaut aucun titre qu'un titre usurpé.
|
|
294
|
+
*/
|
|
295
|
+
async function presenterKey(slug) {
|
|
296
|
+
if (!slug) return null;
|
|
297
|
+
// ⚠️ NE JAMAIS FAIRE ÉCHOUER LA RELECTURE D'ÉTAT. Cette recherche est un ajout à une route dont
|
|
298
|
+
// l'audience dépend pour savoir quelle page est affichée. Une requête de plus, c'est une raison
|
|
299
|
+
// de plus de renvoyer 500 — et perdre TOUT l'état parce qu'on n'a pas su dire qui porte un badge
|
|
300
|
+
// serait un très mauvais échange. Sans réponse : pas de clé, donc pas de titre, et le reste passe.
|
|
301
|
+
try {
|
|
302
|
+
const rows = await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&is_presenter=is.true&select=attendee_key&order=last_seen.desc&limit=1`);
|
|
303
|
+
return (Array.isArray(rows) && rows[0] && rows[0].attendee_key) || null;
|
|
304
|
+
} catch { return null; }
|
|
305
|
+
}
|
|
306
|
+
|
|
284
307
|
// ── Statistiques de présentation (assistance) ────────────────────────────────────────────────────────────
|
|
285
308
|
// Heartbeat d'un participant : upsert de sa ligne d'assistance. On accumule le temps de présence (intervalles
|
|
286
309
|
// < 60 s → un aller-retour ne gonfle pas total_ms) et l'ensemble des pages vues (page courante de la présentation).
|
|
@@ -302,7 +325,12 @@ async function recordAttendance(slug, { key, name, email, avatar, isMember, isPr
|
|
|
302
325
|
const addMs = gap > 0 && gap <= ATTEND_MAX_GAP_MS ? gap : 0;
|
|
303
326
|
const pages = Array.isArray(cur.pages) ? cur.pages.slice() : [];
|
|
304
327
|
if (!pages.includes(page)) pages.push(page);
|
|
305
|
-
await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { last_seen: new Date(now).toISOString(), total_ms: Number(cur.total_ms || 0) + addMs, pages, name: (name || cur.name || "").slice(0, 120) || null, avatar: (avatar || cur.avatar || "").slice(0, 600) || null
|
|
328
|
+
await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { last_seen: new Date(now).toISOString(), total_ms: Number(cur.total_ms || 0) + addMs, pages, name: (name || cur.name || "").slice(0, 120) || null, avatar: (avatar || cur.avatar || "").slice(0, 600) || null,
|
|
329
|
+
// ⚠️ Les deux drapeaux se remettent à jour à chaque battement, ils ne sont plus figés à la
|
|
330
|
+
// première ligne : un transfert de présentation change qui porte le titre, et une session qui
|
|
331
|
+
// s'authentifie en cours de route devient un membre. Figés, ils décriraient l'instant de
|
|
332
|
+
// l'arrivée et non la réalité — et le premier arrivé aurait raison pour toujours.
|
|
333
|
+
is_member: !!isMember, is_presenter: !!isPresenter } });
|
|
306
334
|
return { ok: true };
|
|
307
335
|
}
|
|
308
336
|
|
|
@@ -382,4 +410,4 @@ async function listPresentationsForDoc(docId) {
|
|
|
382
410
|
}
|
|
383
411
|
|
|
384
412
|
module.exports = {
|
|
385
|
-
messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
|
|
413
|
+
messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presenterKey, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
|