@gotcos/glasses-server 6.99.25 → 6.99.29
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/CHANGELOG.md +166 -0
- package/package.json +1 -1
- package/server/index.ts +46 -4
- package/server/lib/agent-session-output-images.ts +280 -0
- package/server/lib/agent-session-store.ts +3 -1
- package/server/lib/agent-session-turns.ts +18 -7
- package/server/lib/client-liveness.ts +38 -3
- package/server/lib/codex-subagent-activity.ts +36 -0
- package/server/lib/g2-turn-provenance.ts +679 -0
- package/server/lib/media-store.ts +41 -2
- package/server/lib/permission-broker.ts +94 -17
- package/server/lib/queue-turn-evidence.ts +19 -2
- package/server/lib/session-halt.ts +15 -1
- package/server/lib/session-stream-events.ts +4 -0
- package/server/lib/session-subagents.ts +155 -0
- package/server/lib/session-turn-attachments.ts +245 -0
- package/server/lib/thread-turn-queue-deliver.ts +17 -2
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +731 -0
- package/server/lib/turn-file-store.ts +202 -0
- package/server/lib/turn-footer.ts +81 -0
- package/server/routes/agent-session-bindings.ts +164 -16
- package/server/routes/agent-sessions.ts +77 -8
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +30 -1
- package/server/routes/g2-turns.ts +46 -0
- package/server/routes/health.ts +9 -0
- package/server/routes/media.ts +32 -8
- package/server/routes/permission-broker.ts +47 -4
- package/server/routes/thread-turn-queue.ts +148 -8
- package/server/routes/today-brief.ts +148 -0
- package/server/web-control/app.css +2 -0
- package/server/web-control/app.js +738 -102
- package/server/web-control/index.html +5 -1
- package/server/web-control/panes/cos-chat.css +18 -0
- package/server/web-control/panes/cos-chat.js +239 -45
- package/server/web-control/panes/meetings.js +20 -9
- package/server/web-control/panes/questions.css +39 -1
- package/server/web-control/panes/questions.js +368 -51
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +2 -0
- package/server/web-control/panes/settings.js +34 -2
- package/server/web-control/panes/today-actions.css +62 -4
- package/server/web-control/panes/today-actions.js +384 -53
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
<symbol id="i-photo" viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="2.5"/><circle cx="9" cy="10" r="1.6"/><path d="m4 17 5-4.5 3.5 3 3-2.5 4.5 4"/></symbol>
|
|
41
41
|
<symbol id="i-video" viewBox="0 0 24 24"><rect x="3.5" y="6" width="12.5" height="12" rx="2.5"/><path d="m16 10.5 4.5-2.5v8L16 13.5"/></symbol>
|
|
42
42
|
<symbol id="i-doc" viewBox="0 0 24 24"><path d="M7 3.5h6.5L18 8v12a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 20V4a.5.5 0 0 1 .5-.5z"/><path d="M13.5 3.5V8H18M9 12.5h6M9 15.5h6"/></symbol>
|
|
43
|
+
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M4.5 8h3l1.6-2.2h5.8L16.5 8h3a1.5 1.5 0 0 1 1.5 1.5v8A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-8A1.5 1.5 0 0 1 4.5 8z"/><circle cx="12" cy="13.2" r="3.4"/></symbol>
|
|
43
44
|
<symbol id="i-clip" viewBox="0 0 24 24"><path d="m15.5 7.5-6.8 6.8a1.8 1.8 0 0 0 2.5 2.5l7.3-7.3a3.5 3.5 0 0 0-5-5l-7.6 7.6a5.2 5.2 0 0 0 7.4 7.4l5.7-5.7"/></symbol>
|
|
44
45
|
<symbol id="i-messages" viewBox="0 0 24 24"><path d="M4 7a2.5 2.5 0 0 1 2.5-2.5h11A2.5 2.5 0 0 1 20 7v7a2.5 2.5 0 0 1-2.5 2.5H10L5.5 20v-3.6A2.5 2.5 0 0 1 4 14z"/></symbol>
|
|
45
46
|
<symbol id="i-usage" viewBox="0 0 24 24"><path d="M4 20h16M7 16v-4M12 16V7M17 16v-7"/></symbol>
|
|
@@ -253,8 +254,10 @@
|
|
|
253
254
|
<div class="sheet-grip"></div>
|
|
254
255
|
<form class="sheet-head" id="ask-form">
|
|
255
256
|
<input id="ask-q" class="field" type="search" placeholder="Ask COS or find a session" autocomplete="off" enterkeyhint="go">
|
|
257
|
+
<span id="ask-clip" class="ask-clip"></span>
|
|
256
258
|
<button type="button" class="link-btn" id="ask-close">Cancel</button>
|
|
257
259
|
</form>
|
|
260
|
+
<div id="ask-files" class="cc-chips ask-files hidden" aria-live="polite"></div>
|
|
258
261
|
<div id="ask-results"></div>
|
|
259
262
|
</div>
|
|
260
263
|
</div>
|
|
@@ -276,7 +279,8 @@
|
|
|
276
279
|
"dictation": "/control/panes/dictation.js",
|
|
277
280
|
"settings": "/control/panes/settings.js",
|
|
278
281
|
"memories": "/control/panes/memories.js",
|
|
279
|
-
"todayActions": "/control/panes/today-actions.js"
|
|
282
|
+
"todayActions": "/control/panes/today-actions.js",
|
|
283
|
+
"readerMedia": "/control/panes/reader-media.js"
|
|
280
284
|
}</script>
|
|
281
285
|
<!-- The Knowledge graph explorer, loaded by panes/memories.js when the graph first opens. -->
|
|
282
286
|
<script type="application/json" id="memories-graph-assets">{
|
|
@@ -198,3 +198,21 @@
|
|
|
198
198
|
.cc-prep.dragging .cc-prep-panel { outline: 2px dashed var(--accent); outline-offset: 4px; }
|
|
199
199
|
.cc-prep { scroll-margin-top: calc(64px + var(--safe-top, 0px)); }
|
|
200
200
|
@media (min-width: 860px) { .msg-right .cc-prep { padding: 10px 16px 0; max-height: 55%; overflow-y: auto; flex: none; } }
|
|
201
|
+
|
|
202
|
+
/* The Ask sheet's files (11/07, app.js askFiles): the attach kit's chips and clip beside the Ask box. */
|
|
203
|
+
.sheet-head .cc-attach { flex: none; height: 44px; border-radius: 12px; }
|
|
204
|
+
.ask-clip:empty { display: none; }
|
|
205
|
+
.ask-files { margin-top: 10px; }
|
|
206
|
+
.ask-files .ask-files-note { flex-basis: 100%; margin: 0 2px; font-size: 13px; line-height: 1.35; color: var(--sub); }
|
|
207
|
+
#ask-sheet .sheet.dragging { outline: 2px dashed var(--accent); outline-offset: -6px; }
|
|
208
|
+
|
|
209
|
+
/* 6.99.27: a session's reply box with files (app.js paintReaderFiles, panes/today-actions.js): the kit's chips on a row of
|
|
210
|
+
their own above the words, Take photo and the clip beside Fork and Send, and the limits in plain words under the chips. */
|
|
211
|
+
.att-composer { flex-wrap: wrap; }
|
|
212
|
+
.att-composer .att-files { flex: 1 1 100%; padding: 2px 0 4px; }
|
|
213
|
+
.att-composer .att-files.hidden { display: none; }
|
|
214
|
+
.att-clip, .ta-clip { display: inline-flex; gap: 6px; }
|
|
215
|
+
.att-clip:empty { display: none; }
|
|
216
|
+
.att-clip .cc-attach { height: 38px; width: 42px; border-radius: var(--radius-sm); }
|
|
217
|
+
.att-composer.dragging { outline: 2px dashed var(--accent); outline-offset: 4px; }
|
|
218
|
+
.cc-chips .att-limit { flex-basis: 100%; margin: 0 2px; font-size: 12.5px; line-height: 1.35; color: var(--sub); }
|
|
@@ -107,7 +107,12 @@
|
|
|
107
107
|
steer: !!(C.caps && C.caps.steer),
|
|
108
108
|
thread: !!(drop && drop.enabled === true && drop.thread && drop.thread.protocol === 1),
|
|
109
109
|
// 6.99.19: Prep (capabilities.prep, protocol 1). Absent on an older server: "Prep me" stays a plain Ask.
|
|
110
|
-
prep: prepCapsOf(caps.prep)
|
|
110
|
+
prep: prepCapsOf(caps.prep),
|
|
111
|
+
// 11/07: how many files a session turn takes (threadTurnAttachments). 0 (an older server): words only, and Today's
|
|
112
|
+
// reply box shows no clip, so a file can never be dropped from a turn in silence.
|
|
113
|
+
turnFiles: h && Number.isInteger(h.threadTurnAttachments) && h.threadTurnAttachments > 0 ? Math.min(h.threadTurnAttachments, MAX_ATTS) : 0,
|
|
114
|
+
// 6.99.27: any file for a session turn, up to this many bytes (threadTurnFileBytes). 0: the chat's list only.
|
|
115
|
+
turnFileBytes: h && Number.isInteger(h.threadTurnFileBytes) && h.threadTurnFileBytes > 0 ? Math.min(h.threadTurnFileBytes, MAX_DOC) : 0
|
|
111
116
|
};
|
|
112
117
|
if (next.prep && next.prep.serverInstanceId) PREP_INST = next.prep.serverInstanceId;
|
|
113
118
|
var changed = !C.caps || C.caps.dropIt !== next.dropIt || C.caps.jobs !== next.jobs || C.caps.thread !== next.thread;
|
|
@@ -276,8 +281,7 @@
|
|
|
276
281
|
'<div class="cc-chips hidden" data-cos-chips></div>' +
|
|
277
282
|
'<textarea class="field cc-input" id="' + esc(k.inputId) + '" rows="1" maxlength="2000" placeholder="' + esc(o.placeholder) + '" aria-label="' + esc(o.label) + '"></textarea>' +
|
|
278
283
|
'<div class="cc-buttons">' +
|
|
279
|
-
'
|
|
280
|
-
'<input type="file" class="cc-file" multiple accept="' + ACCEPT + '" data-cos-file aria-label="Attach photos or files"></label>' +
|
|
284
|
+
cameraHtml('data-cos-file') + clipHtml('data-cos-file') +
|
|
281
285
|
'<button type="button" class="cx-btn primary cc-ask" data-cos="ask">Ask</button>' +
|
|
282
286
|
(drop ? '<button type="button" class="cx-btn cc-drop" data-cos="drop">Drop</button>' : '') + '</div>' +
|
|
283
287
|
'<div class="cc-foot"><span class="cc-menus" data-cos-model-slot>' + (C.models ? modelMenuHtml() : '') + '</span>' +
|
|
@@ -549,11 +553,19 @@
|
|
|
549
553
|
var ua = (window.navigator && window.navigator.userAgent) || '';
|
|
550
554
|
return /safari/i.test(ua) && !/chrome|chromium|crios|fxios|edg|android|opr/i.test(ua);
|
|
551
555
|
}
|
|
556
|
+
// QA 6.99.27 (User 3): every browser on an iPhone or iPad is WebKit and decodes HEIC, including the Home Screen web
|
|
557
|
+
// app, whose user agent has no "Safari" in it. iPadOS asks for the desktop site by default, so it reads as a Mac
|
|
558
|
+
// with a touch screen.
|
|
559
|
+
function appleMobile() {
|
|
560
|
+
var nav = window.navigator || {}, ua = nav.userAgent || '';
|
|
561
|
+
return /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && Number(nav.maxTouchPoints) > 1);
|
|
562
|
+
}
|
|
563
|
+
function heicReadable() { return isSafari() || appleMobile(); }
|
|
552
564
|
/** What a file is to COS: 'photo', 'doc' or 'video', with a refusal sentence when it cannot be attached. */
|
|
553
565
|
function classify(file) {
|
|
554
566
|
var type = String(file.type || '').toLowerCase(), ext = extOf(file.name);
|
|
555
567
|
var heic = /^image\/hei[cf]/.test(type) || ext === 'heic' || ext === 'heif';
|
|
556
|
-
if (heic && !
|
|
568
|
+
if (heic && !heicReadable()) return { refuse: file.name + ' is a HEIC photo, which only Safari can read here. Attach it from Safari, or save it as a JPEG first.' };
|
|
557
569
|
if (/^image\//.test(type) || PHOTO_EXT[ext]) return { kind: 'photo' };
|
|
558
570
|
if (/^video\//.test(type) || VIDEO_EXT[ext]) {
|
|
559
571
|
if (!VIDEO_EXT[ext] && type !== 'video/mp4' && type !== 'video/quicktime') return { refuse: file.name + ' is a video COS cannot read. Use MP4 or MOV.' };
|
|
@@ -566,19 +578,80 @@
|
|
|
566
578
|
}
|
|
567
579
|
return { refuse: file.name + ' cannot be attached. COS takes photos, PDF, TXT, MD, CSV, JSON, XLSX, MP4 and MOV.' };
|
|
568
580
|
}
|
|
569
|
-
|
|
581
|
+
// ── the attach kit (11/07: "attach files and images from the dashboard view as well") ──
|
|
582
|
+
// The clip, the chips, the limits and the upload above, for any place that sends files with words: the chat's own
|
|
583
|
+
// composers, Today's reply and note boxes (panes/today-actions.js) and the Ask sheet (app.js). One copy, used by all.
|
|
584
|
+
// A box is { atts: [], signedOut: false, ui } (W.cosChat.attach.box): ui.changed(box) repaints after any change, ui.removed(box) runs after a
|
|
585
|
+
// Remove, ui.signedOut(box) says the browser was signed out mid-upload. Progress moves only the chip's own bar
|
|
586
|
+
// (paintProgress, by the chip's id). Nothing here sends: each caller sends the ready ids its own way, and only once
|
|
587
|
+
// pending() and failed() are both false.
|
|
588
|
+
// opts.any (a session turn): no accept list, so any file, and the label says the limits.
|
|
589
|
+
function clipHtml(fileAttr, opts) {
|
|
590
|
+
var any = !!(opts && opts.any);
|
|
591
|
+
var label = any ? 'Attach photos or files, up to 5, ' + mb(opts.any) + ' each' : 'Attach photos or files';
|
|
592
|
+
return '<label class="cx-btn cc-attach" title="' + esc(label) + '"><svg class="icon" aria-hidden="true"><use href="#i-clip"/></svg><span class="sr-only">' + esc(label) + '</span>' +
|
|
593
|
+
// No capture attribute: iOS then offers Photo Library, Take Photo and Choose File.
|
|
594
|
+
'<input type="file" class="cc-file" multiple' + (any ? '' : ' accept="' + ACCEPT + '"') + ' ' + fileAttr + ' aria-label="' + esc(label) + '"></label>';
|
|
595
|
+
}
|
|
596
|
+
// 6.99.27 (Miles: "take picture"): the camera itself, one tap. On a phone or tablet only: a desktop browser ignores
|
|
597
|
+
// `capture` and would open a second file picker labelled Take photo.
|
|
598
|
+
function cameraOn() { return !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); }
|
|
599
|
+
function cameraHtml(fileAttr) {
|
|
600
|
+
if (!cameraOn()) return '';
|
|
601
|
+
return '<label class="cx-btn cc-attach cc-camera" title="Take photo"><svg class="icon" aria-hidden="true"><use href="#i-camera"/></svg><span class="sr-only">Take photo</span>' +
|
|
602
|
+
'<input type="file" class="cc-file" accept="image/*" capture="environment" ' + fileAttr + ' aria-label="Take photo"></label>';
|
|
603
|
+
}
|
|
604
|
+
// The limits in plain words, under the chips of a session turn.
|
|
605
|
+
function limitLine(box) {
|
|
606
|
+
return box && box.anyBytes ? 'Up to 5 files, ' + mb(box.anyBytes) + ' each (videos up to ' + mb(MAX_VIDEO) + '). The session gets where each one is on your Mac.' : 'Up to 5 photos or files.';
|
|
607
|
+
}
|
|
608
|
+
// A composer here has no ui of its own (it is made before COMPOSER_UI below exists): it is the chat's.
|
|
609
|
+
function uiOf(box) { return box.ui || COMPOSER_UI; }
|
|
610
|
+
function boxChanged(box) { var ui = uiOf(box); if (ui.changed) ui.changed(box); }
|
|
611
|
+
/**
|
|
612
|
+
* 6.99.27: what a SESSION turn takes, which is more than the chat does. The agent gets each file's place on the Mac and
|
|
613
|
+
* opens it with its own tools, so any type goes, up to `maxBytes` (the Mac's threadTurnFileBytes, 64 MB) and up to
|
|
614
|
+
* 100 MB for an MP4 or MOV (the media store's video cap). Photos go as taken: kind 'photo' only for the chip.
|
|
615
|
+
* QA 6.99.27 (Skeptic W3): the 100 MB is only for a file whose type is video/mp4 or video/quicktime (or, with no type,
|
|
616
|
+
* whose name says so). Any other video (WebM, MKV) is a turn file on the Mac, whose cap is 64 MB, so it is refused
|
|
617
|
+
* here before the upload rather than after it.
|
|
618
|
+
*/
|
|
619
|
+
function classifyForSession(file, maxBytes) {
|
|
620
|
+
var type = String(file.type || '').toLowerCase(), ext = extOf(file.name);
|
|
621
|
+
var kindOf = type || VIDEO_EXT[ext] || '';
|
|
622
|
+
var video = kindOf === 'video/mp4' || kindOf === 'video/quicktime';
|
|
623
|
+
var cap = video ? MAX_VIDEO : maxBytes;
|
|
624
|
+
if (file.size > cap) return { refuse: file.name + ' is ' + mb(file.size) + '. A session takes ' + (video ? 'a video' : 'a file') + ' up to ' + mb(cap) + '.' };
|
|
625
|
+
if (!file.size) return { refuse: file.name + ' is empty.' };
|
|
626
|
+
if (/^image\//.test(type) || PHOTO_EXT[ext]) {
|
|
627
|
+
var heic = /^image\/hei[cf]/.test(type) || ext === 'heic' || ext === 'heif';
|
|
628
|
+
return { kind: 'photo', mime: type || 'application/octet-stream', showable: !heic || heicReadable() };
|
|
629
|
+
}
|
|
630
|
+
if (video) return { kind: 'video', mime: kindOf };
|
|
631
|
+
return { kind: DOC_EXT[ext] ? 'doc' : 'file', mime: type && type !== 'application/octet-stream' ? type : DOC_EXT[ext] || 'application/octet-stream' };
|
|
632
|
+
}
|
|
633
|
+
function dropThumb(t) { if (t.raw && /^blob:/.test(t.thumb || '')) { try { URL.revokeObjectURL(t.thumb); } catch (_e) {} } }
|
|
634
|
+
function objectUrl(file) { try { return URL.createObjectURL(file); } catch (_e) { return ''; } }
|
|
635
|
+
function boxAdd(box, files) {
|
|
570
636
|
var list = Array.prototype.slice.call(files || []);
|
|
571
|
-
if (!list.length) return;
|
|
572
637
|
var refusals = [];
|
|
573
638
|
list.forEach(function (file) {
|
|
574
|
-
if (
|
|
575
|
-
var c = classify(file);
|
|
639
|
+
if (box.atts.length >= MAX_ATTS) { refusals.push('Up to 5 attachments per message. ' + file.name + ' was not added.'); return; }
|
|
640
|
+
var c = box.anyBytes ? classifyForSession(file, box.anyBytes) : classify(file);
|
|
576
641
|
if (c.refuse) { refusals.push(c.refuse); return; }
|
|
577
642
|
var t = { id: uuid(), file: file, name: file.name || (c.kind === 'photo' ? 'Photo' : 'File'), size: file.size, kind: c.kind, mime: c.mime || '', state: 'reading', progress: 0, mediaId: '', thumb: '', error: '' };
|
|
578
|
-
|
|
579
|
-
|
|
643
|
+
// 6.99.27, a session turn: the file as it is, never resized or converted (the agent reads the original), and the
|
|
644
|
+
// upload says so, so the Mac keeps a format the media store does not read (lib/turn-file-store.ts).
|
|
645
|
+
if (box.anyBytes) { t.raw = true; t.purpose = 'session-turn'; if (c.kind === 'photo' && c.showable) t.thumb = objectUrl(file); }
|
|
646
|
+
box.atts.push(t);
|
|
647
|
+
startUpload(box, t);
|
|
580
648
|
});
|
|
581
|
-
|
|
649
|
+
if (list.length) box.signedOut = false;
|
|
650
|
+
return refusals;
|
|
651
|
+
}
|
|
652
|
+
function addFiles(k, files) {
|
|
653
|
+
if (!files || !files.length) return;
|
|
654
|
+
var refusals = boxAdd(k, files);
|
|
582
655
|
setNote(k, refusals.join(' '));
|
|
583
656
|
paintChips(k); paintButtons(k);
|
|
584
657
|
}
|
|
@@ -588,18 +661,21 @@
|
|
|
588
661
|
if (t.state === 'failed') return t.error || 'Did not upload';
|
|
589
662
|
return sizeLabel(t.size);
|
|
590
663
|
}
|
|
591
|
-
|
|
664
|
+
// attr: the click attribute the box's own handler reads ('data-cos' here, 'data-ta' on Today, 'data-action' in the
|
|
665
|
+
// Ask sheet), so a chip's Remove never reaches another box's handler.
|
|
666
|
+
function chipHtml(t, attr) {
|
|
667
|
+
attr = attr || 'data-cos';
|
|
592
668
|
var icon = t.thumb ? '<img src="' + esc(t.thumb) + '" alt="">' : '<svg class="icon" aria-hidden="true"><use href="#i-' + (t.kind === 'video' ? 'video' : t.kind === 'photo' ? 'photo' : 'doc') + '"/></svg>';
|
|
593
669
|
return '<div class="cc-chip ' + t.state + '" data-chip="' + t.id + '"><span class="cc-chip-thumb">' + icon + '</span>' +
|
|
594
670
|
'<span class="cc-chip-text"><span class="cc-chip-name">' + esc(t.name) + '</span><span class="cc-chip-meta" data-chip-meta>' + esc(chipMeta(t)) + '</span></span>' +
|
|
595
|
-
(t.state === 'failed' ? '<button type="button" class="link-btn cc-chip-retry"
|
|
596
|
-
'<button type="button" class="cc-chip-x"
|
|
671
|
+
(t.state === 'failed' ? '<button type="button" class="link-btn cc-chip-retry" ' + attr + '="att-retry" data-att="' + t.id + '">Retry</button>' : '') +
|
|
672
|
+
'<button type="button" class="cc-chip-x" ' + attr + '="att-remove" data-att="' + t.id + '" aria-label="Remove ' + esc(t.name) + '"><span aria-hidden="true">×</span></button>' +
|
|
597
673
|
(t.state === 'uploading' || t.state === 'reading' ? '<span class="cc-chip-bar" data-chip-bar style="width:' + Math.round(t.progress * 100) + '%"></span>' : '') + '</div>';
|
|
598
674
|
}
|
|
599
675
|
function paintChips(k) {
|
|
600
676
|
var form = formOf(k), box = form && form.querySelector('[data-cos-chips]');
|
|
601
677
|
if (!box) return;
|
|
602
|
-
box.innerHTML = k.atts.map(chipHtml).join('');
|
|
678
|
+
box.innerHTML = k.atts.map(function (t) { return chipHtml(t); }).join('');
|
|
603
679
|
box.classList.toggle('hidden', !k.atts.length);
|
|
604
680
|
}
|
|
605
681
|
// Progress moves the bar and the words only, never the chip row (a tap on Remove must not miss).
|
|
@@ -610,11 +686,60 @@
|
|
|
610
686
|
var bar = chip.querySelector('[data-chip-bar]'); if (bar) bar.style.width = Math.round(t.progress * 100) + '%';
|
|
611
687
|
}
|
|
612
688
|
function findAtt(k, id) { for (var i = 0; i < k.atts.length; i++) if (k.atts[i].id === id) return k.atts[i]; return null; }
|
|
613
|
-
function
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
689
|
+
function boxRemove(box, id) {
|
|
690
|
+
box.atts = box.atts.filter(function (t) { if (t.id === id) { if (t.xhr) { try { t.xhr.abort(); } catch (_e) {} } dropThumb(t); } return t.id !== id; });
|
|
691
|
+
var ui = uiOf(box); if (ui.removed) ui.removed(box);
|
|
692
|
+
boxChanged(box);
|
|
693
|
+
}
|
|
694
|
+
function removeAtt(k, id) { boxRemove(k, id); }
|
|
695
|
+
function boxRetry(box, id) {
|
|
696
|
+
var t = findAtt(box, id);
|
|
697
|
+
if (!t) return false;
|
|
698
|
+
box.signedOut = false;
|
|
699
|
+
startUpload(box, t);
|
|
700
|
+
boxChanged(box);
|
|
701
|
+
return true;
|
|
617
702
|
}
|
|
703
|
+
// QA 6.99.27: the files that went with a message leave the box. That is not a Remove, so ui.removed is not told (the
|
|
704
|
+
// reader's clears the status line, which may be telling the person about the message that just took them). Answers
|
|
705
|
+
// what left, enough to put each back as a kept chip (boxRestore) if the message does not go after all.
|
|
706
|
+
function boxTake(box, ids) {
|
|
707
|
+
var went = [];
|
|
708
|
+
box.atts = box.atts.filter(function (t) {
|
|
709
|
+
if (!t.mediaId || ids.indexOf(t.mediaId) < 0) return true;
|
|
710
|
+
went.push({ mediaId: t.mediaId, name: t.name, size: t.size, kind: t.kind, mime: t.mime, keptAt: t.keptAt || 0 });
|
|
711
|
+
dropThumb(t);
|
|
712
|
+
return false;
|
|
713
|
+
});
|
|
714
|
+
if (went.length) boxChanged(box);
|
|
715
|
+
return went;
|
|
716
|
+
}
|
|
717
|
+
// Files the Mac already keeps, back on the box as ready chips (no upload), at most five in all. Answers how many are
|
|
718
|
+
// on the box afterwards out of those asked for.
|
|
719
|
+
function boxRestore(box, files) {
|
|
720
|
+
var back = 0;
|
|
721
|
+
(files || []).forEach(function (f) {
|
|
722
|
+
if (!f || !f.mediaId) return;
|
|
723
|
+
if (box.atts.some(function (t) { return t.mediaId === f.mediaId; })) { back++; return; }
|
|
724
|
+
if (box.atts.length >= MAX_ATTS) return;
|
|
725
|
+
box.atts.push({ id: uuid(), file: null, name: f.name || 'File', size: f.size || 0, kind: f.kind || 'file', mime: f.mime || '', state: 'ready', progress: 1, mediaId: f.mediaId, thumb: '', error: '', keptAt: f.keptAt || 0 });
|
|
726
|
+
back++;
|
|
727
|
+
});
|
|
728
|
+
if (back) boxChanged(box);
|
|
729
|
+
return back;
|
|
730
|
+
}
|
|
731
|
+
// Everything goes, an upload still running included (its request is stopped, and its late answer finds no record).
|
|
732
|
+
function boxClear(box) {
|
|
733
|
+
box.atts.forEach(function (t) { if (t.xhr) { try { t.xhr.abort(); } catch (_e) {} } dropThumb(t); });
|
|
734
|
+
box.atts = [];
|
|
735
|
+
boxChanged(box);
|
|
736
|
+
}
|
|
737
|
+
// The chat's composers, as boxes.
|
|
738
|
+
var COMPOSER_UI = {
|
|
739
|
+
changed: function (k) { paintChips(k); paintButtons(k); },
|
|
740
|
+
removed: function (k) { if (!uploadsFailed(k) && /did not upload/i.test(k.note)) setNote(k, ''); },
|
|
741
|
+
signedOut: function (k) { setNote(k, 'This browser was signed out. Your message and files are kept. Sign in, then tap Retry on each file.'); }
|
|
742
|
+
};
|
|
618
743
|
/** A photo, drawn into a canvas at most 1024 px on its longest edge, as a JPEG at quality 0.85. */
|
|
619
744
|
function resizePhoto(file) {
|
|
620
745
|
return new Promise(function (resolve, reject) {
|
|
@@ -640,46 +765,82 @@
|
|
|
640
765
|
* with credentials 'same-origin' (the cookie goes with any same-origin XHR) plus X-COS-Web: 1, which a cookie write
|
|
641
766
|
* needs. A 401 here never opens the sign-in screen: the chip says so and the message stays (see uploadError).
|
|
642
767
|
*/
|
|
643
|
-
|
|
768
|
+
// extra: more headers (6.99.27: X-COS-Purpose for a session turn's file).
|
|
769
|
+
// QA 6.99.27 (User 8): an upload that makes no progress for a minute stops and says so, and one that stopped while
|
|
770
|
+
// the page was hidden (the phone locked, the browser in the background) says that, never that the Mac did not answer.
|
|
771
|
+
var UPLOAD_STALL_MS = 60000;
|
|
772
|
+
function uploadRequest(path, body, headers, onProgress, t, extra) {
|
|
644
773
|
return new Promise(function (resolve, reject) {
|
|
645
774
|
var xhr = new XMLHttpRequest();
|
|
646
775
|
t.xhr = xhr;
|
|
776
|
+
var stall = null, stalled = false, wasHidden = !!document.hidden;
|
|
777
|
+
var onVisibility = function () { if (document.hidden) wasHidden = true; };
|
|
778
|
+
var watch = function () { clearTimeout(stall); stall = setTimeout(function () { stalled = true; try { xhr.abort(); } catch (_e) {} }, UPLOAD_STALL_MS); };
|
|
779
|
+
var settle = function () {
|
|
780
|
+
clearTimeout(stall); stall = null; t.xhr = null;
|
|
781
|
+
if (document.removeEventListener) document.removeEventListener('visibilitychange', onVisibility);
|
|
782
|
+
};
|
|
783
|
+
// The bytes are all sent: the Mac is reading the file, which is not a stall.
|
|
784
|
+
var sent = function () { clearTimeout(stall); stall = null; };
|
|
785
|
+
var stopped = function () {
|
|
786
|
+
var err = wasHidden || document.hidden ? new Error('The upload stopped while the phone was locked. Retry it.') : new Error('The upload stopped. Nothing moved for a minute. Retry it.');
|
|
787
|
+
err.code = wasHidden || document.hidden ? 'upload_locked' : 'upload_stalled';
|
|
788
|
+
return err;
|
|
789
|
+
};
|
|
790
|
+
if (document.addEventListener) document.addEventListener('visibilitychange', onVisibility);
|
|
647
791
|
xhr.open('POST', path, true);
|
|
648
792
|
xhr.setRequestHeader('X-COS-Web', '1');
|
|
649
793
|
Object.keys(headers).forEach(function (h) { xhr.setRequestHeader(h, headers[h]); });
|
|
650
|
-
if (
|
|
794
|
+
if (extra) Object.keys(extra).forEach(function (h) { xhr.setRequestHeader(h, extra[h]); });
|
|
795
|
+
if (xhr.upload) {
|
|
796
|
+
xhr.upload.onprogress = function (e) {
|
|
797
|
+
if (e.lengthComputable && e.total) { onProgress(e.loaded / e.total); if (e.loaded >= e.total) { sent(); return; } }
|
|
798
|
+
watch();
|
|
799
|
+
};
|
|
800
|
+
xhr.upload.onload = sent;
|
|
801
|
+
}
|
|
651
802
|
xhr.onload = function () {
|
|
652
|
-
|
|
803
|
+
settle();
|
|
653
804
|
var data = {}; try { data = JSON.parse(xhr.responseText || '{}'); } catch (_e) { data = {}; }
|
|
654
805
|
if (xhr.status >= 200 && xhr.status < 300) return resolve(data);
|
|
655
806
|
var err = new Error('The Mac answered ' + xhr.status + '.'); err.status = xhr.status;
|
|
656
807
|
err.code = data && (typeof data.error === 'string' ? data.error : data.error && data.error.code) || '';
|
|
657
808
|
reject(err);
|
|
658
809
|
};
|
|
659
|
-
xhr.onerror = function () {
|
|
660
|
-
|
|
810
|
+
xhr.onerror = function () {
|
|
811
|
+
settle();
|
|
812
|
+
if (wasHidden || document.hidden) return reject(stopped());
|
|
813
|
+
var err = new Error('The Mac did not answer.'); err.code = 'network'; reject(err);
|
|
814
|
+
};
|
|
815
|
+
xhr.onabort = function () {
|
|
816
|
+
settle();
|
|
817
|
+
if (stalled) return reject(stopped());
|
|
818
|
+
var err = new Error('Removed.'); err.code = 'aborted'; reject(err);
|
|
819
|
+
};
|
|
661
820
|
xhr.send(body);
|
|
821
|
+
watch();
|
|
662
822
|
});
|
|
663
823
|
}
|
|
664
824
|
var UPLOAD_COPY = {
|
|
665
825
|
batch_too_large: 'Too large for one upload.', too_many_images: 'Up to 5 attachments per message.',
|
|
666
826
|
media_processing_unavailable: 'The Mac cannot read photos right now.', unsupported_kind: 'COS cannot read this kind of file.',
|
|
667
827
|
unsupported_media_type: 'COS cannot read this kind of file.', file_too_large: 'Too large for COS.', attachment_too_large: 'Too large for COS.',
|
|
668
|
-
server_maintenance: 'The Mac is updating. Retry in a minute.', network: 'The Mac did not answer. Retry when it is awake.'
|
|
828
|
+
server_maintenance: 'The Mac is updating. Retry in a minute.', network: 'The Mac did not answer. Retry when it is awake.',
|
|
829
|
+
upload_locked: 'The upload stopped while the phone was locked. Retry it.', upload_stalled: 'The upload stopped. Nothing moved for a minute. Retry it.'
|
|
669
830
|
};
|
|
670
|
-
function uploadError(
|
|
831
|
+
function uploadError(box, t, err) {
|
|
671
832
|
if (err && err.code === 'aborted') return;
|
|
672
833
|
t.state = 'failed';
|
|
673
834
|
if (err && err.status === 401) {
|
|
674
835
|
// Signed out mid-message (the browser session expired or every browser was signed out). Nothing is thrown away:
|
|
675
836
|
// the words and the files stay, and Sign in again opens the sign-in screen on purpose, not by surprise.
|
|
676
837
|
t.error = 'Signed out';
|
|
677
|
-
|
|
678
|
-
|
|
838
|
+
box.signedOut = true;
|
|
839
|
+
var ui = uiOf(box); if (ui.signedOut) ui.signedOut(box);
|
|
679
840
|
} else {
|
|
680
841
|
t.error = uploadErrorText(err);
|
|
681
842
|
}
|
|
682
|
-
|
|
843
|
+
boxChanged(box);
|
|
683
844
|
}
|
|
684
845
|
/**
|
|
685
846
|
* One file to the media store, the way every web upload goes: a photo resized to a 1024 px JPEG and posted to /api/media,
|
|
@@ -689,7 +850,7 @@
|
|
|
689
850
|
*/
|
|
690
851
|
function uploadMedia(t, hooks) {
|
|
691
852
|
var at = new Date(t.file.lastModified || Date.now()).toISOString();
|
|
692
|
-
if (t.kind === 'photo') {
|
|
853
|
+
if (t.kind === 'photo' && !t.raw) {
|
|
693
854
|
return resizePhoto(t.file).then(function (dataUrl) {
|
|
694
855
|
var data = dataUrl.slice(dataUrl.indexOf(',') + 1);
|
|
695
856
|
var bytes = Math.floor(data.length * 3 / 4);
|
|
@@ -704,7 +865,7 @@
|
|
|
704
865
|
'Content-Type': t.mime || 'application/octet-stream',
|
|
705
866
|
'X-COS-Filename': encodeURIComponent(t.name),
|
|
706
867
|
'X-COS-Captured-At': at
|
|
707
|
-
}, function (p) { hooks.progress(p); }, t).then(function (d) { return d && d.attachment; });
|
|
868
|
+
}, function (p) { hooks.progress(p); }, t, t.purpose ? { 'X-COS-Purpose': t.purpose } : null).then(function (d) { return d && d.attachment; });
|
|
708
869
|
}
|
|
709
870
|
/** The chip's words for a failed upload (never the sign-in case, which each caller says its own way). */
|
|
710
871
|
function uploadErrorText(err) {
|
|
@@ -712,22 +873,22 @@
|
|
|
712
873
|
if (err && !err.status && !err.code) return err.message || 'Did not upload';
|
|
713
874
|
return UPLOAD_COPY[err && err.code] || (err && err.status === 413 ? 'Too large for COS.' : 'Did not upload');
|
|
714
875
|
}
|
|
715
|
-
function startUpload(
|
|
716
|
-
t.state = 'reading'; t.progress = 0; t.error = '';
|
|
876
|
+
function startUpload(box, t) {
|
|
877
|
+
t.state = 'reading'; t.progress = 0; t.error = ''; t.mediaId = '';
|
|
717
878
|
var step = uploadMedia(t, {
|
|
718
|
-
uploading: function () { t.state = 'uploading';
|
|
719
|
-
progress: function (p) { t.progress = p; paintProgress(
|
|
879
|
+
uploading: function () { t.state = 'uploading'; boxChanged(box); },
|
|
880
|
+
progress: function (p) { t.progress = p; paintProgress(box, t); }
|
|
720
881
|
});
|
|
721
882
|
step.then(function (ref) {
|
|
722
|
-
if (!findAtt(
|
|
883
|
+
if (!findAtt(box, t.id)) return;
|
|
723
884
|
if (!ref || !ref.id) { var e = new Error('The Mac did not keep the file.'); e.code = ''; throw e; }
|
|
724
|
-
t.mediaId = ref.id; t.state = 'ready'; t.progress = 1;
|
|
725
|
-
|
|
885
|
+
t.mediaId = ref.id; t.state = 'ready'; t.progress = 1; t.keptAt = Date.now();
|
|
886
|
+
boxChanged(box);
|
|
726
887
|
}).catch(function (err) {
|
|
727
|
-
if (!findAtt(
|
|
728
|
-
if (err && err.code === 'photo_too_large') { t.state = 'failed'; t.error = uploadErrorText(err);
|
|
729
|
-
if (err && !err.status && !err.code) { t.state = 'failed'; t.error = uploadErrorText(err);
|
|
730
|
-
uploadError(
|
|
888
|
+
if (!findAtt(box, t.id)) return;
|
|
889
|
+
if (err && err.code === 'photo_too_large') { t.state = 'failed'; t.error = uploadErrorText(err); boxChanged(box); return; }
|
|
890
|
+
if (err && !err.status && !err.code) { t.state = 'failed'; t.error = uploadErrorText(err); boxChanged(box); return; }
|
|
891
|
+
uploadError(box, t, err);
|
|
731
892
|
});
|
|
732
893
|
}
|
|
733
894
|
function readyAtts(k) { return k.atts.filter(function (t) { return t.state === 'ready' && t.mediaId; }); }
|
|
@@ -945,8 +1106,13 @@
|
|
|
945
1106
|
return ensureCaps().then(function (caps) {
|
|
946
1107
|
if (!caps || !caps.jobs) { var off = new Error('Ask needs the durable query jobs switch on your Mac.'); off.code = 'durable_query_jobs_disabled'; throw off; }
|
|
947
1108
|
return new Promise(function (resolve, reject) {
|
|
1109
|
+
// 11/07: files attached in the Ask sheet go with the question, as from the chat's own clip (ready ids only:
|
|
1110
|
+
// the sheet never asks while one is uploading or failed).
|
|
1111
|
+
var o = {};
|
|
1112
|
+
if (extra && extra.context) o.context = extra.context;
|
|
1113
|
+
if (extra && Array.isArray(extra.attachmentIds) && extra.attachmentIds.length) { o.attachmentIds = extra.attachmentIds.slice(0, MAX_ATTS); o.atts = Array.isArray(extra.atts) ? extra.atts.slice(0, MAX_ATTS) : []; }
|
|
948
1114
|
submitAsk(String(text || '').trim(), null, function (ok) { if (ok) resolve(['cos']); else reject(new Error('Not sent. The question is in Ask COS; try again there.')); },
|
|
949
|
-
|
|
1115
|
+
o.context || o.attachmentIds ? o : undefined);
|
|
950
1116
|
});
|
|
951
1117
|
});
|
|
952
1118
|
}
|
|
@@ -1647,8 +1813,8 @@
|
|
|
1647
1813
|
if (what === 'cancel') return cancel(t.getAttribute('data-cid'));
|
|
1648
1814
|
if (what === 'att-remove') return removeAtt(kOf(t), t.getAttribute('data-att'));
|
|
1649
1815
|
if (what === 'att-retry') {
|
|
1650
|
-
var k = kOf(t)
|
|
1651
|
-
if (att) {
|
|
1816
|
+
var k = kOf(t);
|
|
1817
|
+
if (findAtt(k, t.getAttribute('data-att'))) { setNote(k, ''); boxRetry(k, t.getAttribute('data-att')); }
|
|
1652
1818
|
return;
|
|
1653
1819
|
}
|
|
1654
1820
|
if (what === 'sign-in') { if (W.showAuth) W.showAuth('Sign in again. Your message and files are kept; tap Retry on each file after.'); return; }
|
|
@@ -1730,6 +1896,34 @@
|
|
|
1730
1896
|
setOnChange: function (fn) { C.onChange = fn; }, setOnAnswered: function (fn) { C.onAnswered = fn; },
|
|
1731
1897
|
// Test and render hooks: what the chat holds right now.
|
|
1732
1898
|
classify: classify, uploadMedia: uploadMedia, uploadErrorText: uploadErrorText, uuid: uuid,
|
|
1899
|
+
// 11/07: the attach kit, for Today's reply and note boxes and the Ask sheet (see "the attach kit" above).
|
|
1900
|
+
attach: {
|
|
1901
|
+
max: MAX_ATTS, accept: ACCEPT,
|
|
1902
|
+
// opts.anyBytes (a session turn): any file up to that size, sent as it is (classifyForSession).
|
|
1903
|
+
box: function (ui, opts) { return { atts: [], signedOut: false, ui: ui || {}, anyBytes: opts && opts.anyBytes > 0 ? opts.anyBytes : 0 }; },
|
|
1904
|
+
add: boxAdd, remove: boxRemove, retry: boxRetry, clear: boxClear,
|
|
1905
|
+
take: boxTake, restore: boxRestore,
|
|
1906
|
+
pending: uploadsPending, failed: uploadsFailed,
|
|
1907
|
+
ids: function (box) { return readyAtts(box).slice(0, MAX_ATTS).map(function (t) { return t.mediaId; }); },
|
|
1908
|
+
sent: function (box) { return readyAtts(box).slice(0, MAX_ATTS).map(function (t) { return { name: t.name, kind: t.kind, thumb: t.thumb }; }); },
|
|
1909
|
+
chipsHtml: function (box, attr) { return box.atts.map(function (t) { return chipHtml(t, attr); }).join(''); },
|
|
1910
|
+
clipHtml: clipHtml, cameraHtml: cameraHtml, limitLine: limitLine,
|
|
1911
|
+
// Files on the clipboard (a pasted screenshot), or none.
|
|
1912
|
+
pasted: function (e) { var f = e && e.clipboardData && e.clipboardData.files; return f && f.length ? f : null; },
|
|
1913
|
+
// Files dragged over `zone`: marks it while they hover, and answers the files on the drop. Null for anything else.
|
|
1914
|
+
dragged: function (e, zone) {
|
|
1915
|
+
var types = e && e.dataTransfer && e.dataTransfer.types;
|
|
1916
|
+
if (!types || Array.prototype.indexOf.call(types, 'Files') < 0) return null;
|
|
1917
|
+
e.preventDefault();
|
|
1918
|
+
if (e.type === 'dragover' || e.type === 'dragenter') { zone.classList.add('dragging'); return null; }
|
|
1919
|
+
zone.classList.remove('dragging');
|
|
1920
|
+
return e.type === 'drop' ? e.dataTransfer.files : null;
|
|
1921
|
+
},
|
|
1922
|
+
// How many files a session turn takes on this Mac (threadTurnAttachments); 0 means words only.
|
|
1923
|
+
turnFiles: function () { return C.caps && C.caps.turnFiles > 0 ? C.caps.turnFiles : 0; },
|
|
1924
|
+
turnFileBytes: function () { return C.caps && C.caps.turnFiles > 0 && C.caps.turnFileBytes > 0 ? C.caps.turnFileBytes : 0; },
|
|
1925
|
+
askOn: askOn, ensureCaps: ensureCaps
|
|
1926
|
+
},
|
|
1733
1927
|
// 6.99.19: Prep. meetings.js reads the capability from here (one /api/health read for the page).
|
|
1734
1928
|
prepCaps: prepCaps,
|
|
1735
1929
|
// Today's Next up (app.js prepNext): Prep me for that meeting, as the chat's own Prep me does. With Prep on the Mac
|
|
@@ -2352,7 +2352,13 @@
|
|
|
2352
2352
|
function prepCapsNow() { var cc = W.cosChat; return cc && typeof cc.prepCaps === 'function' ? cc.prepCaps() : null; }
|
|
2353
2353
|
function livePrep(sid) { if (!S.lprep || S.lprep.sid !== sid) S.lprep = { sid: sid, asked: false, state: 'idle', drafts: [], busy: '', done: null, note: '', changed: '' }; return S.lprep; }
|
|
2354
2354
|
function notePrepInstance(b) { var cc = W.cosChat; if (b && cc && typeof cc.notePrepInstance === 'function') cc.notePrepInstance(b.serverInstanceId); }
|
|
2355
|
-
function prepBoundTo(d, sid) { return ((d && d.bindings) || []).some(function (b) { return b && b.sessionId === sid && !b.removedAt; }); }
|
|
2355
|
+
function prepBoundTo(d, sid) { return ((d && d.bindings) || []).some(function (b) { return b && b.sessionId === sid && !b.removedAt && b.state !== 'applying'; }); }
|
|
2356
|
+
// An interrupted bind owns frozen rows at its original revision. A later editable draft is not its retry.
|
|
2357
|
+
function prepPendingBind(d, sid) {
|
|
2358
|
+
return ((d && d.bindings) || []).filter(function (b) {
|
|
2359
|
+
return b && b.sessionId === sid && !b.removedAt && b.state === 'applying' && Number.isSafeInteger(b.revision) && b.revision >= 0;
|
|
2360
|
+
})[0] || null;
|
|
2361
|
+
}
|
|
2356
2362
|
function prepReadyFiles(d) { return ((d && d.files) || []).filter(function (x) { return x && x.status === 'ready'; }); }
|
|
2357
2363
|
function prepHasNotes(d) { return !!String((d && d.steer) || '').trim(); }
|
|
2358
2364
|
// Whether this live view may offer a prep at all: the area is reserved exactly when this is true.
|
|
@@ -2376,7 +2382,7 @@
|
|
|
2376
2382
|
if (S.lprep !== p) return;
|
|
2377
2383
|
notePrepInstance(r.body);
|
|
2378
2384
|
p.drafts = r.status === 200 && Array.isArray(r.body.drafts) ? r.body.drafts.filter(function (d) {
|
|
2379
|
-
return d && typeof d.id === 'string' && !prepBoundTo(d, f.sid) && (prepHasNotes(d) || prepReadyFiles(d).length > 0);
|
|
2385
|
+
return d && typeof d.id === 'string' && !prepBoundTo(d, f.sid) && (prepPendingBind(d, f.sid) || prepHasNotes(d) || prepReadyFiles(d).length > 0);
|
|
2380
2386
|
}).slice(0, 5) : [];
|
|
2381
2387
|
p.state = r.status === 200 ? 'ready' : 'failed';
|
|
2382
2388
|
paintLivePrep();
|
|
@@ -2388,7 +2394,7 @@
|
|
|
2388
2394
|
return prepHasNotes(d) ? (n ? 'its notes and ' + files : 'its notes') : 'its ' + files;
|
|
2389
2395
|
}
|
|
2390
2396
|
function prepAddBtn(p, d) {
|
|
2391
|
-
return '<button type="button" class="cx-btn quiet" data-mt="prep-bind" data-id="' + esc(d.id) + '"' + (p.busy ? ' disabled' : '') + '>' + (p.busy === d.id ? 'Adding…' : 'Add to this meeting') + '</button>';
|
|
2397
|
+
return '<button type="button" class="cx-btn quiet" data-mt="prep-bind" data-id="' + esc(d.id) + '"' + (p.busy ? ' disabled' : '') + '>' + (p.busy === d.id ? 'Adding…' : prepPendingBind(d, p.sid) ? 'Retry add prep' : 'Add to this meeting') + '</button>';
|
|
2392
2398
|
}
|
|
2393
2399
|
function prepQuiet(text, warn) { return '<div class="mt-prepline" role="status"><span class="mt-prepline-text' + (warn ? ' mt-amber' : '') + '">' + esc(text) + '</span></div>'; }
|
|
2394
2400
|
function livePrepHtml() {
|
|
@@ -2402,14 +2408,14 @@
|
|
|
2402
2408
|
else if (!p.drafts.length) html = prepQuiet('No prep for this meeting. Make one from a Prep me card in COS chat.');
|
|
2403
2409
|
else if (p.drafts.length === 1) {
|
|
2404
2410
|
var d = p.drafts[0], title = cleanTitle(d.title) || 'this meeting';
|
|
2405
|
-
var text = p.note || 'Prep for ' + title + (p.changed === d.id ? ' changed since it was offered: add ' : ' is ready: add ') + prepWhat(d) + ' to this meeting';
|
|
2411
|
+
var text = p.note || (prepPendingBind(d, p.sid) ? 'Finish adding the saved prep for ' + title + '. Later edits are not included.' : 'Prep for ' + title + (p.changed === d.id ? ' changed since it was offered: add ' : ' is ready: add ') + prepWhat(d) + ' to this meeting');
|
|
2406
2412
|
html = '<div class="mt-prepline"><span class="mt-prepline-text' + (p.note ? ' mt-amber' : '') + '" title="' + esc(text) + '">' + esc(text) + '</span>' + prepAddBtn(p, d) + '</div>';
|
|
2407
2413
|
} else {
|
|
2408
2414
|
// Several: the person picks. Each row says what it adds; the list scrolls inside the same box.
|
|
2409
2415
|
html = '<div class="mt-prepline list"><ul class="mt-preplist" aria-label="' + esc(p.drafts.length + ' preps are ready for this meeting') + '">' +
|
|
2410
2416
|
(p.note ? '<li class="mt-prepnote"><span class="mt-prepline-text mt-amber">' + esc(p.note) + '</span></li>' : '') +
|
|
2411
2417
|
p.drafts.map(function (d) {
|
|
2412
|
-
var label = (cleanTitle(d.title) || 'Prep') + (p.changed === d.id ? ' (changed)' : '') + ': ' + prepWhat(d).replace(/^its /, '');
|
|
2418
|
+
var label = (cleanTitle(d.title) || 'Prep') + (prepPendingBind(d, p.sid) ? ': finish adding the saved prep. Later edits are not included.' : (p.changed === d.id ? ' (changed)' : '') + ': ' + prepWhat(d).replace(/^its /, ''));
|
|
2413
2419
|
return '<li><span class="mt-prepline-text" title="' + esc(label) + '">' + esc(label) + '</span>' + prepAddBtn(p, d) + '</li>';
|
|
2414
2420
|
}).join('') + '</ul></div>';
|
|
2415
2421
|
}
|
|
@@ -2432,16 +2438,21 @@
|
|
|
2432
2438
|
// v1.1: the bind names the revision the offer described ({revision, sessionId, serverInstanceId, surface}), so a newer
|
|
2433
2439
|
// version nobody has seen is never attached. The server id is the newest one read: capabilities.prep or the most
|
|
2434
2440
|
// recent Prep answer (W.cosChat.prepInstance), else Meeting Ask's, which is the same id.
|
|
2435
|
-
function bindOnce(sid, d) {
|
|
2441
|
+
function bindOnce(sid, d, retry) {
|
|
2436
2442
|
var cc = W.cosChat, known = cc && typeof cc.prepInstance === 'function' ? cc.prepInstance() : '';
|
|
2437
2443
|
return (known ? Promise.resolve(known) : loadConfig().then(function (conf) { return conf && conf.serverInstanceId; })).then(function (inst) {
|
|
2438
2444
|
if (!inst) throw new Error(S.conf.notice || 'Meeting assistance is not available on this server.');
|
|
2439
2445
|
// Amendment 19: the header is what authorizes the write; the body carries the same id.
|
|
2440
|
-
return answer('/api/prep/drafts/' + encodeURIComponent(d.id) + '/bind', { method: 'POST', headers: { 'X-COS-Expected-Server-Instance': inst }, body: { revision: d.revision, sessionId: sid, serverInstanceId: inst, surface: 'web' } });
|
|
2446
|
+
return answer('/api/prep/drafts/' + encodeURIComponent(d.id) + '/bind', { method: 'POST', headers: { 'X-COS-Expected-Server-Instance': inst }, body: { revision: retry ? retry.revision : d.revision, sessionId: sid, serverInstanceId: inst, surface: 'web' } });
|
|
2441
2447
|
}).then(function (a) {
|
|
2442
2448
|
var b = a.body || {}, code = typeof b.error === 'string' ? b.error : (b.error && b.error.code) || b.reason || '';
|
|
2443
2449
|
if (a.status >= 200 && a.status < 300 || a.status === 409) notePrepInstance(b);
|
|
2444
|
-
if (a.status >= 200 && a.status < 300)
|
|
2450
|
+
if (a.status >= 200 && a.status < 300) {
|
|
2451
|
+
if (retry && !((b.draft && b.draft.bindings) || []).some(function (bound) {
|
|
2452
|
+
return bound && bound.sessionId === sid && bound.revision === retry.revision && bound.state === 'applied' && !bound.removedAt;
|
|
2453
|
+
})) throw new Error('The server has not confirmed adding this prep. Retry add prep.');
|
|
2454
|
+
return b;
|
|
2455
|
+
}
|
|
2445
2456
|
// A different server than the one that offered it: the prep is read again (amendment 19) and the line says so.
|
|
2446
2457
|
// Adding it takes another tap, against the server that answers now.
|
|
2447
2458
|
if (a.status === 409 && code === 'server_instance_mismatch') {
|
|
@@ -2469,7 +2480,7 @@
|
|
|
2469
2480
|
if (!f || !p || p.sid !== f.sid || p.busy || p.done || f.endedAt) return;
|
|
2470
2481
|
var d = p.drafts.filter(function (x) { return x.id === id; })[0]; if (!d) return;
|
|
2471
2482
|
p.busy = id; p.note = ''; paintLivePrep();
|
|
2472
|
-
bindOnce(f.sid, d).then(function (b) {
|
|
2483
|
+
bindOnce(f.sid, d, prepPendingBind(d, f.sid)).then(function (b) {
|
|
2473
2484
|
p.busy = ''; p.done = (b && b.draft) || d;
|
|
2474
2485
|
var a = A(f.sid);
|
|
2475
2486
|
// The meeting's files as the bind answered them, when it answered Meeting Ask files; and read again either way.
|