@leftoff/cli 0.9.8-beta.10 → 0.9.8-beta.11
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 +5 -0
- package/bin/leftoff.js +327 -324
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +148 -1
- package/ui/app.js +387 -13
- package/ui/attach.js +211 -0
- package/ui/composer.js +46 -0
- package/ui/lightbox.js +452 -0
- package/ui/live-view.js +16 -3
- package/ui/pdfview.js +510 -0
- package/ui/vendor/PDFJS-LICENSE.txt +179 -0
- package/ui/vendor/pdfjs/pdf.min.js +27 -0
- package/ui/vendor/pdfjs/pdf.worker.min.js +27 -0
package/ui/app.js
CHANGED
|
@@ -12,6 +12,10 @@ import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, to
|
|
|
12
12
|
import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
|
|
13
13
|
import { canStartPull, pullStep, pullRelease } from './pull.js'
|
|
14
14
|
import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
|
|
15
|
+
import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
|
|
16
|
+
import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
|
|
17
|
+
import { isBlank, composerKey, isTouchUi, composerHeight } from './composer.js' // 0.9.8 the Live composer
|
|
18
|
+
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
15
19
|
import { wireTips } from './tips.js'
|
|
16
20
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
17
21
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
@@ -1319,11 +1323,34 @@ function sessionShellHtml(s) {
|
|
|
1319
1323
|
'<div class="s-skel" role="status" aria-live="polite" aria-busy="true"><div class="s-skel-note"><span class="lv-spin" aria-hidden="true"></span>Opening…</div>' +
|
|
1320
1324
|
'<div class="skel-line"></div><div class="skel-line short"></div><div class="skel-block"></div><div class="skel-line"></div><div class="skel-line short"></div></div>'
|
|
1321
1325
|
}
|
|
1326
|
+
// A selector that finds the same control again after a redraw (null: it can't be told apart).
|
|
1327
|
+
function focusKey(el) {
|
|
1328
|
+
if (!el || !el.tagName) return null
|
|
1329
|
+
if (el.id) return '#' + CSS.escape(el.id)
|
|
1330
|
+
const d = el.dataset || {}
|
|
1331
|
+
const keys = ['action', 'lid', 'sid', 'att', 'id', 'tab', 'submitFor'].filter((k) => d[k] != null)
|
|
1332
|
+
if (!keys.length) return null
|
|
1333
|
+
const attr = (k) => '[data-' + k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + '="' + CSS.escape(d[k]) + '"]'
|
|
1334
|
+
return el.tagName.toLowerCase() + keys.map(attr).join('')
|
|
1335
|
+
}
|
|
1322
1336
|
function paintSession(s, data) {
|
|
1323
1337
|
if (data.live && !data.live.error) liveUi.sig[s.id] = liveSignature(data.live.messages)
|
|
1338
|
+
// a redraw (the live poll, a settled reply) keeps the composer focused with its caret where it was
|
|
1339
|
+
const fa = document.activeElement
|
|
1340
|
+
const kf = fa && fa.classList && fa.classList.contains('composer-input') && $app.contains(fa) ? { d: fa.dataset.draft, a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
1341
|
+
// …and any other control that had focus (a chip, a thumbnail, a button: a
|
|
1342
|
+
// preview closed with Esc puts focus back on one, then a redraw replaced it)
|
|
1343
|
+
const kb = !kf && fa && fa !== document.body && $app.contains(fa) ? focusKey(fa) : null
|
|
1324
1344
|
liveKeeper.before($app)
|
|
1325
1345
|
$app.innerHTML = sessionPageHtml(s, data)
|
|
1326
1346
|
liveAfterRender(s.id)
|
|
1347
|
+
if (kf) {
|
|
1348
|
+
const nt = $app.querySelector('.composer-input')
|
|
1349
|
+
if (nt && nt.dataset.draft === kf.d && !nt.disabled) { nt.focus({ preventScroll: true }); try { const n = nt.value.length; nt.setSelectionRange(Math.min(kf.a, n), Math.min(kf.b, n)) } catch {} }
|
|
1350
|
+
} else if (kb && !$app.contains(document.activeElement)) {
|
|
1351
|
+
const hits = $app.querySelectorAll(kb)
|
|
1352
|
+
if (hits.length === 1 && !hits[0].disabled) hits[0].focus({ preventScroll: true })
|
|
1353
|
+
}
|
|
1327
1354
|
aroundWire(s.id)
|
|
1328
1355
|
}
|
|
1329
1356
|
async function viewSession(id) {
|
|
@@ -1461,17 +1488,343 @@ function liveBoxHtml(s, v) {
|
|
|
1461
1488
|
if (lastClaude) markLiveSeen(s.id, lastClaude.at)
|
|
1462
1489
|
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
|
|
1463
1490
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
1464
|
-
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now }) + '</ol>' +
|
|
1491
|
+
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
|
|
1465
1492
|
'<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
|
|
1466
1493
|
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
|
|
1467
1494
|
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
1468
|
-
|
|
1469
|
-
'<button type="button" class="btn primary" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '"' + (may ? '' : ' aria-disabled="true"') + '>Send</button></div>' +
|
|
1495
|
+
composerHtml(s, draft, may) +
|
|
1470
1496
|
(may ? '' : '<p class="faint">Sending from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.</p>') + '</section>'
|
|
1471
1497
|
}
|
|
1498
|
+
// The Live composer: one rounded box, a textarea that starts at one line and
|
|
1499
|
+
// grows, and a round ↑ in its corner (dim until there's something to send).
|
|
1500
|
+
// No interrupt reaches Claude Code from the board, so while it works the
|
|
1501
|
+
// button still sends: the message waits for its next turn, as before.
|
|
1502
|
+
const SEND_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
|
|
1503
|
+
const CLIP_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
|
|
1504
|
+
const FILE_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>'
|
|
1505
|
+
function composerHtml(s, draft, may) {
|
|
1506
|
+
const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
|
|
1507
|
+
const atts = attachList(s.id)
|
|
1508
|
+
const off = !may || (isBlank(draft) && !atts.length)
|
|
1509
|
+
return '<div class="composer live-row' + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '" data-sid="' + esc(s.id) + '">' +
|
|
1510
|
+
'<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
|
|
1511
|
+
'<textarea class="composer-input live-text" rows="1" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" autocomplete="off" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd) + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
|
|
1512
|
+
// the phone's own picker (Photo Library, Take Photo, Choose File): any file
|
|
1513
|
+
'<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
|
|
1514
|
+
'<button type="button" class="composer-attach" data-action="attach" data-id="' + esc(s.id) + '" aria-label="Attach" title="Attach photos or files"' + (may ? '' : ' disabled') + '><span class="composer-clip">' + CLIP_IC + '</span></button>' +
|
|
1515
|
+
'<button type="button" class="composer-send" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '" aria-label="Send" title="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
|
|
1519
|
+
// Each file uploads to the Mac that runs the session as soon as you attach it;
|
|
1520
|
+
// the chips live here (not in the DOM), so a redraw of the page keeps them.
|
|
1521
|
+
const attachUi = { by: new Map(), urls: new Map(), blobs: new Map(), loading: new Map(), seq: 0, running: 0, queue: [] }
|
|
1522
|
+
const attachList = (sid) => attachUi.by.get(sid) || []
|
|
1523
|
+
function chipHtml(a) {
|
|
1524
|
+
const pct = a.total ? Math.min(100, Math.round((a.sent / a.total) * 100)) : 0
|
|
1525
|
+
const status = a.status === 'failed' ? 'Didn’t upload' : a.status === 'done' ? fmtSize(a.size) : a.status === 'prep' ? 'Preparing…' : fmtSize(a.size) + ' · ' + pct + '%'
|
|
1526
|
+
return '<div class="att-chip ' + esc(a.status) + '" data-lid="' + esc(a.lid) + '"' + (a.error ? ' title="' + esc(a.error) + '"' : '') + '>' +
|
|
1527
|
+
'<button type="button" class="att-open" data-action="att-chip-open" data-lid="' + esc(a.lid) + '" aria-label="Preview ' + esc(a.name) + '">' +
|
|
1528
|
+
'<span class="att-thumb">' + (a.preview ? '<img src="' + esc(a.preview) + '" alt="">' : FILE_IC) + '</span>' +
|
|
1529
|
+
'<span class="att-meta"><span class="att-name" title="' + esc(a.name) + '">' + esc(shortName(a.name)) + '</span><span class="att-size">' + esc(status) + '</span></span></button>' +
|
|
1530
|
+
(a.status === 'failed' ? '<button type="button" class="att-btn att-retry" data-action="att-retry" data-lid="' + esc(a.lid) + '" aria-label="Retry ' + esc(a.name) + '" title="Retry">↻</button>' : '') +
|
|
1531
|
+
'<button type="button" class="att-btn att-x" data-action="att-remove" data-lid="' + esc(a.lid) + '" aria-label="Remove ' + esc(a.name) + '" title="Remove">×</button>' +
|
|
1532
|
+
(a.status === 'up' || a.status === 'prep' ? '<span class="att-bar" aria-hidden="true"><span data-pct="' + pct + '"></span></span>' : '') + '</div>'
|
|
1533
|
+
}
|
|
1534
|
+
// The board's CSP takes no inline styles: a progress bar's width is set here.
|
|
1535
|
+
function barWidths(root) { for (const b of root.querySelectorAll('.att-bar > span[data-pct]')) b.style.width = b.dataset.pct + '%' }
|
|
1536
|
+
// Redraw one session's chips in place (and the ↑'s state).
|
|
1537
|
+
function paintChips(sid) {
|
|
1538
|
+
const box = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"]')
|
|
1539
|
+
if (!box) return
|
|
1540
|
+
const atts = attachList(sid)
|
|
1541
|
+
const wrap = box.querySelector('.att-chips')
|
|
1542
|
+
const fa = wrap.contains(document.activeElement) ? focusKey(document.activeElement) : null // a progress tick keeps the chip you're on focused
|
|
1543
|
+
wrap.innerHTML = atts.map(chipHtml).join('')
|
|
1544
|
+
if (fa) { const t = wrap.querySelector(fa); if (t) t.focus({ preventScroll: true }) }
|
|
1545
|
+
barWidths(wrap)
|
|
1546
|
+
wrap.hidden = !atts.length
|
|
1547
|
+
box.classList.toggle('has-atts', !!atts.length)
|
|
1548
|
+
const ta = box.querySelector('.composer-input')
|
|
1549
|
+
if (ta) syncComposer(ta)
|
|
1550
|
+
}
|
|
1551
|
+
function addFiles(sid, files, { pasted = false } = {}) {
|
|
1552
|
+
const list = Array.from(files || []).filter(Boolean)
|
|
1553
|
+
if (!sid || !list.length) return
|
|
1554
|
+
const have = attachList(sid)
|
|
1555
|
+
const plan = attachPlan(list, have.length)
|
|
1556
|
+
if (plan.skipped.length) toast('Not attached: ' + skippedText(plan.skipped), 4200)
|
|
1557
|
+
const add = plan.take.map((f) => {
|
|
1558
|
+
const file = pasted && /^image\.\w+$|^$/.test(f.name || '') ? new File([f], pastedName(f.type), { type: f.type, lastModified: Date.now() }) : f
|
|
1559
|
+
return { lid: 'a' + (++attachUi.seq), sid, file, name: file.name || 'file', size: file.size, type: file.type || '', kind: attachKind(file), status: 'prep', sent: 0, total: file.size, id: null, preview: null, thumb: null, error: null }
|
|
1560
|
+
})
|
|
1561
|
+
if (!add.length) return
|
|
1562
|
+
attachUi.by.set(sid, [...have, ...add])
|
|
1563
|
+
paintChips(sid)
|
|
1564
|
+
try { promoteActive() } catch {}
|
|
1565
|
+
for (const a of add) startUpload(a)
|
|
1566
|
+
}
|
|
1567
|
+
function startUpload(a) {
|
|
1568
|
+
a.status = 'prep'; a.error = null; a.sent = 0
|
|
1569
|
+
attachUi.queue.push(a); pumpUploads()
|
|
1570
|
+
}
|
|
1571
|
+
// Three at a time: a phone on a slow line shouldn't open ten uploads at once.
|
|
1572
|
+
function pumpUploads() {
|
|
1573
|
+
while (attachUi.running < 3 && attachUi.queue.length) {
|
|
1574
|
+
const a = attachUi.queue.shift()
|
|
1575
|
+
if (!attachList(a.sid).includes(a)) continue
|
|
1576
|
+
attachUi.running++
|
|
1577
|
+
runUpload(a).finally(() => { attachUi.running--; pumpUploads() })
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
async function runUpload(a) {
|
|
1581
|
+
const gone = () => !attachList(a.sid).includes(a)
|
|
1582
|
+
try {
|
|
1583
|
+
if (!a.prepared) {
|
|
1584
|
+
const p = await prepareFile(a.file)
|
|
1585
|
+
a.prepared = p.file; a.thumb = p.thumb
|
|
1586
|
+
a.name = p.file.name || a.name; a.size = p.file.size; a.total = p.file.size; a.type = p.file.type || a.type
|
|
1587
|
+
if (attachKind(p.file) === 'image' && !a.preview && p.thumb) a.preview = 'data:image/jpeg;base64,' + p.thumb
|
|
1588
|
+
// a PDF's first page, for its chip and its bubble (a few seconds at most; without it, the file icon)
|
|
1589
|
+
if (attachKind(p.file) === 'pdf' && !a.thumb) {
|
|
1590
|
+
const t = await Promise.race([pdfThumb(p.file), new Promise((r) => setTimeout(() => r(null), 5000))]).catch(() => null)
|
|
1591
|
+
if (t) { a.thumb = t; a.preview = 'data:image/jpeg;base64,' + t }
|
|
1592
|
+
}
|
|
1593
|
+
if (p.note === 'heic') a.error = 'This browser can’t convert HEIC; it goes as is'
|
|
1594
|
+
}
|
|
1595
|
+
if (gone()) return
|
|
1596
|
+
a.status = 'up'; paintChips(a.sid)
|
|
1597
|
+
let last = 0
|
|
1598
|
+
const f = await uploadFile({ api, sessionId: a.sid, file: a.prepared, thumb: a.thumb, isCancelled: gone,
|
|
1599
|
+
onProgress: (sent, total) => { a.sent = sent; a.total = total; const t = Date.now(); if (t - last > 120 || sent >= total) { last = t; paintChips(a.sid) } } })
|
|
1600
|
+
a.id = f.id; a.size = f.size; a.kind = f.kind; a.status = 'done'
|
|
1601
|
+
if (a.preview) attachUi.urls.set(a.sid + '/' + f.id, a.preview) // the bubble shows it without asking the Mac
|
|
1602
|
+
} catch (e) {
|
|
1603
|
+
if (e.code === 'removed' || gone()) return
|
|
1604
|
+
a.status = 'failed'; a.error = e.message || 'Upload failed'
|
|
1605
|
+
}
|
|
1606
|
+
paintChips(a.sid)
|
|
1607
|
+
attachWaiters()
|
|
1608
|
+
}
|
|
1609
|
+
let attachWaitList = []
|
|
1610
|
+
function attachWaiters() { const w = attachWaitList; attachWaitList = []; for (const f of w) f() }
|
|
1611
|
+
// Wait until nothing of this session's is still uploading.
|
|
1612
|
+
async function uploadsSettled(sid) {
|
|
1613
|
+
while (attachList(sid).some((a) => a.status === 'prep' || a.status === 'up')) await new Promise((r) => { attachWaitList.push(r); setTimeout(r, 1000) })
|
|
1614
|
+
}
|
|
1615
|
+
function removeAttachment(lid) {
|
|
1616
|
+
for (const [sid, list] of attachUi.by) {
|
|
1617
|
+
const a = list.find((x) => x.lid === lid)
|
|
1618
|
+
if (!a) continue
|
|
1619
|
+
attachUi.by.set(sid, list.filter((x) => x !== a))
|
|
1620
|
+
paintChips(sid)
|
|
1621
|
+
const ta = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"] .composer-input')
|
|
1622
|
+
if (ta && !isTouchUi(window.matchMedia)) ta.focus({ preventScroll: true })
|
|
1623
|
+
return
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
function retryAttachment(lid) {
|
|
1627
|
+
for (const list of attachUi.by.values()) { const a = list.find((x) => x.lid === lid); if (a) { startUpload(a); paintChips(a.sid); return } }
|
|
1628
|
+
}
|
|
1629
|
+
// The composer a drop or a paste is for: the session page's own (enabled).
|
|
1630
|
+
function composerFor(el) {
|
|
1631
|
+
const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
|
|
1632
|
+
return box && !box.classList.contains('off') ? box : null
|
|
1633
|
+
}
|
|
1634
|
+
document.addEventListener('change', (ev) => {
|
|
1635
|
+
const inp = ev.target
|
|
1636
|
+
if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
|
|
1637
|
+
addFiles(inp.dataset.sid, inp.files)
|
|
1638
|
+
inp.value = '' // the same file can be picked again
|
|
1639
|
+
})
|
|
1640
|
+
document.addEventListener('paste', (ev) => {
|
|
1641
|
+
const t = ev.target
|
|
1642
|
+
const inComposer = t && t.classList && t.classList.contains('composer-input')
|
|
1643
|
+
if (!inComposer && t && t.closest && t.closest('input, textarea, [contenteditable]')) return
|
|
1644
|
+
const cd = ev.clipboardData
|
|
1645
|
+
const files = cd ? Array.from(cd.files || []).length ? Array.from(cd.files) : Array.from(cd.items || []).filter((i) => i.kind === 'file').map((i) => i.getAsFile()).filter(Boolean) : []
|
|
1646
|
+
if (!files.length) return
|
|
1647
|
+
const box = composerFor(inComposer ? t : null)
|
|
1648
|
+
if (!box) return
|
|
1649
|
+
ev.preventDefault()
|
|
1650
|
+
addFiles(box.dataset.sid, files, { pasted: true })
|
|
1651
|
+
})
|
|
1652
|
+
{
|
|
1653
|
+
// Drag files onto the session page: the box lights up; drop attaches.
|
|
1654
|
+
const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
|
|
1655
|
+
let leaveT = 0
|
|
1656
|
+
const lit = (on) => { const b = document.querySelector('#app .composer[data-sid]'); if (b) b.classList.toggle('att-drop', on) }
|
|
1657
|
+
document.addEventListener('dragover', (e) => { if (!hasFiles(e) || !composerFor(null)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; lit(true); clearTimeout(leaveT); leaveT = setTimeout(() => lit(false), 300) })
|
|
1658
|
+
document.addEventListener('drop', (e) => {
|
|
1659
|
+
if (!hasFiles(e)) return
|
|
1660
|
+
const box = composerFor(null)
|
|
1661
|
+
e.preventDefault() // never let a stray drop navigate away from the board
|
|
1662
|
+
lit(false)
|
|
1663
|
+
if (box) addFiles(box.dataset.sid, e.dataTransfer.files)
|
|
1664
|
+
})
|
|
1665
|
+
}
|
|
1666
|
+
// A sent message's attachments in its bubble: thumbnails (tap for full size) or file chips.
|
|
1667
|
+
function attBubbleHtml(atts, sid) {
|
|
1668
|
+
return '<div class="lv-atts">' + atts.map((a) => {
|
|
1669
|
+
const s0 = a.sessionId || sid, key = s0 + '/' + a.id
|
|
1670
|
+
const label = esc(a.name) + ' · ' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '')
|
|
1671
|
+
const data = ' data-action="att-open" data-sid="' + esc(s0) + '" data-att="' + esc(a.id) + '" data-name="' + esc(a.name) + '" data-kind="' + esc(a.kind) + '" data-thumb="' + (a.thumb === false ? '0' : '1') + '" data-size="' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '') + '"'
|
|
1672
|
+
if (a.kind === 'image') { const u = attachUi.urls.get(key); return '<button type="button" class="lv-att lv-att-img"' + data + ' aria-label="Open ' + esc(a.name) + '" title="' + label + '"><img alt="' + esc(a.name) + '" data-att-thumb="' + esc(key) + '"' + (u ? ' src="' + esc(u) + '"' : '') + '></button>' }
|
|
1673
|
+
const firstPage = a.kind === 'pdf' && a.thumb !== false ? '<img alt="" data-att-thumb="' + esc(key) + '"' + (attachUi.urls.get(key) ? ' src="' + esc(attachUi.urls.get(key)) + '"' : '') + '>' : '' // its first page, when there is one
|
|
1674
|
+
return '<button type="button" class="lv-att lv-att-file"' + data + ' aria-label="Open ' + esc(a.name) + '" title="' + label + '"><span class="att-thumb' + (firstPage ? ' att-thumb-pdf' : '') + '">' + FILE_IC + firstPage + '</span><span class="att-meta"><span class="att-name">' + esc(a.name) + '</span><span class="att-size">' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '') + '</span></span></button>'
|
|
1675
|
+
}).join('') + '</div>'
|
|
1676
|
+
}
|
|
1677
|
+
// Thumbnails come from the Mac once, then from memory. data: URLs, because the
|
|
1678
|
+
// board's CSP allows images from itself and data: only (no blob:).
|
|
1679
|
+
const blobToDataUrl = (b) => new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = () => reject(r.error || new Error('unreadable')); r.readAsDataURL(b) })
|
|
1680
|
+
function attData(sid, id, { thumb = false } = {}) {
|
|
1681
|
+
const key = sid + '/' + id + (thumb ? '' : '#full')
|
|
1682
|
+
if (attachUi.urls.has(key)) return Promise.resolve({ url: attachUi.urls.get(key), blob: attachUi.blobs.get(key) || null })
|
|
1683
|
+
if (attachUi.loading.has(key)) return attachUi.loading.get(key)
|
|
1684
|
+
const p = fetchAttachment({ api, sessionId: sid, id, thumb }).then(async (b) => { const url = await blobToDataUrl(b); attachUi.urls.set(key, url); attachUi.blobs.set(key, b); return { url, blob: b } }).finally(() => attachUi.loading.delete(key))
|
|
1685
|
+
attachUi.loading.set(key, p)
|
|
1686
|
+
return p
|
|
1687
|
+
}
|
|
1688
|
+
function hydrateAttThumbs() {
|
|
1689
|
+
for (const img of document.querySelectorAll('#app img[data-att-thumb]:not([src])')) {
|
|
1690
|
+
const key = img.dataset.attThumb
|
|
1691
|
+
if (attachUi.urls.has(key)) { img.src = attachUi.urls.get(key); continue }
|
|
1692
|
+
const btn = img.closest('.lv-att'); if (!btn || btn.dataset.loading) continue
|
|
1693
|
+
btn.dataset.loading = '1'
|
|
1694
|
+
const thumb = btn.dataset.thumb !== '0'
|
|
1695
|
+
attData(btn.dataset.sid, btn.dataset.att, { thumb }).then(({ url }) => { attachUi.urls.set(key, url); for (const i of document.querySelectorAll('#app img[data-att-thumb="' + CSS.escape(key) + '"]')) i.src = url })
|
|
1696
|
+
.catch((e) => {
|
|
1697
|
+
// a PDF sent without a first-page picture (before 0.9.8, or it couldn't be drawn) keeps its icon
|
|
1698
|
+
if (btn.dataset.kind === 'pdf' && e && e.code === 'no_thumb') { for (const i of btn.querySelectorAll('img[data-att-thumb]')) i.remove(); return }
|
|
1699
|
+
btn.classList.add('gone'); btn.title = 'Not on that Mac any more'
|
|
1700
|
+
})
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
// An image opens full screen (ui/lightbox.js) with the other pictures of the
|
|
1704
|
+
// same message beside it; the thumbnail shows (blurred) while the full one
|
|
1705
|
+
// comes from the Mac. Text and other files keep the docs viewer.
|
|
1706
|
+
const attSel = (sid, id) => '.lv-att[data-sid="' + CSS.escape(sid) + '"][data-att="' + CSS.escape(id) + '"]'
|
|
1707
|
+
function openImageAttachment(btn) {
|
|
1708
|
+
const group = btn.closest('.lv-atts')
|
|
1709
|
+
const btns = group ? [...group.querySelectorAll('.lv-att[data-kind="image"]')] : [btn]
|
|
1710
|
+
const items = btns.map((b) => {
|
|
1711
|
+
const im = b.querySelector('img'), sid = b.dataset.sid, id = b.dataset.att
|
|
1712
|
+
const full = attachUi.urls.get(sid + '/' + id + '#full')
|
|
1713
|
+
return { name: b.dataset.name || 'image', sizeLabel: b.dataset.size || '', thumb: (im && im.getAttribute('src')) || null, url: full || null,
|
|
1714
|
+
w: im && im.naturalWidth, h: im && im.naturalHeight, load: () => attData(sid, id).then((r) => r.url) }
|
|
1715
|
+
})
|
|
1716
|
+
const sid = btn.dataset.sid, id = btn.dataset.att
|
|
1717
|
+
openLightbox({ items, index: Math.max(0, btns.indexOf(btn)), from: btn.querySelector('img') || btn, refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
|
|
1718
|
+
}
|
|
1719
|
+
// A chip in the box before it's sent: the picture from the file you picked
|
|
1720
|
+
// (a data: URL; the CSP takes no blob:), the other pictures in the box beside it.
|
|
1721
|
+
// Any other file: its name and size in the docs viewer (nothing to download yet).
|
|
1722
|
+
function openChip(lid) {
|
|
1723
|
+
let a = null
|
|
1724
|
+
for (const list of attachUi.by.values()) { a = list.find((x) => x.lid === lid); if (a) break }
|
|
1725
|
+
if (!a) return
|
|
1726
|
+
const chip = document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
|
|
1727
|
+
const isImg = (x) => x.kind === 'image' && (x.preview || /^image\/(png|jpeg|gif|webp|bmp|svg\+xml|avif)$/i.test((x.prepared || x.file || {}).type || ''))
|
|
1728
|
+
const refocus = () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
|
|
1729
|
+
if (a.kind === 'pdf') {
|
|
1730
|
+
const f = a.prepared || a.file
|
|
1731
|
+
openPdfView({ name: a.name, sizeLabel: fmtSize(a.size), download: false, from: chip, refocus, load: async () => ({ bytes: new Uint8Array(await f.arrayBuffer()) }) })
|
|
1732
|
+
return
|
|
1733
|
+
}
|
|
1734
|
+
if (isImg(a)) {
|
|
1735
|
+
const list = attachList(a.sid).filter(isImg)
|
|
1736
|
+
const items = list.map((x) => ({ name: x.name, sizeLabel: fmtSize(x.size), thumb: x.preview || null, download: false,
|
|
1737
|
+
load: () => { if (!x.fullUrl) x.fullUrl = blobToDataUrl(x.prepared || x.file); return x.fullUrl } }))
|
|
1738
|
+
openLightbox({ items, index: Math.max(0, list.indexOf(a)), from: (chip && chip.querySelector('img')) || chip, refocus: () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]') })
|
|
1739
|
+
return
|
|
1740
|
+
}
|
|
1741
|
+
const el = docViewerEl()
|
|
1742
|
+
el.querySelector('.dv-title').textContent = a.name
|
|
1743
|
+
el.querySelector('.dv-sub').textContent = ''
|
|
1744
|
+
el.querySelector('.dv-dl').hidden = true
|
|
1745
|
+
docUi.r = null; docUi.name = a.name
|
|
1746
|
+
el.querySelector('.dv-body').innerHTML = '<div class="empty att-chip-info"><b>' + esc(a.name) + '</b><br>' + esc(fmtSize(a.size)) + (a.status === 'done' ? ' · goes with your next message' : a.status === 'failed' ? ' · didn’t upload' : ' · uploading') + '</div>'
|
|
1747
|
+
el.hidden = false
|
|
1748
|
+
document.documentElement.classList.add('dv-open')
|
|
1749
|
+
el.querySelector('.dv-x') && el.querySelector('.dv-x').focus({ preventScroll: true })
|
|
1750
|
+
}
|
|
1751
|
+
// A PDF opens in its own full-screen preview (ui/pdfview.js): the pages drawn
|
|
1752
|
+
// from the file the Mac sends back (the same chunked read as a picture).
|
|
1753
|
+
function openPdfAttachment(btn) {
|
|
1754
|
+
const sid = btn.dataset.sid, id = btn.dataset.att
|
|
1755
|
+
openPdfView({ name: btn.dataset.name || 'file.pdf', sizeLabel: btn.dataset.size || '', from: btn,
|
|
1756
|
+
load: async () => { const r = await attData(sid, id); return { bytes: new Uint8Array(await r.blob.arrayBuffer()), url: r.url } },
|
|
1757
|
+
refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
|
|
1758
|
+
}
|
|
1759
|
+
async function openAttachment(btn) {
|
|
1760
|
+
const sid = btn.dataset.sid, id = btn.dataset.att, name = btn.dataset.name || 'file', kind = btn.dataset.kind
|
|
1761
|
+
if (kind === 'image') return openImageAttachment(btn)
|
|
1762
|
+
if (kind === 'pdf') return openPdfAttachment(btn)
|
|
1763
|
+
const el = docViewerEl()
|
|
1764
|
+
el.querySelector('.dv-title').textContent = name
|
|
1765
|
+
el.querySelector('.dv-sub').textContent = ''
|
|
1766
|
+
el.querySelector('.dv-dl').hidden = true
|
|
1767
|
+
const body = el.querySelector('.dv-body')
|
|
1768
|
+
body.innerHTML = '<div class="empty">Opening…</div>'
|
|
1769
|
+
docUi.r = null; docUi.name = name
|
|
1770
|
+
el.hidden = false
|
|
1771
|
+
document.documentElement.classList.add('dv-open')
|
|
1772
|
+
try {
|
|
1773
|
+
const { url: u, blob } = await attData(sid, id)
|
|
1774
|
+
if (el.hidden) return
|
|
1775
|
+
const dl = '<a class="btn" href="' + esc(u) + '" download="' + esc(name) + '">' + icon('download') + 'Download</a>'
|
|
1776
|
+
if (kind === 'image') body.innerHTML = '<div class="att-full"><img src="' + esc(u) + '" alt="' + esc(name) + '"></div><div class="att-full-actions">' + dl + '</div>'
|
|
1777
|
+
else if (kind === 'text') { const t = blob ? await blob.text() : ''; body.innerHTML = '<pre class="att-text">' + esc(t.slice(0, 200000)) + '</pre><div class="att-full-actions">' + dl + '</div>' }
|
|
1778
|
+
else body.innerHTML = '<div class="empty">' + esc(name) + '</div><div class="att-full-actions">' + dl + '</div>'
|
|
1779
|
+
} catch (e) { if (!el.hidden) body.innerHTML = '<div class="empty">Couldn’t open it: ' + esc(e.message) + '</div>' }
|
|
1780
|
+
}
|
|
1781
|
+
// Fit the box to its words (one line up to COMPOSER_MAX_LINES, then it scrolls) and light the ↑ when there's something to send.
|
|
1782
|
+
function fitComposer(ta) {
|
|
1783
|
+
if (!ta || !ta.isConnected) return
|
|
1784
|
+
const cs = getComputedStyle(ta)
|
|
1785
|
+
const lh = parseFloat(cs.lineHeight) || 22
|
|
1786
|
+
const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)
|
|
1787
|
+
const keep = ta.scrollTop
|
|
1788
|
+
ta.style.height = 'auto'
|
|
1789
|
+
const r = composerHeight({ scrollHeight: ta.scrollHeight, lineHeight: lh, padY })
|
|
1790
|
+
ta.style.height = r.height + 'px'
|
|
1791
|
+
ta.style.overflowY = r.scroll ? 'auto' : 'hidden'
|
|
1792
|
+
if (r.scroll) ta.scrollTop = keep
|
|
1793
|
+
syncComposer(ta)
|
|
1794
|
+
}
|
|
1795
|
+
function syncComposer(ta) {
|
|
1796
|
+
const box = ta && ta.closest('.composer')
|
|
1797
|
+
const btn = box && box.querySelector('.composer-send')
|
|
1798
|
+
if (!btn || btn.classList.contains('sending')) return
|
|
1799
|
+
btn.disabled = ta.disabled || (isBlank(ta.value) && !attachList(box.dataset.sid).length)
|
|
1800
|
+
}
|
|
1801
|
+
document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
|
|
1802
|
+
// Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
|
|
1803
|
+
document.addEventListener('keydown', (ev) => {
|
|
1804
|
+
const ta = ev.target
|
|
1805
|
+
if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
|
|
1806
|
+
if (ev.metaKey || ev.ctrlKey) return // ⌘↵ / Ctrl+↵: the Needs-you handler presses the button
|
|
1807
|
+
if (composerKey(ev, { touch: isTouchUi(window.matchMedia) }) !== 'send') return
|
|
1808
|
+
ev.preventDefault()
|
|
1809
|
+
const cb = ta.closest('.composer')
|
|
1810
|
+
if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
|
|
1811
|
+
const box = ta.closest('.live-box')
|
|
1812
|
+
const btn = box && box.querySelector('[data-submit-for="live"]')
|
|
1813
|
+
if (!btn || btn.disabled) return
|
|
1814
|
+
const last = Number(box.dataset.kbdAt || 0)
|
|
1815
|
+
if (Date.now() - last < 1500) return
|
|
1816
|
+
box.dataset.kbdAt = String(Date.now())
|
|
1817
|
+
btn.click()
|
|
1818
|
+
})
|
|
1819
|
+
// A tap anywhere on the box (its padding) is a tap in the textarea.
|
|
1820
|
+
document.addEventListener('click', (ev) => { const b = ev.target && ev.target.classList && ev.target.classList.contains('composer') ? ev.target : null; const ta = b && b.querySelector('.composer-input'); if (ta && !ta.disabled) ta.focus() })
|
|
1821
|
+
// Pressing ↑ keeps the keyboard up (the textarea keeps focus) on a phone.
|
|
1822
|
+
document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.composer-send') && document.activeElement && document.activeElement.classList.contains('composer-input')) ev.preventDefault() })
|
|
1472
1823
|
// After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
|
|
1473
1824
|
function liveAfterRender(sid) {
|
|
1825
|
+
for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
|
|
1474
1826
|
const r = liveKeeper.after($app)
|
|
1827
|
+
hydrateAttThumbs(); barWidths($app)
|
|
1475
1828
|
if (!r) return
|
|
1476
1829
|
const ol = r.thread.querySelector('.live-log')
|
|
1477
1830
|
const empty = r.thread.querySelector('.live-empty')
|
|
@@ -1599,6 +1952,7 @@ async function refreshLive(reason) {
|
|
|
1599
1952
|
liveKeeper.before(box)
|
|
1600
1953
|
box.insertBefore(fresh, next)
|
|
1601
1954
|
const r = liveKeeper.after(box)
|
|
1955
|
+
hydrateAttThumbs()
|
|
1602
1956
|
if (r) liveJumpButton(r.thread, liveKeeper.unseen)
|
|
1603
1957
|
}
|
|
1604
1958
|
}
|
|
@@ -1645,19 +1999,34 @@ document.addEventListener('visibilitychange', onWake)
|
|
|
1645
1999
|
async function liveSendClick(btn) {
|
|
1646
2000
|
const box = btn.closest('.live-box')
|
|
1647
2001
|
const ta = box && box.querySelector('.live-text')
|
|
2002
|
+
const sid = btn.dataset.id
|
|
1648
2003
|
const text = ta ? ta.value.trim() : ''
|
|
1649
|
-
if (!text
|
|
1650
|
-
if (btn.
|
|
1651
|
-
|
|
2004
|
+
if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
|
|
2005
|
+
if (btn.disabled || btn.classList.contains('sending')) return
|
|
2006
|
+
const hadFocus = !!(ta && document.activeElement === ta)
|
|
2007
|
+
btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
|
|
1652
2008
|
try {
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
2009
|
+
// 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
|
|
2010
|
+
await uploadsSettled(sid)
|
|
2011
|
+
const atts = attachList(sid)
|
|
2012
|
+
const failed = atts.filter((a) => a.status === 'failed').length
|
|
2013
|
+
if (failed) { toast(failed === 1 ? 'A file didn’t upload: retry it or remove it.' : failed + ' files didn’t upload: retry or remove them.', 4200); return }
|
|
2014
|
+
const ids = atts.filter((a) => a.status === 'done' && a.id).map((a) => a.id)
|
|
2015
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live', { text, ...(ids.length ? { attachments: ids } : {}) })
|
|
2016
|
+
// what you typed while it went stays; what was sent goes
|
|
2017
|
+
if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
|
|
2018
|
+
if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
|
|
1656
2019
|
if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
|
|
1657
2020
|
const ol = box && box.querySelector('.live-log')
|
|
1658
2021
|
if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
|
|
1659
2022
|
setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
|
|
1660
|
-
} catch (e) { toast(e.message, 4200) } finally {
|
|
2023
|
+
} catch (e) { toast(e.message, 4200) } finally {
|
|
2024
|
+
await render()
|
|
2025
|
+
const nb = document.querySelector('.live-box [data-action="live-send"]')
|
|
2026
|
+
if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
|
|
2027
|
+
const nt = document.querySelector('.live-box .composer-input')
|
|
2028
|
+
if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
|
|
2029
|
+
}
|
|
1661
2030
|
}
|
|
1662
2031
|
|
|
1663
2032
|
// ------------------------------------------------------------ search inside transcripts (0.9.6)
|
|
@@ -3970,7 +4339,7 @@ document.addEventListener('click', (e) => { if (e.target.closest && $app.contain
|
|
|
3970
4339
|
sw = null
|
|
3971
4340
|
if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
|
|
3972
4341
|
const t = e.target
|
|
3973
|
-
if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .tabs, .moremenu') || scrollsSideways(t)) return
|
|
4342
|
+
if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .moremenu') || scrollsSideways(t)) return
|
|
3974
4343
|
sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
|
|
3975
4344
|
}, { passive: true })
|
|
3976
4345
|
document.addEventListener('touchend', (e) => {
|
|
@@ -4035,7 +4404,7 @@ function keepPlace() {
|
|
|
4035
4404
|
ptr.innerHTML = '<span class="ptr-ring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></span>'
|
|
4036
4405
|
document.body.appendChild(ptr)
|
|
4037
4406
|
const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
|
|
4038
|
-
const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
|
|
4407
|
+
const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden]), .emoji-sheet-wrap')
|
|
4039
4408
|
let pull = null, refreshing = false
|
|
4040
4409
|
const paint = (step) => {
|
|
4041
4410
|
ptr.hidden = !step || (!step.pulling && !refreshing)
|
|
@@ -4097,7 +4466,7 @@ function keepPlace() {
|
|
|
4097
4466
|
const top = document.querySelector('.top')
|
|
4098
4467
|
let st = { hidden: false, anchor: 0 }, raf = 0, held = true
|
|
4099
4468
|
const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
|
|
4100
|
-
const overlay = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .lo-backdrop, .doc-viewer:not([hidden]), .emoji-sheet-wrap, .relay-screen:not([hidden])')
|
|
4469
|
+
const overlay = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .lo-backdrop, .doc-viewer:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden]), .emoji-sheet-wrap, .relay-screen:not([hidden])')
|
|
4101
4470
|
const apply = (hidden) => {
|
|
4102
4471
|
if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
|
|
4103
4472
|
document.body.classList.toggle('nav-hidden', hidden)
|
|
@@ -4786,6 +5155,11 @@ document.addEventListener('click', async (ev) => {
|
|
|
4786
5155
|
if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
|
|
4787
5156
|
if (action === 'reply-send') return replyClick(btn)
|
|
4788
5157
|
if (action === 'live-send') return liveSendClick(btn)
|
|
5158
|
+
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
|
|
5159
|
+
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
5160
|
+
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
|
5161
|
+
if (action === 'att-open') return openAttachment(btn)
|
|
5162
|
+
if (action === 'att-chip-open') return openChip(btn.dataset.lid)
|
|
4789
5163
|
if (action === 'live-jump') return liveJumpClick(btn)
|
|
4790
5164
|
if (action === 'live-earlier') return liveEarlierClick(btn)
|
|
4791
5165
|
if (action === 'quick-reply') return quickReplyClick(btn)
|