@oar-sh/oar 0.9.8 → 0.9.9
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 +231 -0
- package/README.md +53 -32
- package/bin/oar.js +814 -228
- package/package.json +4 -5
- package/server/README.md +20 -1
- package/server/db-schema.mjs +8 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/public/app/api-client.js +30 -0
- package/server/public/app/attachments-view.js +508 -125
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/bootstrap.js +46 -2
- package/server/public/app/conversation-view.js +110 -25
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/journal-view.js +16 -2
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/router.js +8 -2
- package/server/public/app/socket-handlers.js +6 -0
- package/server/public/app/store.js +6 -0
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +43 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +304 -54
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +5 -0
- package/server/routes/messages-routes.mjs +33 -61
- package/server/routes/sessions-routes.mjs +92 -24
- package/server/server-runtime.mjs +29 -0
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-history-refresh-service.mjs +24 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Telling a tap from everything else that happens on the viewer's content.
|
|
2
|
+
//
|
|
3
|
+
// A tap is one pointer that goes down and comes up in place, quickly. A drag
|
|
4
|
+
// (panning a zoomed image, selecting text), a pinch (a second pointer) and a
|
|
5
|
+
// long press are not taps. Neither is the first half of a double tap: the
|
|
6
|
+
// tap is reported only once the double-tap window has passed without a
|
|
7
|
+
// second pointer going down, so a double tap zooms without first flipping
|
|
8
|
+
// the controls. Taps on things that act by themselves (links, buttons, a
|
|
9
|
+
// player's own controls) are left to them.
|
|
10
|
+
|
|
11
|
+
export const TAP_MAX_MOVE_PX = 10;
|
|
12
|
+
export const TAP_MAX_DURATION_MS = 500;
|
|
13
|
+
export const DOUBLE_TAP_WINDOW_MS = 300;
|
|
14
|
+
|
|
15
|
+
const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, summary, video, audio, [contenteditable], [data-no-viewer-tap]';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pure tap recognizer fed with pointer events of the shape
|
|
19
|
+
* `{ type, pointerId, clientX, clientY, timeStamp, target }`. Calls `onTap`
|
|
20
|
+
* with the final pointer position, `isInteractive(target)` says whether a
|
|
21
|
+
* target handles taps itself (defaults to the usual controls), and
|
|
22
|
+
* `hasSelection()` lets a text selection that ended under the pointer count
|
|
23
|
+
* as a drag rather than a tap.
|
|
24
|
+
*/
|
|
25
|
+
export function createTapRecognizer({
|
|
26
|
+
onTap,
|
|
27
|
+
isInteractive = defaultIsInteractive,
|
|
28
|
+
hasSelection = () => false,
|
|
29
|
+
setTimeoutImpl = setTimeout,
|
|
30
|
+
clearTimeoutImpl = clearTimeout,
|
|
31
|
+
} = {}) {
|
|
32
|
+
const state = {
|
|
33
|
+
active: null, // the pointer being watched: { id, x, y, at }
|
|
34
|
+
pointers: 0, // pointers currently down
|
|
35
|
+
pending: null, // timer holding a tap until the double-tap window passes
|
|
36
|
+
cancelled: false, // a second pointer joined or the pointer moved too far
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const dropPending = () => {
|
|
40
|
+
if (state.pending === null) return;
|
|
41
|
+
clearTimeoutImpl(state.pending);
|
|
42
|
+
state.pending = null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
handle(event) {
|
|
47
|
+
const type = String(event?.type || '');
|
|
48
|
+
if (type === 'pointerdown') {
|
|
49
|
+
state.pointers += 1;
|
|
50
|
+
// A second pointer down while a tap waits to be reported: the start
|
|
51
|
+
// of a double tap (or a pinch); either way not a single tap.
|
|
52
|
+
if (state.pending !== null) {
|
|
53
|
+
dropPending();
|
|
54
|
+
state.cancelled = true;
|
|
55
|
+
state.active = null;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (state.pointers > 1) {
|
|
59
|
+
state.cancelled = true;
|
|
60
|
+
state.active = null;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (isInteractive(event.target)) {
|
|
64
|
+
state.active = null;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
state.cancelled = false;
|
|
68
|
+
state.active = { id: event.pointerId, x: event.clientX, y: event.clientY, at: event.timeStamp };
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (type === 'pointermove') {
|
|
72
|
+
if (!state.active || event.pointerId !== state.active.id) return;
|
|
73
|
+
if (Math.hypot(event.clientX - state.active.x, event.clientY - state.active.y) > TAP_MAX_MOVE_PX) {
|
|
74
|
+
state.cancelled = true;
|
|
75
|
+
state.active = null;
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (type === 'pointerup' || type === 'pointercancel') {
|
|
80
|
+
state.pointers = Math.max(0, state.pointers - 1);
|
|
81
|
+
const active = state.active;
|
|
82
|
+
if (!active || event.pointerId !== active.id) {
|
|
83
|
+
if (state.pointers === 0) state.cancelled = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
state.active = null;
|
|
87
|
+
if (type === 'pointercancel' || state.cancelled) { state.cancelled = false; return; }
|
|
88
|
+
if (event.timeStamp - active.at > TAP_MAX_DURATION_MS) return;
|
|
89
|
+
if (Math.hypot(event.clientX - active.x, event.clientY - active.y) > TAP_MAX_MOVE_PX) return;
|
|
90
|
+
if (hasSelection()) return;
|
|
91
|
+
const point = { clientX: event.clientX, clientY: event.clientY };
|
|
92
|
+
dropPending();
|
|
93
|
+
state.pending = setTimeoutImpl(() => {
|
|
94
|
+
state.pending = null;
|
|
95
|
+
onTap?.(point);
|
|
96
|
+
}, DOUBLE_TAP_WINDOW_MS);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
/** Forget a tap in flight, e.g. when the content is replaced. */
|
|
100
|
+
cancel() {
|
|
101
|
+
dropPending();
|
|
102
|
+
state.active = null;
|
|
103
|
+
state.cancelled = false;
|
|
104
|
+
state.pointers = 0;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function defaultIsInteractive(target) {
|
|
110
|
+
if (!target || typeof target.closest !== 'function') return false;
|
|
111
|
+
return Boolean(target.closest(INTERACTIVE_SELECTOR));
|
|
112
|
+
}
|
package/server/public/index.html
CHANGED
|
@@ -572,6 +572,21 @@
|
|
|
572
572
|
opacity: 0.5;
|
|
573
573
|
cursor: not-allowed;
|
|
574
574
|
}
|
|
575
|
+
/* An icon with a number beside it (📍 3) grows with the number. */
|
|
576
|
+
.header-icon-btn.has-count {
|
|
577
|
+
width: auto;
|
|
578
|
+
min-width: 32px;
|
|
579
|
+
padding: 0 7px;
|
|
580
|
+
gap: 4px;
|
|
581
|
+
}
|
|
582
|
+
.header-icon-count {
|
|
583
|
+
font-size: var(--fs-meta-sm);
|
|
584
|
+
font-variant-numeric: tabular-nums;
|
|
585
|
+
}
|
|
586
|
+
.header-icon-count:empty { display: none; }
|
|
587
|
+
/* The display rule above outranks the UA [hidden] rule, same trap as the
|
|
588
|
+
other [hidden] overrides in this file. */
|
|
589
|
+
#pinned-messages-btn[hidden] { display: none; }
|
|
575
590
|
#cli-dot {
|
|
576
591
|
width: 12px;
|
|
577
592
|
height: 12px;
|
|
@@ -1771,8 +1786,13 @@
|
|
|
1771
1786
|
opacity: 0;
|
|
1772
1787
|
pointer-events: none;
|
|
1773
1788
|
}
|
|
1789
|
+
/* `.msg-actions-revealed`: a tap on the bubble on a touch screen. The tap
|
|
1790
|
+
also leaves an emulated :hover behind, but Chromium drops that hover at
|
|
1791
|
+
the next scroll or layout change (seen on Linux), which left the row
|
|
1792
|
+
untappable; the class holds the reveal until a tap lands elsewhere. */
|
|
1774
1793
|
.msg-bubble:hover .msg-share-visibility-btn[data-hidden-from-shares="false"],
|
|
1775
|
-
.msg-bubble:focus-within .msg-share-visibility-btn[data-hidden-from-shares="false"]
|
|
1794
|
+
.msg-bubble:focus-within .msg-share-visibility-btn[data-hidden-from-shares="false"],
|
|
1795
|
+
.msg-bubble.msg-actions-revealed .msg-share-visibility-btn[data-hidden-from-shares="false"] {
|
|
1776
1796
|
opacity: 1;
|
|
1777
1797
|
pointer-events: auto;
|
|
1778
1798
|
}
|
|
@@ -1780,7 +1800,46 @@
|
|
|
1780
1800
|
.msg.user .msg-share-visibility-btn { color: var(--user-bubble-fg); opacity: 0.8; }
|
|
1781
1801
|
.msg.user .msg-share-visibility-btn[data-hidden-from-shares="false"] { opacity: 0; }
|
|
1782
1802
|
.msg.user .msg-bubble:hover .msg-share-visibility-btn[data-hidden-from-shares="false"],
|
|
1783
|
-
.msg.user .msg-bubble:focus-within .msg-share-visibility-btn[data-hidden-from-shares="false"]
|
|
1803
|
+
.msg.user .msg-bubble:focus-within .msg-share-visibility-btn[data-hidden-from-shares="false"],
|
|
1804
|
+
.msg.user .msg-bubble.msg-actions-revealed .msg-share-visibility-btn[data-hidden-from-shares="false"] { opacity: 0.8; }
|
|
1805
|
+
/* The pin half of the same corner row: `Pin` is revealed like `Hide`, a
|
|
1806
|
+
pinned message keeps its label and `Unpin` in view. */
|
|
1807
|
+
.msg-share-visibility { flex-wrap: wrap; }
|
|
1808
|
+
.msg-pinned-label {
|
|
1809
|
+
color: var(--muted);
|
|
1810
|
+
font-size: var(--fs-ui-chip);
|
|
1811
|
+
white-space: nowrap;
|
|
1812
|
+
}
|
|
1813
|
+
.msg-pin-btn {
|
|
1814
|
+
appearance: none;
|
|
1815
|
+
border: 0;
|
|
1816
|
+
background: transparent;
|
|
1817
|
+
color: var(--muted);
|
|
1818
|
+
cursor: pointer;
|
|
1819
|
+
font-size: var(--fs-ui-chip);
|
|
1820
|
+
line-height: 1;
|
|
1821
|
+
padding: 4px;
|
|
1822
|
+
text-decoration: underline;
|
|
1823
|
+
text-underline-offset: 2px;
|
|
1824
|
+
}
|
|
1825
|
+
.msg-pin-btn:hover:not(:disabled) { color: var(--accent); }
|
|
1826
|
+
.msg-pin-btn:disabled { cursor: wait; opacity: 0.55; }
|
|
1827
|
+
.msg-pin-btn[data-pinned="false"] {
|
|
1828
|
+
opacity: 0;
|
|
1829
|
+
pointer-events: none;
|
|
1830
|
+
}
|
|
1831
|
+
.msg-bubble:hover .msg-pin-btn[data-pinned="false"],
|
|
1832
|
+
.msg-bubble:focus-within .msg-pin-btn[data-pinned="false"],
|
|
1833
|
+
.msg-bubble.msg-actions-revealed .msg-pin-btn[data-pinned="false"] {
|
|
1834
|
+
opacity: 1;
|
|
1835
|
+
pointer-events: auto;
|
|
1836
|
+
}
|
|
1837
|
+
.msg.user .msg-pinned-label,
|
|
1838
|
+
.msg.user .msg-pin-btn { color: var(--user-bubble-fg); opacity: 0.8; }
|
|
1839
|
+
.msg.user .msg-pin-btn[data-pinned="false"] { opacity: 0; }
|
|
1840
|
+
.msg.user .msg-bubble:hover .msg-pin-btn[data-pinned="false"],
|
|
1841
|
+
.msg.user .msg-bubble:focus-within .msg-pin-btn[data-pinned="false"],
|
|
1842
|
+
.msg.user .msg-bubble.msg-actions-revealed .msg-pin-btn[data-pinned="false"] { opacity: 0.8; }
|
|
1784
1843
|
.thinking-thoughts-panel {
|
|
1785
1844
|
margin-bottom: 8px;
|
|
1786
1845
|
min-width: 0;
|
|
@@ -3226,28 +3285,62 @@
|
|
|
3226
3285
|
.workspace-file-link { text-decoration: underline; }
|
|
3227
3286
|
.workspace-file-link:hover { opacity: 0.9; }
|
|
3228
3287
|
|
|
3288
|
+
/* The file viewer fills the screen. Its chrome (title, toolbar, meta line
|
|
3289
|
+
above; position and prev/next below) floats over the content and slides
|
|
3290
|
+
away when the controls are hidden. Text kinds keep the app background and
|
|
3291
|
+
a readable column; media kinds go black edge to edge. */
|
|
3229
3292
|
#file-preview-modal {
|
|
3230
3293
|
position: fixed; inset: 0; z-index: 11000;
|
|
3231
|
-
display: none;
|
|
3232
|
-
background:
|
|
3233
|
-
|
|
3294
|
+
display: none;
|
|
3295
|
+
background: var(--bg2);
|
|
3296
|
+
--file-viewer-chrome-top: 0px;
|
|
3234
3297
|
}
|
|
3235
|
-
#file-preview-modal.visible { display:
|
|
3298
|
+
#file-preview-modal.visible { display: block; }
|
|
3299
|
+
#file-preview-modal.media-mode { background: #000; }
|
|
3236
3300
|
.file-preview-dialog {
|
|
3237
|
-
|
|
3238
|
-
max-height: min(90vh, 900px);
|
|
3239
|
-
display: flex;
|
|
3240
|
-
flex-direction: column;
|
|
3241
|
-
background: var(--bg2);
|
|
3242
|
-
border: 1px solid var(--border);
|
|
3243
|
-
border-radius: 14px;
|
|
3301
|
+
position: absolute; inset: 0;
|
|
3244
3302
|
overflow: hidden;
|
|
3245
3303
|
}
|
|
3304
|
+
.file-preview-chrome {
|
|
3305
|
+
position: absolute; left: 0; right: 0; z-index: 2;
|
|
3306
|
+
transition: opacity 0.18s ease, transform 0.18s ease;
|
|
3307
|
+
}
|
|
3308
|
+
.file-preview-chrome-top {
|
|
3309
|
+
top: 0;
|
|
3310
|
+
padding-top: env(safe-area-inset-top);
|
|
3311
|
+
background: color-mix(in srgb, var(--bg3) 92%, transparent);
|
|
3312
|
+
-webkit-backdrop-filter: blur(10px);
|
|
3313
|
+
backdrop-filter: blur(10px);
|
|
3314
|
+
border-bottom: 1px solid var(--border);
|
|
3315
|
+
}
|
|
3316
|
+
#file-preview-modal.media-mode .file-preview-chrome-top {
|
|
3317
|
+
background: linear-gradient(rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0.62) 70%, rgba(0, 0, 0, 0));
|
|
3318
|
+
border-bottom: 0;
|
|
3319
|
+
-webkit-backdrop-filter: none;
|
|
3320
|
+
backdrop-filter: none;
|
|
3321
|
+
color: #fff;
|
|
3322
|
+
padding-bottom: 18px;
|
|
3323
|
+
}
|
|
3324
|
+
.file-preview-chrome-bottom {
|
|
3325
|
+
bottom: 0;
|
|
3326
|
+
display: flex; align-items: center; justify-content: center; gap: 14px;
|
|
3327
|
+
padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
|
|
3328
|
+
background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.62));
|
|
3329
|
+
color: #fff;
|
|
3330
|
+
}
|
|
3331
|
+
.file-preview-chrome-bottom[hidden] { display: none; }
|
|
3332
|
+
#file-preview-modal.controls-hidden .file-preview-chrome { opacity: 0; pointer-events: none; }
|
|
3333
|
+
#file-preview-modal.controls-hidden .file-preview-chrome-top { transform: translateY(-12px); }
|
|
3334
|
+
#file-preview-modal.controls-hidden .file-preview-chrome-bottom { transform: translateY(12px); }
|
|
3335
|
+
.file-preview-position {
|
|
3336
|
+
min-width: 4ch; text-align: center;
|
|
3337
|
+
font-size: var(--fs-meta-md);
|
|
3338
|
+
font-variant-numeric: tabular-nums;
|
|
3339
|
+
}
|
|
3340
|
+
.file-preview-nav { font-size: 1.3rem; line-height: 1; padding: 4px 14px; }
|
|
3246
3341
|
.file-preview-header {
|
|
3247
3342
|
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
|
3248
3343
|
padding: 12px 14px;
|
|
3249
|
-
border-bottom: 1px solid var(--border);
|
|
3250
|
-
background: var(--bg3);
|
|
3251
3344
|
}
|
|
3252
3345
|
.file-preview-title {
|
|
3253
3346
|
min-width: 0;
|
|
@@ -3272,23 +3365,35 @@
|
|
|
3272
3365
|
}
|
|
3273
3366
|
.file-preview-toolbar {
|
|
3274
3367
|
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
|
3275
|
-
padding:
|
|
3276
|
-
border-bottom: 1px solid var(--border);
|
|
3368
|
+
padding: 0 14px 10px;
|
|
3277
3369
|
}
|
|
3278
3370
|
.file-preview-btn {
|
|
3279
3371
|
border: 1px solid var(--border);
|
|
3280
|
-
background: var(--
|
|
3372
|
+
background: var(--bg2);
|
|
3281
3373
|
color: var(--text);
|
|
3282
3374
|
border-radius: 8px;
|
|
3283
3375
|
padding: 6px 10px;
|
|
3284
3376
|
font-size: var(--fs-meta-md);
|
|
3285
3377
|
cursor: pointer;
|
|
3378
|
+
text-decoration: none;
|
|
3286
3379
|
}
|
|
3287
3380
|
.file-preview-btn.active {
|
|
3288
3381
|
border-color: var(--accent);
|
|
3289
3382
|
color: var(--accent);
|
|
3290
3383
|
}
|
|
3291
3384
|
.file-preview-btn[hidden] { display: none; }
|
|
3385
|
+
#file-preview-modal.media-mode .file-preview-btn,
|
|
3386
|
+
#file-preview-modal.media-mode .file-preview-close {
|
|
3387
|
+
background: rgba(255, 255, 255, 0.14);
|
|
3388
|
+
border-color: rgba(255, 255, 255, 0.22);
|
|
3389
|
+
color: #fff;
|
|
3390
|
+
}
|
|
3391
|
+
#file-preview-modal.media-mode .file-preview-btn.active {
|
|
3392
|
+
border-color: var(--accent);
|
|
3393
|
+
color: #fff;
|
|
3394
|
+
background: color-mix(in srgb, var(--accent) 45%, transparent);
|
|
3395
|
+
}
|
|
3396
|
+
#file-preview-modal.media-mode .file-preview-meta { color: rgba(255, 255, 255, 0.72); }
|
|
3292
3397
|
.file-preview-warning {
|
|
3293
3398
|
display: none;
|
|
3294
3399
|
margin-left: auto;
|
|
@@ -3297,16 +3402,30 @@
|
|
|
3297
3402
|
}
|
|
3298
3403
|
.file-preview-warning.visible { display: inline; }
|
|
3299
3404
|
.file-preview-meta {
|
|
3300
|
-
padding:
|
|
3405
|
+
padding: 0 14px 8px;
|
|
3301
3406
|
font-size: var(--fs-meta-sm);
|
|
3302
3407
|
color: var(--muted);
|
|
3303
|
-
border-bottom: 1px dashed var(--border);
|
|
3304
3408
|
}
|
|
3409
|
+
.file-preview-meta:empty { display: none; }
|
|
3410
|
+
/* Text starts below the chrome so the first line is readable with the
|
|
3411
|
+
controls up; the chrome's height is measured into the variable. */
|
|
3305
3412
|
.file-preview-body {
|
|
3306
|
-
|
|
3307
|
-
min-height: 0; /* prevents flex child from overflowing the dialog's max-height */
|
|
3413
|
+
position: absolute; inset: 0;
|
|
3308
3414
|
overflow: auto;
|
|
3309
|
-
|
|
3415
|
+
-webkit-overflow-scrolling: touch;
|
|
3416
|
+
padding: calc(var(--file-viewer-chrome-top) + 12px) 14px calc(28px + env(safe-area-inset-bottom));
|
|
3417
|
+
/* Up and down scroll the text; sideways is left to the viewer (a swipe
|
|
3418
|
+
to the next file). A block that scrolls sideways itself keeps that. */
|
|
3419
|
+
touch-action: pan-y pinch-zoom;
|
|
3420
|
+
}
|
|
3421
|
+
.file-preview-markdown pre,
|
|
3422
|
+
.file-preview-code pre,
|
|
3423
|
+
.file-preview-markdown table { touch-action: pan-x pan-y; }
|
|
3424
|
+
.file-preview-nav:disabled { opacity: 0.35; cursor: default; }
|
|
3425
|
+
.file-preview-markdown,
|
|
3426
|
+
.file-preview-body > .file-preview-code {
|
|
3427
|
+
max-width: 56rem;
|
|
3428
|
+
margin: 0 auto;
|
|
3310
3429
|
}
|
|
3311
3430
|
.file-preview-markdown {
|
|
3312
3431
|
line-height: 1.5;
|
|
@@ -3369,42 +3488,71 @@
|
|
|
3369
3488
|
user-select: none;
|
|
3370
3489
|
-webkit-user-drag: none;
|
|
3371
3490
|
}
|
|
3372
|
-
.file-preview-video-shell
|
|
3491
|
+
.file-preview-video-shell,
|
|
3492
|
+
.file-preview-audio-shell {
|
|
3373
3493
|
display: flex;
|
|
3374
3494
|
flex-direction: column;
|
|
3495
|
+
align-items: center;
|
|
3496
|
+
justify-content: center;
|
|
3375
3497
|
gap: 10px;
|
|
3376
3498
|
width: 100%;
|
|
3377
3499
|
height: 100%;
|
|
3378
3500
|
}
|
|
3379
3501
|
.file-preview-video {
|
|
3380
|
-
width: 100%;
|
|
3381
3502
|
max-width: 100%;
|
|
3382
|
-
max-height:
|
|
3503
|
+
max-height: 100%;
|
|
3504
|
+
width: auto;
|
|
3505
|
+
height: auto;
|
|
3383
3506
|
background: #000;
|
|
3384
|
-
border-radius: 10px;
|
|
3385
3507
|
}
|
|
3386
|
-
/*
|
|
3508
|
+
/* The player takes the card's width; a percentage on the player alone
|
|
3509
|
+
would resolve against a content-sized flex row and come out as zero. */
|
|
3510
|
+
.file-preview-audio-shell .file-preview-card { width: min(560px, calc(100% - 28px)); }
|
|
3511
|
+
.file-preview-audio-shell .file-preview-card-actions { width: 100%; }
|
|
3512
|
+
.file-preview-audio-shell audio { width: 100%; }
|
|
3513
|
+
/* Media and cards: no scrolling, the content is centred on the whole screen. */
|
|
3387
3514
|
.file-preview-body.image-zoom-mode,
|
|
3388
|
-
.file-preview-body.video-preview-mode
|
|
3389
|
-
|
|
3390
|
-
|
|
3515
|
+
.file-preview-body.video-preview-mode,
|
|
3516
|
+
.file-preview-body.audio-preview-mode,
|
|
3517
|
+
.file-preview-body.card-preview-mode {
|
|
3391
3518
|
overflow: hidden;
|
|
3392
3519
|
padding: 0;
|
|
3393
3520
|
display: flex;
|
|
3394
3521
|
flex-direction: column;
|
|
3395
|
-
|
|
3522
|
+
touch-action: none;
|
|
3396
3523
|
}
|
|
3397
3524
|
.file-preview-body.image-zoom-mode .file-preview-image {
|
|
3398
3525
|
flex: 1;
|
|
3399
|
-
min-height: 0; /*
|
|
3400
|
-
|
|
3526
|
+
min-height: 0; /* lets the img's max-height:100% resolve */
|
|
3527
|
+
}
|
|
3528
|
+
.file-preview-body.image-zoom-mode .file-preview-image img { border-radius: 0; }
|
|
3529
|
+
/* A file with nothing to show inline (a PDF, an unknown binary) or an audio
|
|
3530
|
+
file: a card in the middle with the name, the size and what can be done. */
|
|
3531
|
+
.file-preview-card {
|
|
3532
|
+
margin: auto;
|
|
3533
|
+
max-width: min(420px, calc(100% - 28px));
|
|
3534
|
+
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
|
3535
|
+
text-align: center;
|
|
3536
|
+
padding: 24px;
|
|
3537
|
+
border: 1px solid var(--border);
|
|
3538
|
+
border-radius: 16px;
|
|
3539
|
+
background: var(--bg3);
|
|
3401
3540
|
}
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
background: #0b0f14;
|
|
3541
|
+
#file-preview-modal.media-mode .file-preview-card {
|
|
3542
|
+
background: rgba(255, 255, 255, 0.08);
|
|
3543
|
+
border-color: rgba(255, 255, 255, 0.16);
|
|
3544
|
+
color: #fff;
|
|
3407
3545
|
}
|
|
3546
|
+
.file-preview-card-icon { font-size: 3rem; line-height: 1; }
|
|
3547
|
+
.file-preview-card-name {
|
|
3548
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
3549
|
+
font-size: var(--fs-meta-md);
|
|
3550
|
+
word-break: break-all;
|
|
3551
|
+
}
|
|
3552
|
+
.file-preview-card-meta { font-size: var(--fs-meta-sm); color: var(--muted); }
|
|
3553
|
+
#file-preview-modal.media-mode .file-preview-card-meta { color: rgba(255, 255, 255, 0.72); }
|
|
3554
|
+
.file-preview-card-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 8px; }
|
|
3555
|
+
.file-preview-card-actions .file-preview-btn { padding: 10px 16px; }
|
|
3408
3556
|
.msg-attachment-video-chip {
|
|
3409
3557
|
display: inline-flex;
|
|
3410
3558
|
align-items: center;
|
|
@@ -3421,6 +3569,11 @@
|
|
|
3421
3569
|
color: var(--muted);
|
|
3422
3570
|
margin-top: 10px;
|
|
3423
3571
|
}
|
|
3572
|
+
.file-preview-body.image-zoom-mode .file-preview-note,
|
|
3573
|
+
.file-preview-body.video-preview-mode .file-preview-note {
|
|
3574
|
+
margin: auto;
|
|
3575
|
+
padding: 14px;
|
|
3576
|
+
}
|
|
3424
3577
|
|
|
3425
3578
|
/* Annotation editor. Clones the file-preview shell, but sits above it
|
|
3426
3579
|
(z-index 11500) so annotating a sent image can layer over the lightbox. */
|
|
@@ -4224,6 +4377,84 @@
|
|
|
4224
4377
|
font-size: var(--fs-meta-sm);
|
|
4225
4378
|
padding: 8px 6px;
|
|
4226
4379
|
}
|
|
4380
|
+
/* Pinned messages list (the summary modal, kind 'pinned-messages'). */
|
|
4381
|
+
.pinned-list {
|
|
4382
|
+
display: flex;
|
|
4383
|
+
flex-direction: column;
|
|
4384
|
+
gap: 8px;
|
|
4385
|
+
}
|
|
4386
|
+
.pinned-row {
|
|
4387
|
+
display: flex;
|
|
4388
|
+
align-items: stretch;
|
|
4389
|
+
border: 1px solid var(--border);
|
|
4390
|
+
background: var(--bg3);
|
|
4391
|
+
border-radius: 10px;
|
|
4392
|
+
overflow: hidden;
|
|
4393
|
+
}
|
|
4394
|
+
.pinned-row:hover,
|
|
4395
|
+
.pinned-row:focus-within {
|
|
4396
|
+
border-color: var(--accent);
|
|
4397
|
+
}
|
|
4398
|
+
.pinned-row-jump {
|
|
4399
|
+
appearance: none;
|
|
4400
|
+
border: 0;
|
|
4401
|
+
background: transparent;
|
|
4402
|
+
color: var(--text);
|
|
4403
|
+
font: inherit;
|
|
4404
|
+
text-align: left;
|
|
4405
|
+
cursor: pointer;
|
|
4406
|
+
flex: 1;
|
|
4407
|
+
min-width: 0;
|
|
4408
|
+
padding: 10px 12px;
|
|
4409
|
+
display: flex;
|
|
4410
|
+
flex-direction: column;
|
|
4411
|
+
gap: 4px;
|
|
4412
|
+
}
|
|
4413
|
+
.pinned-row-meta {
|
|
4414
|
+
font-size: var(--fs-meta-xs);
|
|
4415
|
+
color: var(--muted);
|
|
4416
|
+
}
|
|
4417
|
+
.pinned-row-chip {
|
|
4418
|
+
border: 1px solid var(--border);
|
|
4419
|
+
border-radius: 999px;
|
|
4420
|
+
padding: 0 6px;
|
|
4421
|
+
white-space: nowrap;
|
|
4422
|
+
}
|
|
4423
|
+
.pinned-row-preview {
|
|
4424
|
+
font-size: var(--fs-meta-sm);
|
|
4425
|
+
line-height: 1.4;
|
|
4426
|
+
display: -webkit-box;
|
|
4427
|
+
-webkit-line-clamp: 2;
|
|
4428
|
+
-webkit-box-orient: vertical;
|
|
4429
|
+
overflow: hidden;
|
|
4430
|
+
overflow-wrap: anywhere;
|
|
4431
|
+
}
|
|
4432
|
+
.pinned-row-no-text {
|
|
4433
|
+
color: var(--muted);
|
|
4434
|
+
font-style: italic;
|
|
4435
|
+
}
|
|
4436
|
+
/* 44px: a thumb-sized target, always in view (no hover on a phone). */
|
|
4437
|
+
.pinned-row-unpin {
|
|
4438
|
+
appearance: none;
|
|
4439
|
+
border: 0;
|
|
4440
|
+
border-left: 1px solid var(--border);
|
|
4441
|
+
background: transparent;
|
|
4442
|
+
color: var(--muted);
|
|
4443
|
+
cursor: pointer;
|
|
4444
|
+
flex: 0 0 44px;
|
|
4445
|
+
min-height: 44px;
|
|
4446
|
+
font-size: var(--fs-ui-icon);
|
|
4447
|
+
line-height: 1;
|
|
4448
|
+
}
|
|
4449
|
+
.pinned-row-unpin:hover {
|
|
4450
|
+
color: var(--danger);
|
|
4451
|
+
background: rgba(248, 81, 73, 0.08);
|
|
4452
|
+
}
|
|
4453
|
+
.pinned-empty {
|
|
4454
|
+
color: var(--muted);
|
|
4455
|
+
font-size: var(--fs-meta-sm);
|
|
4456
|
+
padding: 8px 6px;
|
|
4457
|
+
}
|
|
4227
4458
|
|
|
4228
4459
|
#git-changes-modal,
|
|
4229
4460
|
#git-diff-modal {
|
|
@@ -5304,6 +5535,7 @@
|
|
|
5304
5535
|
}
|
|
5305
5536
|
#pending-question-banner { font-size: var(--fs-ui-chip); padding: 3px 8px; }
|
|
5306
5537
|
.header-icon-btn { width: 30px; height: 30px; border-radius: 8px; font-size: var(--fs-ui-icon); }
|
|
5538
|
+
.header-icon-btn.has-count { min-width: 30px; padding: 0 6px; }
|
|
5307
5539
|
#chat-actions-menu { min-width: 200px; }
|
|
5308
5540
|
/* Slightly taller than desktop so the tap target barely changes; the row
|
|
5309
5541
|
pitch still drops from 36px to 32px thanks to the tighter menu gap. */
|
|
@@ -5541,6 +5773,15 @@
|
|
|
5541
5773
|
flex: 1 1 calc(50% - 4px);
|
|
5542
5774
|
min-height: 40px;
|
|
5543
5775
|
}
|
|
5776
|
+
/* The pin list has one action, Close: the full-width action row above
|
|
5777
|
+
would leave its title no room. Title left, close button right. */
|
|
5778
|
+
#summary-modal[data-kind="pinned-messages"] .summary-header-actions {
|
|
5779
|
+
width: auto;
|
|
5780
|
+
}
|
|
5781
|
+
#summary-modal[data-kind="pinned-messages"] .summary-header-actions .summary-close {
|
|
5782
|
+
flex: 0 0 auto;
|
|
5783
|
+
min-width: 40px;
|
|
5784
|
+
}
|
|
5544
5785
|
.model-variant-row {
|
|
5545
5786
|
min-height: 48px;
|
|
5546
5787
|
padding: 10px 4px;
|
|
@@ -5813,6 +6054,7 @@
|
|
|
5813
6054
|
</form>
|
|
5814
6055
|
</div>
|
|
5815
6056
|
<div id="chat-header-actions">
|
|
6057
|
+
<button id="pinned-messages-btn" class="header-icon-btn has-count" type="button" title="Pinned messages" aria-label="Pinned messages" hidden>📍<span class="header-icon-count"></span></button>
|
|
5816
6058
|
<button id="message-search-btn" class="header-icon-btn" onclick="openMessageSearchModal()" title="Search messages" aria-label="Search messages">🔍</button>
|
|
5817
6059
|
<button id="context-btn" class="header-icon-btn" onclick="showContext()" title="Check current context" aria-label="Check current context">🧠</button>
|
|
5818
6060
|
<div id="chat-actions-menu-wrap">
|
|
@@ -7025,25 +7267,33 @@
|
|
|
7025
7267
|
</div>
|
|
7026
7268
|
</div>
|
|
7027
7269
|
|
|
7028
|
-
<!--
|
|
7270
|
+
<!-- Fullscreen file viewer. The content fills the screen; the chrome bars
|
|
7271
|
+
float over it and a tap on the content shows or hides them. -->
|
|
7029
7272
|
<div id="file-preview-modal" aria-hidden="true">
|
|
7030
7273
|
<div class="file-preview-dialog" role="dialog" aria-modal="true" aria-label="File preview">
|
|
7031
|
-
<div class="file-preview-
|
|
7032
|
-
|
|
7033
|
-
<
|
|
7274
|
+
<div id="file-preview-body" class="file-preview-body"></div>
|
|
7275
|
+
<div id="file-preview-chrome-top" class="file-preview-chrome file-preview-chrome-top">
|
|
7276
|
+
<div class="file-preview-header">
|
|
7277
|
+
<div id="file-preview-title" class="file-preview-title">File preview</div>
|
|
7278
|
+
<button class="file-preview-close" onclick="closeFilePreview()" aria-label="Close preview">×</button>
|
|
7279
|
+
</div>
|
|
7280
|
+
<div class="file-preview-toolbar">
|
|
7281
|
+
<button id="file-preview-back" class="file-preview-btn" type="button" onclick="goBackFilePreview()" hidden disabled>← Back</button>
|
|
7282
|
+
<button id="file-preview-mode-preview" class="file-preview-btn active" onclick="setFilePreviewMode('preview')">Preview</button>
|
|
7283
|
+
<button id="file-preview-mode-raw" class="file-preview-btn" onclick="setFilePreviewMode('raw')">Raw</button>
|
|
7284
|
+
<button id="file-preview-html-toggle" class="file-preview-btn" onclick="toggleFilePreviewHtml()">Show literal HTML</button>
|
|
7285
|
+
<a id="file-preview-open-raw" class="file-preview-btn" download>Download</a>
|
|
7286
|
+
<button id="file-preview-copy-btn" class="file-preview-btn" type="button" onclick="copyFilePreviewImage()" hidden>Copy</button>
|
|
7287
|
+
<button id="file-preview-annotate-btn" class="file-preview-btn" type="button" onclick="openAnnotateEditorForPreview()" hidden>🖍️ Annotate</button>
|
|
7288
|
+
<span id="file-preview-warning" class="file-preview-warning">⚠ Safe layout HTML enabled; active content is blocked</span>
|
|
7289
|
+
</div>
|
|
7290
|
+
<div id="file-preview-meta" class="file-preview-meta"></div>
|
|
7034
7291
|
</div>
|
|
7035
|
-
<div class="file-preview-
|
|
7036
|
-
<button id="file-preview-
|
|
7037
|
-
<
|
|
7038
|
-
<button id="file-preview-
|
|
7039
|
-
<button id="file-preview-html-toggle" class="file-preview-btn" onclick="toggleFilePreviewHtml()">Show literal HTML</button>
|
|
7040
|
-
<a id="file-preview-open-raw" class="file-preview-btn" download>Download</a>
|
|
7041
|
-
<button id="file-preview-copy-btn" class="file-preview-btn" type="button" onclick="copyFilePreviewImage()" hidden>Copy</button>
|
|
7042
|
-
<button id="file-preview-annotate-btn" class="file-preview-btn" type="button" onclick="openAnnotateEditorForPreview()" hidden>🖍️ Annotate</button>
|
|
7043
|
-
<span id="file-preview-warning" class="file-preview-warning">⚠ Safe layout HTML enabled; active content is blocked</span>
|
|
7292
|
+
<div id="file-preview-chrome-bottom" class="file-preview-chrome file-preview-chrome-bottom" hidden>
|
|
7293
|
+
<button id="file-preview-prev" class="file-preview-btn file-preview-nav" type="button" aria-label="Previous file">‹</button>
|
|
7294
|
+
<span id="file-preview-position" class="file-preview-position"></span>
|
|
7295
|
+
<button id="file-preview-next" class="file-preview-btn file-preview-nav" type="button" aria-label="Next file">›</button>
|
|
7044
7296
|
</div>
|
|
7045
|
-
<div id="file-preview-meta" class="file-preview-meta"></div>
|
|
7046
|
-
<div id="file-preview-body" class="file-preview-body"></div>
|
|
7047
7297
|
</div>
|
|
7048
7298
|
</div>
|
|
7049
7299
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pinned messages: the pinned_at column detection and its SQL in one place.
|
|
4
|
+
// The statements are null on a database the 0008 migration has not reached
|
|
5
|
+
// yet; every caller treats that as "no pins".
|
|
6
|
+
export function createMessagePinStatements(db) {
|
|
7
|
+
const messageColumns = new Set(
|
|
8
|
+
db.prepare(`PRAGMA table_info(messages)`).all().map((column) => String(column?.name || '').trim()),
|
|
9
|
+
);
|
|
10
|
+
const messagesSupportPins = messageColumns.has('pinned_at');
|
|
11
|
+
if (!messagesSupportPins) {
|
|
12
|
+
return {
|
|
13
|
+
messagesSupportPins,
|
|
14
|
+
setMessagePinnedAt: null,
|
|
15
|
+
listPinnedMessages: null,
|
|
16
|
+
countPinnedMessages: null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
messagesSupportPins,
|
|
21
|
+
setMessagePinnedAt: db.prepare(`
|
|
22
|
+
UPDATE messages
|
|
23
|
+
SET pinned_at = ?
|
|
24
|
+
WHERE id = ? AND conversation_id = ?
|
|
25
|
+
`),
|
|
26
|
+
// Conversation order, not pin order: the list reads like a table of contents.
|
|
27
|
+
listPinnedMessages: db.prepare(`
|
|
28
|
+
SELECT id, role, text, mode, attachments, timestamp, pinned_at, hidden_from_shares
|
|
29
|
+
FROM messages
|
|
30
|
+
WHERE conversation_id = ? AND pinned_at IS NOT NULL
|
|
31
|
+
ORDER BY timestamp ASC
|
|
32
|
+
`),
|
|
33
|
+
countPinnedMessages: db.prepare(`
|
|
34
|
+
SELECT COUNT(*) AS cnt
|
|
35
|
+
FROM messages
|
|
36
|
+
WHERE conversation_id = ? AND pinned_at IS NOT NULL
|
|
37
|
+
`),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
|
|
4
|
+
import { createMessagePinStatements } from './message-pin-statements.mjs';
|
|
4
5
|
import { sessionWorkerProviderSqlList } from '../../shared/provider-routing.mjs';
|
|
5
6
|
|
|
6
7
|
export function createMessageRepository(db) {
|
|
7
8
|
const shareVisibility = createShareVisibilityStatements(db);
|
|
9
|
+
const messagePins = createMessagePinStatements(db);
|
|
8
10
|
const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
|
|
9
11
|
.some((column) => column.name === 'image_operation_id');
|
|
10
12
|
// A turn paused at the Claude usage limit (usage-limit-pause-service): a
|
|
@@ -27,6 +29,9 @@ export function createMessageRepository(db) {
|
|
|
27
29
|
getSharedMessages: shareVisibility.getSharedMessages,
|
|
28
30
|
getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
|
|
29
31
|
setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
|
|
32
|
+
setMessagePinnedAt: messagePins.setMessagePinnedAt,
|
|
33
|
+
listPinnedMessages: messagePins.listPinnedMessages,
|
|
34
|
+
countPinnedMessages: messagePins.countPinnedMessages,
|
|
30
35
|
getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
|
|
31
36
|
getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
|
|
32
37
|
insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|