diff --git a/server/calls.js b/server/calls.js index fe6263b..13530af 100644 --- a/server/calls.js +++ b/server/calls.js @@ -32,21 +32,25 @@ async function meetingContext(room) { async function finalizeTranscript(room, onlyUserId) { const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; } const buf = transcriptBuffers.get(room) || []; - const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs]; + // CLAIM the subscriber(s) SYNCHRONOUSLY (before any await). Two concurrent finalize calls for the SAME uid — + // e.g. the same user's two devices both leaving at once (#12 multi-device) — would otherwise both pass the + // membership check during the awaits below and write the transcript TWICE (the "transcript shows two times" + // bug). subs.delete() returns true only for the first caller, so the loser claims nothing and writes nothing. + const candidates = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs]; + const ids = candidates.filter((uid) => subs.delete(uid)); if (ids.length && buf.length) { const ctx = await meetingContext(room); const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; }); const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n'; for (const uid of ids) { let user = null; try { user = await R.users.byId(uid); } catch (_) {} - if (!user) { subs.delete(uid); continue; } + if (!user) continue; const id = A.id(); const file = 'm_' + id + '.txt'; try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; } // groupId null → private to its creator (see canSeeRec / /mrec auth). await R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email }); - subs.delete(uid); } - } else { ids.forEach((uid) => subs.delete(uid)); } + } if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done } diff --git a/server/public/home.html b/server/public/home.html index 7825b13..dbcc35f 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -3229,7 +3229,13 @@ async function nativeSaveFile(url, name, meta){ e.preventDefault(); e.stopPropagation(); // never let the WebView navigate to the file var P=C.Plugins; // Real inline save into the app's Files folder (id comes from /files/; mime refines the subfolder). - if(P&&P.Filesystem){ nativeSaveFile(href, a.getAttribute('download')||'', { mime:a.getAttribute('data-mime')||'' }); return; } + if(P&&P.Filesystem){ + var isRec=/\/mrec\//.test(href); var dn=a.getAttribute('download')||''; + // recordings/transcripts: after saving, OPEN in native Quick Look (view + its own share/save — e.g. into + // Files or Word) instead of the WebView loading the file inline with no way back. + nativeSaveFile(href, dn, { mime:a.getAttribute('data-mime')||'' }).then(function(){ if(isRec){ var rid=String(href).split('?')[0].split('/').pop(); var rec=bzLibGet(rid); if(rec) bzOpenFile(rec, dn); } }); + return; + } var isImg = /\.(png|jpe?g|gif|webp|heic|heif|bmp|svg)(\?|#|$)/i.test(href) || !!a.closest('.lightbox'); toast(isImg ? 'Press and hold the image, then tap "Save to Photos".' : 'Saving files needs the latest app update.'); }, true); // capture phase so it wins over the lightbox / bubble handlers @@ -5077,7 +5083,7 @@ async function loadScheduledMeetings(){ +'
'+meta.join(' · ')+'
' +(m.description?'
'+pEsc(m.description)+'
':'') +(m.invited&&m.invited.length?'
'+ic('users',12)+' '+pEsc(m.invited.slice(0,3).join(', '))+(m.invited.length>3?(' +'+(m.invited.length-3)):'')+'
':'') - +(m.recordings&&m.recordings.length?'
'+m.recordings.map(r=>''+ic('download',14)+''+(r.kind==='video'?'Recording':'Transcript')+''+(r.kind==='video'&&r.durationMs?''+fmtElapsed(r.durationMs)+'':'')+'').join('')+'
':'')+'' + +(m.recordings&&m.recordings.length?'
'+m.recordings.map(r=>{ var isVid=r.kind==='video'; var _dn=String(m.title||'Meeting').replace(/[\/\\:*?"<>|]+/g,'_')+(isVid?' recording':' transcript')+(isVid?(/mp4/.test(r.mime||'')?'.mp4':'.webm'):'.txt'); return ''+ic('download',14)+''+(isVid?'Recording':'Transcript')+''+(isVid&&r.durationMs?''+fmtElapsed(r.durationMs)+'':'')+''; }).join('')+'
':'')+'' +'
' +((m.status!=='past'&&!cancelled)?'':'') +((m.status!=='past'&&!cancelled&&canStart)?'':'') diff --git a/server/routes.js b/server/routes.js index ecd07ca..393f6ef 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1320,7 +1320,7 @@ route('GET', '/api/meetings', async (req, res) => { // Attach recordings/transcripts. A recording is visible to its creator, group members, or people // who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become // their own "Past meeting" entry (group calls show the group name). - const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name }); + const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, mime: r.mime || '', createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name }); const canSeeRec = async (r) => { if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner if (r.created_by === u.id) return true;