@gotcos/glasses-server 6.99.12 → 6.99.16

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.
@@ -5,7 +5,8 @@
5
5
  Data: GET /api/meetings?limit=200&domain=all&month=, /api/meetings/search, /api/meetings/detail,
6
6
  /api/meetings/live-chunks, /api/meeting/sessions/:id/status, /api/meeting/orphans, /api/meeting-ask/*,
7
7
  /api/live-cues/history, /api/context/status. Writes only on a tap: Meeting Ask, Stop and save, End and save,
8
- Recover, Save, Save as follow-up.
8
+ Recover, Save, Save as follow-up, and (6.99.14) Add file, paste or drop in the live view: /api/media or
9
+ /api/media/file, then POST /api/meeting-ask/files/media.
9
10
  Routes: #/meetings, #/meetings/<domain>/<month>/<filename>, #/meetings/live/<sessionId>. */
10
11
  (function () {
11
12
  'use strict';
@@ -812,6 +813,7 @@
812
813
  if (r.startTime != null) f.start = r.startTime;
813
814
  if (r.lastActivityAt != null) f.last = Math.max(f.last || 0, r.lastActivityAt);
814
815
  if (typeof r.titleSteer === 'string') f.titleSteer = cleanTitle(r.titleSteer) || null;
816
+ setLevels(f, r.levels);
815
817
  liveChanged();
816
818
  }, function () {});
817
819
  }
@@ -1793,17 +1795,32 @@
1793
1795
  return html;
1794
1796
  }
1795
1797
  var FILE_STATE = { pending: 'Reading', unsupported: 'No readable text', failed: 'Could not be read', 'secret-held': 'Held: it appears to contain credentials' };
1796
- function filesRowHtml(sid) {
1798
+ // `live`: the live view while it records, where this page adds files itself (Add file, paste, drop). There the row is
1799
+ // always drawn, one line tall whatever it says, so files that arrive with the Ask history, an upload that starts and
1800
+ // one that finishes never move what is under it. Open, the files and the uploads are rows of the same height: a
1801
+ // finished upload becomes its file in place. { items, note } are the uploads and the last refusal.
1802
+ function filesRowHtml(sid, live) {
1797
1803
  var a = A(sid), shown = (a.files || []).filter(function (x) { return x && x.status !== 'removed' && String(x.name || '').trim(); });
1798
- if (!shown.length) return '';
1799
- var title = 'Files \u00b7 ' + shown.length;
1800
- var html = '<div class="mt-files"><button type="button" class="mt-files-head" data-mt="files-toggle" data-sid="' + esc(sid) + '" aria-expanded="' + !!a.filesOpen + '">' +
1801
- icon('clip') + '<span>' + esc(title) + '</span>' + icon('chev') + '</button>';
1802
- if (a.filesOpen) {
1804
+ var ups = live ? live.items : [], note = live ? live.note : '';
1805
+ if (!shown.length && !(live && live.on) && !ups.length && !note) return '';
1806
+ var busy = ups.filter(function (t) { return t.state !== 'failed'; }).length, failed = ups.length - busy;
1807
+ var title = shown.length ? 'Files \u00b7 ' + shown.length : (live && live.on ? 'No files yet. Add file, paste or drop one here.' : 'Files');
1808
+ var extra = busy ? ' \u00b7 adding ' + busy : '';
1809
+ var bad = failed ? (failed === 1 ? '1 did not upload' : failed + ' did not upload') : '';
1810
+ var openable = shown.length + ups.length > 0 || !!note;
1811
+ var html = '<div class="mt-files' + (live ? ' live' : '') + '">' + (openable
1812
+ ? '<button type="button" class="mt-files-head" data-mt="files-toggle" data-sid="' + esc(sid) + '" aria-expanded="' + !!a.filesOpen + '">'
1813
+ : '<div class="mt-files-head quiet">') +
1814
+ icon('clip') + '<span>' + esc(title + extra) + (bad ? '<span class="mt-amber"> \u00b7 ' + esc(bad) + '</span>' : '') + '</span>' + (openable ? icon('chev') + '</button>' : '</div>');
1815
+ if (a.filesOpen && openable) {
1803
1816
  html += '<ul class="mt-files-list">' + shown.map(function (x) {
1804
- var note = [x.status !== 'ready' ? (x.notice || FILE_STATE[x.status] || '') : '', x.origin === 'phone' ? 'Added from the phone' : ''].filter(Boolean).join(' \u00b7 ');
1805
- return '<li><span class="mt-files-name">' + esc(x.name) + '</span>' + (note ? '<span class="mt-muted">' + esc(note) + '</span>' : '') + '</li>';
1806
- }).join('') + '</ul><p class="mt-muted mt-files-note">Meeting Ask reads these files. Add or remove them in COS Control on your Mac.</p>';
1817
+ var line = [x.status !== 'ready' ? (x.notice || FILE_STATE[x.status] || '') : (x.notice || ''), x.origin === 'phone' ? 'Added from a phone or browser' : ''].filter(Boolean).join(' \u00b7 ');
1818
+ return '<li><span class="mt-files-name">' + esc(x.name) + '</span>' + (line ? '<span class="mt-muted">' + esc(line) + '</span>' : '') + '</li>';
1819
+ }).join('') + ups.map(uploadRowHtml).join('') + '</ul>' +
1820
+ (note ? '<p class="mt-amber-line mt-files-note" role="status">' + esc(note) + '</p>' : '') +
1821
+ '<p class="mt-muted mt-files-note">' + (live && live.on
1822
+ ? 'Meeting Ask reads these files. Add more here; remove them in COS Control on your Mac.'
1823
+ : 'Meeting Ask reads these files. Add or remove them in COS Control on your Mac.') + '</p>';
1807
1824
  }
1808
1825
  return html + '</div>';
1809
1826
  }
@@ -1901,18 +1918,27 @@
1901
1918
  }
1902
1919
  function openLive(sid) {
1903
1920
  S.view = 'live';
1904
- if (S.liveSid !== sid) { S.liveSid = sid; S.follow = true; S.confirm = ''; S.liveCopy = ''; S.liveReason = ''; }
1921
+ if (S.liveSid !== sid) { S.liveSid = sid; S.confirm = ''; S.liveCopy = ''; S.liveReason = ''; }
1922
+ // A new box starts at its newest line, following.
1923
+ S.follow = true;
1905
1924
  var f = feedFor(sid);
1906
1925
  A(sid).ctx = { live: true };
1907
1926
  W.setCrumb(liveTitle(f));
1908
- pane.innerHTML = '<div class="cx mt mt-livev" data-tint="meetings"><div id="mt-lhead"></div><div class="mt-lgrid"><div class="mt-lmain">' +
1909
- '<div id="mt-turns" class="mt-turns" aria-live="polite"></div><div id="mt-lfoot"></div></div>' +
1927
+ // 6.99.14: the transcript scrolls inside its own box, as Control's does, so a new line never moves the page: the
1928
+ // title, the recording bar, the files and the Ask panel stay where the reader left them. Everything that changes
1929
+ // height while it records (the stop line, the files, the confirm card) sits under the box, never above it.
1930
+ pane.innerHTML = '<div class="cx mt mt-livev" data-tint="meetings"><div id="mt-lhead"></div><div class="mt-lgrid"><div class="mt-lmain" id="mt-lmain">' +
1931
+ '<div class="mt-tbox"><div id="mt-turns" class="mt-turns" tabindex="0" role="log" aria-live="polite" aria-label="Live transcript"></div>' +
1932
+ '<button type="button" class="cx-btn quiet mt-jump" id="mt-jump" data-mt="jump" hidden>Jump to live</button></div>' +
1933
+ '<div id="mt-lbar" class="mt-lbarslot"></div><div id="mt-lfiles"></div><div id="mt-lfoot"></div>' +
1934
+ '<input type="file" id="mt-lpick" class="mt-pick" multiple accept="' + esc(LIVE_ACCEPT) + '" tabindex="-1" aria-hidden="true"></div>' +
1910
1935
  '<aside class="mt-side-panel" id="mt-ask-' + safeId(sid) + '" data-sid="' + esc(sid) + '">' + sidePanelHtml(sid) + '</aside></div></div>';
1911
- S.headHtml = S.footHtml = null;
1936
+ S.headHtml = S.footHtml = S.barHtml = S.filesHtml = null;
1912
1937
  paintLiveHead(); paintTurns(true); paintLiveFoot();
1913
- clearInterval(S.liveTimer);
1938
+ clearInterval(S.liveTimer); clearInterval(S.clockTimer);
1914
1939
  livePoll();
1915
1940
  S.liveTimer = setInterval(livePoll, 1500);
1941
+ S.clockTimer = setInterval(tickClock, 1000);
1916
1942
  panelOpen(sid);
1917
1943
  }
1918
1944
  // Control's LiveMeeting.title: the typed name, else a calendar event that started within CALENDAR_MATCH_MS of the
@@ -1951,7 +1977,9 @@
1951
1977
  var t = Date.now(), files = a.body.sessions || [];
1952
1978
  S.files = files;
1953
1979
  var file = files.filter(function (s) { return s.sessionId === f.sid; })[0];
1954
- var changed = applyRead(f, a.body.read, t);
1980
+ var r = a.body.read;
1981
+ if (r && r.sessionId === f.sid && !r.unchanged) setLevels(f, r.levels);
1982
+ var changed = applyRead(f, r, t);
1955
1983
  if (file) { var m = num(file.mtimeMs); if (!f.last || m > f.last) f.last = m; }
1956
1984
  else if (!f.endedAt) changed = markEnded(f, t) || changed;
1957
1985
  if (!f.endedAt && f.last && t - f.last >= QUIET_MS && t - f.statusAt >= STATUS_QUIET_MS) askStatus(f, t);
@@ -1992,22 +2020,276 @@
1992
2020
  : (ended ? '' : (isMac(sid) ? 'Recording on this Mac' : 'Recording on the glasses') + (f.titleSteer && f.start ? ' · started ' + clockOf(new Date(f.start)) : ''));
1993
2021
  var quiet = ended ? null : quietLabel(f.last, now);
1994
2022
  var sub = f.titleSteer && f.start && !isPhone(sid) ? (durationLabel(f.start, now, f.endedAt) || 'Starting') : liveSubtitle(f.start, now, f.endedAt);
1995
- var controls = '';
1996
- if (!ended && isGlasses(sid)) controls = '<button type="button" class="cx-btn quiet" data-mt="stop-ask" data-sid="' + esc(sid) + '"' + (stopping(sid) ? ' disabled' : '') + '>Stop and save</button>';
1997
- else if (!ended && isPhone(sid)) controls = '<span class="mt-side-note">Stop on iPhone</span>';
1998
- var line = stopLineFor(sid);
2023
+ // Every line here is one line tall whatever it says (the CSS keeps each to one line), so the header never changes
2024
+ // height while it records and the transcript under it never moves.
1999
2025
  var html = '<div class="mt-dhead mt-lhead"><div class="mt-ltitle"><span class="mt-livedot big' + (ended ? '' : ' on') + '"></span><h2 class="mt-dtitle">' + esc(liveTitle(f)) + '</h2></div>' +
2000
2026
  (status ? '<p class="mt-lstatus">' + esc(status) + '</p>' : '') +
2001
2027
  (isPhone(sid) ? '<p class="mt-muted">iPhone microphone recordings are anonymous. Names and voice training are unavailable.</p>' : '') +
2002
2028
  '<p class="mt-muted">' + esc(LIVE_HEADER) + '</p>' +
2003
2029
  (isMac(sid) ? '<p class="mt-muted">Mac playback · microphone off · voice numbers do not identify people</p>' : '') +
2004
- '<p class="mt-lsub">' + esc(sub) + (quiet ? '<span class="mt-amber"> · ' + esc(quiet) + '</span>' : '') + '</p>' +
2005
- liveHandoffLine(f) + filesRowHtml(sid) +
2006
- (line ? '<p class="mt-stopline' + (S.stop && S.stop.nothing ? ' bad' : '') + '">' + esc(line) + '</p>' : '') +
2007
- (controls ? '<div class="mt-bar">' + controls + '</div>' : '') +
2008
- (S.confirm === 'stop:' + sid ? confirmCard(STOP_TITLE, STOP_LINE, 'stop-yes', 'Stop and save', 'Cancel', sid) : '') + '</div>';
2030
+ '<p class="mt-lsub">' + esc(sub) + (quiet ? '<span class="mt-amber"> · ' + esc(quiet) + '</span>' : '') + '</p></div>';
2009
2031
  if (html !== S.headHtml) { S.headHtml = html; el.innerHTML = html; }
2010
2032
  if (W.crumb() !== liveTitle(f)) W.setCrumb(liveTitle(f));
2033
+ paintLiveBar(); paintLiveFiles();
2034
+ }
2035
+
2036
+ // ── the recording bar (LiveRecordingBar, 0.5.339) ──────
2037
+ // The red dot, the elapsed time on its own one-second clock, the level strip, Add file and Stop and save, in one row
2038
+ // of fixed height under the transcript. Under it: the stop line and the confirm card. Once it ends: the handoff line.
2039
+ // The row is rebuilt only when what it offers changes; the clock and the strip draw in place.
2040
+ function elapsedLabel(ms) {
2041
+ var t = Math.max(0, Math.floor((isFinite(ms) ? ms : 0) / 1000)), h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;
2042
+ return h > 0 ? h + ':' + pad2(m) + ':' + pad2(s) : m + ':' + pad2(s);
2043
+ }
2044
+ function clockText(f, now) { return f && f.start ? elapsedLabel(now - f.start) : 'Starting'; }
2045
+ // The Meeting Ask files route takes the ids Meeting Ask does (server/lib/ask-session-id.ts ASK_SESSION).
2046
+ var ASK_SESSION = /^(?:meeting_[A-Za-z0-9_-]{1,89}|(?:mac_|phone_(?:[0-9]{13}_)?)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
2047
+ function canAddFiles(f) { return !!f && !f.endedAt && ASK_SESSION.test(f.sid); }
2048
+ function liveStopHtml(sid) {
2049
+ if (isGlasses(sid)) return '<button type="button" class="cx-btn quiet mt-stopbtn" data-mt="stop-ask" data-sid="' + esc(sid) + '"' + (stopping(sid) ? ' disabled' : '') + '>Stop and save</button>';
2050
+ if (isPhone(sid)) return '<span class="mt-side-note">Stop on iPhone</span>';
2051
+ return '<span class="mt-side-note">Stop on your Mac</span>';
2052
+ }
2053
+ function liveBarHtml(f) {
2054
+ var sid = f.sid;
2055
+ if (f.endedAt) return liveHandoffLine(f);
2056
+ var line = stopLineFor(sid);
2057
+ return '<div class="mt-lbar" role="group" aria-label="Recording">' +
2058
+ '<span class="mt-livedot on" aria-hidden="true"></span>' +
2059
+ '<span class="mt-ltime' + (f.start ? '' : ' starting') + '" id="mt-ltime" role="timer" aria-label="Recording time">' + esc(clockText(f, Date.now())) + '</span>' +
2060
+ '<span class="mt-meterwrap"><canvas class="mt-meter" id="mt-meter" role="img" aria-label="' + esc(LEVEL_LABEL) + '"></canvas>' +
2061
+ '<span class="mt-glyph" aria-hidden="true"><i></i><i></i><i></i></span></span>' +
2062
+ (canAddFiles(f) ? '<button type="button" class="cx-btn quiet mt-addfile" data-mt="add-file" aria-label="Add file" title="Add a screenshot or files to this meeting">' + icon('clip') + '<span class="mt-addlabel">Add file</span></button>' : '') +
2063
+ liveStopHtml(sid) + '</div>' +
2064
+ (line ? '<p class="mt-stopline' + (S.stop && S.stop.nothing ? ' bad' : '') + '">' + esc(line) + '</p>' : '') +
2065
+ (S.confirm === 'stop:' + sid ? confirmCard(STOP_TITLE, STOP_LINE, 'stop-yes', 'Stop and save', 'Cancel', sid) : '');
2066
+ }
2067
+ function paintLiveBar() {
2068
+ var el = $('mt-lbar'); if (!el || S.view !== 'live') return;
2069
+ var f = S.feeds[S.liveSid]; if (!f) return;
2070
+ // The clock's own text is left out of the comparison, so a second ticking never rebuilds the row (or the strip).
2071
+ var html = liveBarHtml(f), key = html.replace(/(id="mt-ltime"[^>]*>)[^<]*/, '$1');
2072
+ if (key !== S.barHtml) { S.barHtml = key; el.innerHTML = html; drawMeter(); }
2073
+ }
2074
+ function tickClock() {
2075
+ if (S.view !== 'live' || document.hidden) return;
2076
+ var f = S.feeds[S.liveSid], el = $('mt-ltime');
2077
+ if (!f || !el) return;
2078
+ var text = clockText(f, Date.now());
2079
+ if (el.textContent !== text) el.textContent = text;
2080
+ }
2081
+
2082
+ // ── the level strip (LiveChunkLevelStripView) ─────────
2083
+ // One bar per chunk from the live read's `levels` (server 6.87.0: the quiet gate's RMS and Silero speech seconds,
2084
+ // kept on the active-session record), newest at the right, a dot where the chunk held almost no speech, the oldest
2085
+ // clipped first when narrow. It trails the room by a chunk. Reduce Motion: one still bar for the newest chunk.
2086
+ var LEVEL_STEP = 5, SPEECH_FLOOR = 0.5, LEVEL_FLOOR = 0.27;
2087
+ var LEVEL_LABEL = 'Audio level, a few seconds behind';
2088
+ function levelSilent(l) { return l.speechSec != null ? l.speechSec < SPEECH_FLOOR : l.level <= LEVEL_FLOOR; }
2089
+ function cleanLevels(v) {
2090
+ if (!Array.isArray(v)) return null;
2091
+ var seen = {}, out = [];
2092
+ v.forEach(function (l) {
2093
+ if (!l || typeof l !== 'object' || !Number.isInteger(l.i) || l.i < 0 || seen[l.i]) return;
2094
+ if (typeof l.level !== 'number' || !(l.level >= 0 && l.level <= 1)) return;
2095
+ if (l.speechSec != null && !(typeof l.speechSec === 'number' && l.speechSec >= 0)) return;
2096
+ seen[l.i] = 1; out.push({ i: l.i, level: l.level, speechSec: l.speechSec == null ? null : l.speechSec });
2097
+ });
2098
+ return out.sort(function (a, b) { return a.i - b.i; }).slice(-120);
2099
+ }
2100
+ function visibleLevels(levels, width) { var fit = Math.floor(width / LEVEL_STEP); return fit < 1 ? [] : levels.slice(-fit); }
2101
+ function reduceMotion() { try { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (_e) { return false; } }
2102
+ function drawMeter() {
2103
+ var c = $('mt-meter'); if (!c || !c.getContext) return;
2104
+ var f = S.feeds[S.liveSid], levels = (f && f.levels) || [];
2105
+ var wrap = c.parentNode; if (wrap && wrap.classList) wrap.classList.toggle('mt-nolevels', !levels.length);
2106
+ var last = levels[levels.length - 1];
2107
+ c.setAttribute('aria-label', LEVEL_LABEL + ': ' + (!last ? 'waiting for the first chunk' : levelSilent(last) ? 'quiet' : 'speech'));
2108
+ var w = c.clientWidth, h = c.clientHeight; if (!w || !h) return;
2109
+ var dpr = window.devicePixelRatio || 1;
2110
+ if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); }
2111
+ var g = c.getContext('2d'); if (!g) return;
2112
+ g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, w, h);
2113
+ if (!levels.length) return;
2114
+ var css = getComputedStyle(c), bar = css.getPropertyValue('--mt-bar').trim() || '#c2410c', dot = css.getPropertyValue('--mt-dot').trim() || '#8a8a8a';
2115
+ var mid = h / 2;
2116
+ if (reduceMotion()) {
2117
+ var lv = levelSilent(last) ? 0 : last.level;
2118
+ g.fillStyle = dot; g.globalAlpha = 0.35; g.fillRect(0, mid - 2, w, 4); g.globalAlpha = 1;
2119
+ g.fillStyle = bar; g.fillRect(0, mid - 2, Math.max(2, w * lv), 4);
2120
+ return;
2121
+ }
2122
+ var shown = visibleLevels(levels, w);
2123
+ for (var back = 0; back < shown.length; back++) {
2124
+ var l = shown[shown.length - 1 - back], x = w - (back + 0.5) * LEVEL_STEP;
2125
+ // The oldest fade out at the left edge, so clipped history reads as older, not cut.
2126
+ g.globalAlpha = Math.min(1, Math.max(0.15, x / 28));
2127
+ if (levelSilent(l)) { g.fillStyle = dot; g.beginPath(); g.arc(x, mid, 1, 0, Math.PI * 2); g.fill(); }
2128
+ else { var bh = Math.max(3, l.level * h); g.fillStyle = bar; g.fillRect(x - 1.5, mid - bh / 2, 3, bh); }
2129
+ }
2130
+ g.globalAlpha = 1;
2131
+ }
2132
+ function setLevels(f, raw) {
2133
+ var next = cleanLevels(raw); if (!next) return;
2134
+ var sig = next.map(function (l) { return l.i + ':' + l.level + ':' + l.speechSec; }).join('|');
2135
+ if (sig === f.levelSig) return;
2136
+ f.levelSig = sig; f.levels = next;
2137
+ if (S.view === 'live' && S.liveSid === f.sid) drawMeter();
2138
+ }
2139
+
2140
+ // ── files during a live meeting (LiveMeetingFiles) ─────
2141
+ // Add file, paste and drop: each file goes to the media store (W.cosChat.uploadMedia, the composer's own upload) and
2142
+ // then joins the meeting's files through POST /api/meeting-ask/files/media, the route the phone uses, so Meeting Ask
2143
+ // reads it and Copy full context carries it. A file never becomes text: a paste or a drop that holds a file is taken
2144
+ // as the file, and one the browser will not hand over is refused out loud instead of landing as its path.
2145
+ var LIVE_ACCEPT = 'image/*,.heic,.heif,application/pdf,.pdf,text/plain,.txt,text/markdown,.md,text/csv,.csv,application/json,.json,.xlsx';
2146
+ var MAX_LIVE_UPLOADS = 10;
2147
+ function LU() { return S.lup || (S.lup = { sid: '', items: [], note: '' }); }
2148
+ function liveUploads(sid) { var u = LU(); if (u.sid !== sid) { u.items.forEach(abortUpload); S.lup = { sid: sid, items: [], note: '' }; } return S.lup; }
2149
+ function abortUpload(t) { if (t.xhr) { try { t.xhr.abort(); } catch (_e) {} } }
2150
+ /** The files a paste or a drop carries: its File list, else the items of kind file. */
2151
+ function filesOf(dt) {
2152
+ if (!dt) return [];
2153
+ var list = Array.prototype.slice.call(dt.files || []);
2154
+ if (!list.length && dt.items) Array.prototype.forEach.call(dt.items, function (it) { if (it && it.kind === 'file' && it.getAsFile) { var x = it.getAsFile(); if (x) list.push(x); } });
2155
+ return list;
2156
+ }
2157
+ /** True when a paste or a drop is a file (or a file's link) rather than words: it must never be inserted as text. */
2158
+ function carriesFile(dt) {
2159
+ if (!dt) return false;
2160
+ var types = Array.prototype.slice.call(dt.types || []);
2161
+ if (types.indexOf('Files') >= 0 || filesOf(dt).length) return true;
2162
+ var uri = '';
2163
+ try { uri = types.indexOf('text/uri-list') >= 0 ? String(dt.getData('text/uri-list') || '') : ''; } catch (_e) { uri = ''; }
2164
+ return /^\s*file:/i.test(uri);
2165
+ }
2166
+ function liveFileKind(file) {
2167
+ var cc = W.cosChat;
2168
+ if (!cc || !cc.classify || !cc.uploadMedia) return { refuse: 'This page could not load its upload code. Reload the page, then try again.' };
2169
+ var c = cc.classify(file);
2170
+ if (c.refuse) return c;
2171
+ if (c.kind === 'video') return { refuse: (file.name || 'That video') + ' is a video. Meeting Ask reads documents and the text in images, so attach a PDF, a text file or a screenshot.' };
2172
+ return c;
2173
+ }
2174
+ function addLiveFiles(list) {
2175
+ var f = S.feeds[S.liveSid];
2176
+ if (S.view !== 'live' || !f) return;
2177
+ var u = liveUploads(f.sid), files = Array.prototype.slice.call(list || []), refusals = [];
2178
+ if (!canAddFiles(f)) { liveNote(f.endedAt ? 'This recording has ended. Files can be added while it records.' : 'Meeting Ask does not take files for this recording.'); return; }
2179
+ files.forEach(function (file) {
2180
+ if (u.items.filter(function (t) { return t.state !== 'failed'; }).length >= MAX_LIVE_UPLOADS) { refusals.push('Up to 10 files at a time. ' + (file.name || 'A file') + ' was not added.'); return; }
2181
+ var c = liveFileKind(file);
2182
+ if (c.refuse) { refusals.push(c.refuse); return; }
2183
+ var t = { id: W.cosChat.uuid(), file: file, name: file.name || (c.kind === 'photo' ? 'Screenshot' : 'File'), size: file.size, kind: c.kind, mime: c.mime || '', state: 'reading', progress: 0, mediaId: '', error: '' };
2184
+ u.items.push(t);
2185
+ sendLiveFile(f.sid, t, false);
2186
+ });
2187
+ u.note = refusals.join(' ');
2188
+ // Opened by the person's own add, in the same task as the paste or drop, never later when an upload lands.
2189
+ A(f.sid).filesOpen = true;
2190
+ paintLiveFiles();
2191
+ }
2192
+ var ATTACH_COPY = {
2193
+ file_context_limit: 'This meeting holds as many files as Meeting Ask reads. Remove one in COS Control on your Mac, then retry.',
2194
+ file_context_storage_limit: 'COS is out of room for meeting files. Remove some in COS Control on your Mac, then retry.',
2195
+ file_revision_conflict: 'The meeting’s files changed while this one was read. Retry.',
2196
+ media_not_found: 'The upload expired before it was added. Retry.', media_expired: 'The upload expired before it was added. Retry.',
2197
+ media_conflict: 'The upload is still being read. Retry in a moment.', media_unavailable: 'COS could not read the upload. Retry.',
2198
+ maintenance: 'COS is updating. Retry in a minute.', invalid_session_id: 'Meeting Ask does not take files for this recording.',
2199
+ invalid_media: 'COS did not accept that file. Retry, or attach it from COS Control on your Mac.'
2200
+ };
2201
+ // The media store has it; now the meeting. A changed server (it restarted) is read again once before giving up.
2202
+ function attachLiveFile(sid, t, again) {
2203
+ t.state = 'attaching'; paintLiveFiles();
2204
+ return loadConfig(again).then(function (conf) {
2205
+ if (!conf) { var e = new Error(S.conf.notice || 'Meeting assistance is not available on this server.'); e.code = 'config'; throw e; }
2206
+ return answer('/api/meeting-ask/files/media', { method: 'POST', body: { sessionId: sid, serverInstanceId: conf.serverInstanceId, media: [{ id: t.mediaId, name: t.name.slice(0, 240) }] } });
2207
+ }).then(function (a) {
2208
+ var b = a.body || {}, code = typeof b.error === 'string' ? b.error : (b.error && b.error.code) || b.reason || '';
2209
+ if (a.status === 200 && Array.isArray(b.files)) return b.files;
2210
+ if (a.status === 409 && code === 'server_instance_mismatch' && !again) return attachLiveFile(sid, t, true);
2211
+ var err = new Error(a.status === 0 ? 'COS on your Mac did not answer. Retry when it is awake.' : a.status === 401 ? SIGNED_OUT + ' The file is kept here; sign in, then retry.' : ATTACH_COPY[code] || 'COS could not add the file to this meeting (HTTP ' + a.status + '). Retry.');
2212
+ err.code = 'attach'; throw err;
2213
+ });
2214
+ }
2215
+ function sendLiveFile(sid, t, retry) {
2216
+ var u = liveUploads(sid);
2217
+ t.state = t.mediaId ? 'attaching' : 'reading'; t.progress = 0; t.error = '';
2218
+ var step = t.mediaId ? Promise.resolve({ id: t.mediaId }) : W.cosChat.uploadMedia(t, {
2219
+ uploading: function () { t.state = 'uploading'; paintLiveFiles(); },
2220
+ progress: function (p) { t.progress = p; paintUploadProgress(t); }
2221
+ });
2222
+ if (retry) paintLiveFiles();
2223
+ step.then(function (ref) {
2224
+ if (u.items.indexOf(t) < 0) return null;
2225
+ if (!ref || !ref.id) { var e = new Error('COS did not keep the file. Retry.'); e.code = 'attach'; throw e; }
2226
+ t.mediaId = ref.id;
2227
+ return attachLiveFile(sid, t, false);
2228
+ }).then(function (files) {
2229
+ if (!files || u.items.indexOf(t) < 0) return;
2230
+ u.items = u.items.filter(function (x) { return x !== t; });
2231
+ var a = A(sid); a.files = files;
2232
+ repaintAsk(sid); paintLiveFiles();
2233
+ }).catch(function (err) {
2234
+ if (u.items.indexOf(t) < 0 || (err && err.code === 'aborted')) return;
2235
+ t.state = 'failed';
2236
+ t.error = err && (err.code === 'attach' || err.code === 'config') ? err.message
2237
+ : err && err.status === 401 ? SIGNED_OUT + ' The file is kept here; sign in, then retry.'
2238
+ : W.cosChat.uploadErrorText(err);
2239
+ paintLiveFiles();
2240
+ });
2241
+ }
2242
+ function uploadMeta(t) {
2243
+ if (t.state === 'reading') return t.kind === 'photo' ? 'Preparing the image' : 'Preparing';
2244
+ if (t.state === 'uploading') return 'Uploading ' + Math.round(t.progress * 100) + '%';
2245
+ if (t.state === 'attaching') return t.kind === 'photo' ? 'Reading the text in the image' : 'Adding to the meeting';
2246
+ return t.error || 'Did not upload';
2247
+ }
2248
+ function uploadRowHtml(t) {
2249
+ return '<li class="mt-up ' + t.state + '" data-up="' + esc(t.id) + '"><span class="mt-files-name">' + esc(t.name) + '</span>' +
2250
+ '<span class="mt-up-meta ' + (t.state === 'failed' ? 'mt-amber' : 'mt-muted') + '" data-up-meta>' + esc(uploadMeta(t)) + '</span>' +
2251
+ '<span class="mt-up-acts">' + (t.state === 'failed' ? '<button type="button" class="mt-link" data-mt="up-retry" data-up="' + esc(t.id) + '">Retry</button>' : '') +
2252
+ '<button type="button" class="mt-link" data-mt="up-remove" data-up="' + esc(t.id) + '" aria-label="Remove ' + esc(t.name) + '">' + (t.state === 'failed' ? 'Remove' : 'Cancel') + '</button></span>' +
2253
+ (t.state === 'uploading' || t.state === 'reading' ? '<span class="mt-up-bar" data-up-bar style="width:' + Math.round(t.progress * 100) + '%"></span>' : '') + '</li>';
2254
+ }
2255
+ // Progress moves the bar and its words only, so a tap on Cancel never misses.
2256
+ function paintUploadProgress(t) {
2257
+ var row = pane.querySelector('[data-up="' + t.id + '"]'); if (!row) return;
2258
+ var meta = row.querySelector('[data-up-meta]'); if (meta) meta.textContent = uploadMeta(t);
2259
+ var bar = row.querySelector('[data-up-bar]'); if (bar) bar.style.width = Math.round(t.progress * 100) + '%';
2260
+ }
2261
+ function paintLiveFiles() {
2262
+ var el = $('mt-lfiles'); if (!el || S.view !== 'live') return;
2263
+ var f = S.feeds[S.liveSid]; if (!f) return;
2264
+ var u = liveUploads(f.sid);
2265
+ var html = filesRowHtml(f.sid, { on: canAddFiles(f), items: u.items, note: u.note });
2266
+ if (html !== S.filesHtml) { S.filesHtml = html; el.innerHTML = html; }
2267
+ }
2268
+ function liveNote(text) { LU().note = text; if (S.liveSid) A(S.liveSid).filesOpen = true; paintLiveFiles(); }
2269
+ function findUpload(id) { var u = LU(); for (var i = 0; i < u.items.length; i++) if (u.items[i].id === id) return u.items[i]; return null; }
2270
+ function onLivePaste(e) {
2271
+ if (S.view !== 'live') return;
2272
+ var dt = e.clipboardData;
2273
+ if (!carriesFile(dt)) return;
2274
+ // Taken as the file, never as its name or path in a text box.
2275
+ e.preventDefault();
2276
+ var files = filesOf(dt);
2277
+ if (files.length) addLiveFiles(files);
2278
+ else liveNote('This browser did not hand over the file. Use Add file to choose it.');
2279
+ }
2280
+ function onLiveDrag(e) {
2281
+ if (S.view !== 'live') return;
2282
+ var dt = e.dataTransfer;
2283
+ if (!carriesFile(dt)) return;
2284
+ // Every file drag is ours: the browser must not open the file, or drop its path into the Ask box.
2285
+ e.preventDefault();
2286
+ var main = $('mt-lmain');
2287
+ if (e.type === 'dragenter' || e.type === 'dragover') { if (dt) dt.dropEffect = 'copy'; if (main) main.classList.add('dropping'); S.dragAt = Date.now(); return; }
2288
+ if (e.type === 'dragleave') { if (main && (!e.relatedTarget || !pane.contains(e.relatedTarget))) main.classList.remove('dropping'); return; }
2289
+ if (main) main.classList.remove('dropping');
2290
+ var files = filesOf(dt);
2291
+ if (files.length) addLiveFiles(files);
2292
+ else liveNote('This browser did not hand over the file. Use Add file to choose it.');
2011
2293
  }
2012
2294
  function paintTurns(force) {
2013
2295
  var el = $('mt-turns'); if (!el) return;
@@ -2017,7 +2299,8 @@
2017
2299
  S.turnsDirty = false;
2018
2300
  var f = S.feeds[S.liveSid]; if (!f) return;
2019
2301
  var hours = f.turns.some(function (t) { return t.elapsedMs >= 3600000; });
2020
- // Reading (not following): hold what is on screen still while turns above it are refined or new ones arrive.
2302
+ // Reading (not following): hold the first line on screen still while turns above it are refined. A new turn lands
2303
+ // below the reader, inside the box, so it moves nothing and needs no scroll at all.
2021
2304
  var anchor = S.follow ? null : turnsAnchor(el);
2022
2305
  if (!f.turns.length) {
2023
2306
  el.innerHTML = '<p class="mt-lempty">' + esc(f.endedAt ? 'No words were transcribed before the recording ended.' : 'Waiting for the first words. Text appears a few seconds after it is spoken.') + '</p>';
@@ -2038,45 +2321,52 @@
2038
2321
  prev = node;
2039
2322
  });
2040
2323
  }
2041
- if (S.follow) jumpToLive(); else { holdAnchor(anchor); paintLiveFoot(); }
2324
+ if (S.follow) pinBottom(el); else holdAnchor(el, anchor);
2325
+ paintJump();
2042
2326
  repaintAskEnabled(f.sid);
2043
2327
  }
2044
2328
  function turnInner(t, hours) {
2045
2329
  return '<div class="mt-lwho"><span class="mt-lname">' + esc(t.speaker) + '</span><span class="mt-lstamp">' + esc(stampMs(t.elapsedMs, hours)) + '</span></div>' +
2046
2330
  t.text.split(/\n{2,}/).map(function (p) { return '<p>' + esc(p) + '</p>'; }).join('');
2047
2331
  }
2048
- // iOS Safari has no scroll anchoring. The anchor is the first turn still on screen; when every turn is above the
2049
- // screen (reading Ask under the transcript) it is the transcript's bottom edge, so the panel below holds still.
2050
- function turnsAnchor(el) {
2051
- var kids = el.children;
2332
+ // iOS Safari has no scroll anchoring, and Chrome's would fight this one (the box sets overflow-anchor: none), so the
2333
+ // anchor is ours on every browser: the first turn still showing in the box, and its distance from the box's top.
2334
+ function turnsAnchor(box) {
2335
+ var kids = box.children, top = box.scrollTop;
2052
2336
  for (var i = 0; i < kids.length; i++) {
2053
- var r = kids[i].getBoundingClientRect();
2054
- if (r.bottom > 0) return r.top >= window.innerHeight ? null : { node: kids[i], top: r.top };
2337
+ var n = kids[i];
2338
+ if (n.offsetTop + n.offsetHeight > top) return { node: n, off: n.offsetTop - top };
2055
2339
  }
2056
- return kids.length ? { node: el, edge: true, top: el.getBoundingClientRect().bottom } : null;
2340
+ return null;
2057
2341
  }
2058
- function holdAnchor(a) {
2342
+ // Moves the box only when a turn above the reader changed height; a write to scrollTop during an iOS fling stops it.
2343
+ function holdAnchor(box, a) {
2059
2344
  if (!a || !a.node.isConnected) return;
2060
- var now = a.edge ? a.node.getBoundingClientRect().bottom : a.node.getBoundingClientRect().top;
2061
- var dy = now - a.top;
2062
- if (Math.abs(dy) >= 1) { S.autoScroll = Date.now(); window.scrollBy(0, dy); }
2345
+ var dy = (a.node.offsetTop - box.scrollTop) - a.off;
2346
+ if (Math.abs(dy) >= 1) box.scrollTop += dy;
2063
2347
  }
2348
+ function atBottom(box) { return box.scrollHeight - box.scrollTop - box.clientHeight <= 40; }
2349
+ function pinBottom(box) { var end = box.scrollHeight - box.clientHeight; if (end - box.scrollTop > 0.5) box.scrollTop = end; }
2064
2350
  // A question needs words: the panel's buttons follow the transcript's first turn.
2065
2351
  function repaintAskEnabled(sid) {
2066
2352
  var a = A(sid), en = askEnabled(sid);
2067
2353
  if (a.lastEnabled !== en) { a.lastEnabled = en; repaintAsk(sid); }
2068
2354
  }
2069
2355
  function jumpToLive() {
2070
- var el = $('mt-turns'), last = el && el.lastElementChild;
2071
- if (last) { S.autoScroll = Date.now(); last.scrollIntoView({ block: 'end' }); }
2072
- S.follow = true; paintLiveFoot();
2356
+ var el = $('mt-turns');
2357
+ if (el) pinBottom(el);
2358
+ S.follow = true; paintJump();
2359
+ }
2360
+ // Jump to live floats over the box's bottom edge: showing it never moves a line.
2361
+ function paintJump() {
2362
+ var b = $('mt-jump'), f = S.feeds[S.liveSid];
2363
+ if (b) b.hidden = S.follow || !(f && f.turns.length);
2073
2364
  }
2074
2365
  function paintLiveFoot() {
2075
2366
  var el = $('mt-lfoot'); if (!el || S.view !== 'live') return;
2076
2367
  var f = S.feeds[S.liveSid], has = !!(f && f.turns.length);
2077
2368
  var html = '<div class="mt-bar"><button type="button" class="cx-btn primary" data-mt="live-copy"' + (has ? '' : ' disabled') + '>Copy transcript</button>' +
2078
- '<button type="button" class="cx-btn quiet" data-mt="live-context">Copy full context</button>' +
2079
- (!S.follow && has ? '<button type="button" class="cx-btn quiet" data-mt="jump">Jump to live</button>' : '') + '</div>' +
2369
+ '<button type="button" class="cx-btn quiet" data-mt="live-context">Copy full context</button></div>' +
2080
2370
  (S.liveCopy ? '<p class="mt-copynote">' + esc(S.liveCopy) + '</p>' : S.liveReason ? '<p class="mt-copynote mt-amber">' + esc('Live transcript paused · ' + S.liveReason.replace(/_/g, ' ')) + '</p>' : '');
2081
2371
  if (html !== S.footHtml) { S.footHtml = html; el.innerHTML = html; }
2082
2372
  }
@@ -2098,14 +2388,12 @@
2098
2388
  return askOpen(f.sid, true).then(function () { return loadCues(f.sid); }).then(function () { done('Ready. Tap Copy full context again.'); });
2099
2389
  });
2100
2390
  }
2101
- function onScroll() {
2102
- if (S.view !== 'live' || Date.now() - (S.autoScroll || 0) < 600) return;
2103
- var el = $('mt-turns'), last = el && el.lastElementChild;
2104
- if (!last) return;
2105
- // Following means the end of the transcript is on screen. Scrolled past it (reading Ask below) is not following:
2106
- // a new chunk must not pull the reader back up.
2107
- var b = last.getBoundingClientRect().bottom, atEnd = b <= window.innerHeight + 40 && b >= 0;
2108
- if (atEnd !== S.follow) { S.follow = atEnd; paintLiveFoot(); if (atEnd && S.turnsDirty) paintTurns(false); }
2391
+ // Following means the end of the transcript shows in its box. Scrolled up is reading: a new line must not pull the
2392
+ // reader down. The page's own scroll is never touched.
2393
+ function onBoxScroll(e) {
2394
+ if (S.view !== 'live' || !e.target || e.target.id !== 'mt-turns') return;
2395
+ var end = atBottom(e.target);
2396
+ if (end !== S.follow) { S.follow = end; paintJump(); if (end && S.turnsDirty) paintTurns(false); }
2109
2397
  }
2110
2398
 
2111
2399
  // ── list shell ────────────────────────────────────────
@@ -2116,7 +2404,7 @@
2116
2404
  '<div id="mt-cal"></div><div id="mt-toolbar"></div><div id="mt-list"></div></div>';
2117
2405
  paintHero(); paintOrphans(); paintLive(); paintCal(); paintToolbar(); paintList();
2118
2406
  }
2119
- function stopTimers() { clearInterval(S.listTimer); clearInterval(S.liveTimer); S.listTimer = null; S.liveTimer = null; }
2407
+ function stopTimers() { clearInterval(S.listTimer); clearInterval(S.liveTimer); clearInterval(S.clockTimer); S.listTimer = null; S.liveTimer = null; S.clockTimer = null; }
2120
2408
 
2121
2409
  // ── events ────────────────────────────────────────────
2122
2410
  function exchangeOf(sid, req) { return A(sid).history.filter(function (x) { return x.requestId === req; })[0]; }
@@ -2165,7 +2453,10 @@
2165
2453
  return;
2166
2454
  }
2167
2455
  case 'ask-refresh': loadConfig(true).then(function () { panelOpen(sid); }); return;
2168
- case 'files-toggle': a = A(sid); a.filesOpen = !a.filesOpen; if (S.view === 'live' && S.liveSid === sid) paintLiveHead(); else repaintAsk(sid); return;
2456
+ case 'files-toggle': a = A(sid); a.filesOpen = !a.filesOpen; if (S.view === 'live' && S.liveSid === sid) paintLiveFiles(); else repaintAsk(sid); return;
2457
+ case 'add-file': { var pick = $('mt-lpick'); if (pick) pick.click(); return; }
2458
+ case 'up-retry': { var ru = findUpload(t.getAttribute('data-up')); if (ru && ru.state === 'failed' && S.liveSid) sendLiveFile(S.liveSid, ru, true); return; }
2459
+ case 'up-remove': { var xu = findUpload(t.getAttribute('data-up')); if (xu) { abortUpload(xu); LU().items = LU().items.filter(function (q) { return q !== xu; }); paintLiveFiles(); } return; }
2169
2460
  case 'ask-act': submit(sid, t.getAttribute('data-action'), t.getAttribute('data-q')); return;
2170
2461
  case 'ask-retry': ex = exchangeOf(sid, req); if (ex) submit(sid, ex.action, ex.question, ex.requestId); return;
2171
2462
  case 'ask-stop': cancelAnswer(sid, req); return;
@@ -2216,6 +2507,7 @@
2216
2507
  if (sid && A(sid).panelDirty) setTimeout(function () { repaintAsk(sid); }, 0);
2217
2508
  });
2218
2509
  pane.addEventListener('change', function (e) {
2510
+ if (e.target.id === 'mt-lpick') { addLiveFiles(e.target.files); e.target.value = ''; return; }
2219
2511
  if (e.target.id === 'mt-recency') { S.recency = e.target.value; paintBrowse(); }
2220
2512
  else if (e.target.id === 'mt-dom') { S.domain = e.target.value; if (queryActive()) runSearch(); else paintBrowse(); }
2221
2513
  else if (e.target.hasAttribute && e.target.hasAttribute('data-mt-model')) setModel(e.target.value);
@@ -2233,7 +2525,13 @@
2233
2525
  var sid = t.getAttribute('data-sid'); submit(sid, 'ask', A(sid).draft);
2234
2526
  }
2235
2527
  });
2236
- window.addEventListener('scroll', onScroll, { passive: true });
2528
+ // scroll does not bubble: the transcript box's own scroll is caught on the way down.
2529
+ pane.addEventListener('scroll', onBoxScroll, true);
2530
+ pane.addEventListener('paste', onLivePaste);
2531
+ // The strip is drawn at the box's own pixel size and in the theme's colours: draw again when either changes.
2532
+ window.addEventListener('resize', function () { if (S.view === 'live') drawMeter(); });
2533
+ if (typeof MutationObserver === 'function') new MutationObserver(function () { if (S.view === 'live') drawMeter(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
2534
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) { pane.addEventListener(type, onLiveDrag); });
2237
2535
  document.addEventListener('visibilitychange', function () {
2238
2536
  if (document.hidden) return;
2239
2537
  if (S.out) return;
@@ -2242,6 +2540,12 @@
2242
2540
  });
2243
2541
  window.addEventListener('focus', refreshShownAsk);
2244
2542
 
2543
+ // Test hooks (server/web-control/live-meeting-view.test.ts drives the real pane through these and through its events).
2544
+ W.meetingsLive = {
2545
+ _state: S, _elapsedLabel: elapsedLabel, _cleanLevels: cleanLevels, _visibleLevels: visibleLevels, _levelSilent: levelSilent,
2546
+ _carriesFile: carriesFile, _filesOf: filesOf, _addLiveFiles: addLiveFiles, _drawMeter: drawMeter, _tickClock: tickClock,
2547
+ _livePoll: livePoll, _feed: function (sid) { return S.feeds[sid]; }, _uploads: function () { return LU(); }
2548
+ };
2245
2549
  W.registerPane('meetings', {
2246
2550
  load: function (args) {
2247
2551
  args = args || [];