@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +53 -32
  3. package/bin/oar.js +814 -228
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/db-schema.mjs +8 -0
  7. package/server/migrations/0008-message-pins.mjs +85 -0
  8. package/server/public/app/api-client.js +30 -0
  9. package/server/public/app/attachments-view.js +508 -125
  10. package/server/public/app/back-guard.mjs +58 -0
  11. package/server/public/app/bootstrap.js +46 -2
  12. package/server/public/app/conversation-view.js +110 -25
  13. package/server/public/app/external-link-policy.mjs +4 -1
  14. package/server/public/app/file-version.mjs +58 -0
  15. package/server/public/app/journal-view.js +16 -2
  16. package/server/public/app/pinned-messages-view.mjs +253 -0
  17. package/server/public/app/pwa-install.js +1 -1
  18. package/server/public/app/relay-dot-state.mjs +8 -1
  19. package/server/public/app/router.js +8 -2
  20. package/server/public/app/socket-handlers.js +6 -0
  21. package/server/public/app/store.js +6 -0
  22. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  23. package/server/public/app/viewer-controls-state.mjs +59 -0
  24. package/server/public/app/viewer-gallery.mjs +46 -0
  25. package/server/public/app/viewer-gestures.mjs +105 -0
  26. package/server/public/app/viewer-history.mjs +43 -0
  27. package/server/public/app/viewer-tap.mjs +112 -0
  28. package/server/public/index.html +304 -54
  29. package/server/repositories/message-pin-statements.mjs +39 -0
  30. package/server/repositories/message-repository.mjs +5 -0
  31. package/server/routes/messages-routes.mjs +33 -61
  32. package/server/routes/sessions-routes.mjs +92 -24
  33. package/server/server-runtime.mjs +29 -0
  34. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  35. package/server/services/file-serving.mjs +155 -0
  36. package/server/services/message-pin-list.mjs +52 -0
  37. package/server/services/oar-cli-helpers.mjs +139 -4
  38. package/server/services/oar-cli-menu.mjs +240 -0
  39. package/server/services/oar-cli-wsl.mjs +129 -0
  40. package/server/services/safe-served-content.mjs +61 -11
  41. package/server/services/session-history-refresh-service.mjs +24 -0
@@ -0,0 +1,253 @@
1
+ // Pinned messages: the per-conversation pin list, the 📍 header button with
2
+ // its count, and the list modal (the shared summary modal, kind
3
+ // 'pinned-messages'). Fed by the conversation payload's `pins` on load and by
4
+ // the `conversation_pins_updated` socket event (REPLACE semantics per
5
+ // conversation), so a pin set on another device shows here without a reload.
6
+ // This list is also what a bubble's Pin / Unpin label is drawn from.
7
+
8
+ import {
9
+ IS_SHARED_VIEW,
10
+ closeSummaryModal,
11
+ escHtml,
12
+ fmtDate,
13
+ openSummaryModal,
14
+ renderSummaryModalContent,
15
+ showTransientRelayNotice,
16
+ summaryModalState,
17
+ } from './store.js';
18
+ import { updateMessagePin } from './api-client.js';
19
+
20
+ export const PINNED_MESSAGES_MODAL_KIND = 'pinned-messages';
21
+
22
+ const pinsByConversation = new Map();
23
+ // The relay numbers its pin lists. A conversation load that left before a pin
24
+ // change can arrive after the change's own socket event: the older list must
25
+ // not win, or the page would stay wrong until the next load.
26
+ const pinRevisionByConversation = new Map();
27
+ const pinTogglesInFlight = new Set();
28
+ let currentConversationId = null;
29
+ let modalBodyBound = false;
30
+ // The transcript lives in conversation-view.js and journal-view.js, which
31
+ // import this module; they hand their side in through initPinnedMessagesView.
32
+ let handlers = {
33
+ openConversation: null,
34
+ focusMessage: null,
35
+ syncRenderedPinState: null,
36
+ };
37
+
38
+ function normalizePins(pins) {
39
+ return (Array.isArray(pins) ? pins : [])
40
+ .map((pin) => ({ ...pin, messageId: String(pin?.messageId || '').trim() }))
41
+ .filter((pin) => pin.messageId);
42
+ }
43
+
44
+ export function getConversationPins(conversationId) {
45
+ return pinsByConversation.get(String(conversationId || '').trim()) || [];
46
+ }
47
+
48
+ export function isMessagePinned(conversationId, messageId) {
49
+ const id = String(messageId || '').trim();
50
+ return !!id && getConversationPins(conversationId).some((pin) => pin.messageId === id);
51
+ }
52
+
53
+ export function pinnedCountLabel(count) {
54
+ const total = Number(count) || 0;
55
+ return total ? `${total} pinned` : '';
56
+ }
57
+
58
+ function pinRoleLabel(role) {
59
+ return String(role || '').trim().toLowerCase() === 'user' ? 'You' : 'Agent';
60
+ }
61
+
62
+ export function renderPinnedListHtml(pins) {
63
+ const list = normalizePins(pins);
64
+ if (!list.length) {
65
+ return '<div class="pinned-empty">No pinned messages. Use Pin on a message to add one.</div>';
66
+ }
67
+ return `<div class="pinned-list">${list.map((pin) => {
68
+ const id = escHtml(pin.messageId);
69
+ const when = fmtDate(pin.timestamp);
70
+ const attachmentCount = Number(pin.attachmentCount) || 0;
71
+ const meta = [
72
+ escHtml(pinRoleLabel(pin.role)),
73
+ when ? escHtml(when) : '',
74
+ attachmentCount ? `📎 ${attachmentCount}` : '',
75
+ pin.hiddenFromShares === true ? '<span class="pinned-row-chip">hidden from shared</span>' : '',
76
+ ].filter(Boolean).join(' · ');
77
+ const preview = String(pin.preview || '').trim();
78
+ const previewHtml = preview
79
+ ? escHtml(preview)
80
+ : '<span class="pinned-row-no-text">(no text)</span>';
81
+ return `
82
+ <div class="pinned-row">
83
+ <button type="button" class="pinned-row-jump" data-pin-jump="${id}">
84
+ <span class="pinned-row-meta">${meta}</span>
85
+ <span class="pinned-row-preview">${previewHtml}</span>
86
+ </button>
87
+ <button type="button" class="pinned-row-unpin" data-pin-unpin="${id}" title="Unpin" aria-label="Unpin">🗑</button>
88
+ </div>`;
89
+ }).join('')}</div>`;
90
+ }
91
+
92
+ function syncPinnedHeaderButton() {
93
+ const button = document.getElementById('pinned-messages-btn');
94
+ if (!button) return;
95
+ const count = getConversationPins(currentConversationId).length;
96
+ button.hidden = IS_SHARED_VIEW || !count;
97
+ const countEl = button.querySelector('.header-icon-count');
98
+ if (countEl) countEl.textContent = count ? String(count) : '';
99
+ const label = count ? `Pinned messages (${count})` : 'Pinned messages';
100
+ button.setAttribute('title', label);
101
+ button.setAttribute('aria-label', label);
102
+ }
103
+
104
+ function pinnedModalContent() {
105
+ const pins = getConversationPins(currentConversationId);
106
+ return {
107
+ title: '📍 Pinned messages',
108
+ subtitle: pinnedCountLabel(pins.length),
109
+ bodyHtml: renderPinnedListHtml(pins),
110
+ kind: PINNED_MESSAGES_MODAL_KIND,
111
+ };
112
+ }
113
+
114
+ function syncCurrentConversationPins() {
115
+ syncPinnedHeaderButton();
116
+ if (summaryModalState.kind === PINNED_MESSAGES_MODAL_KIND) {
117
+ renderSummaryModalContent(pinnedModalContent());
118
+ }
119
+ handlers.syncRenderedPinState?.(
120
+ new Set(getConversationPins(currentConversationId).map((pin) => pin.messageId)),
121
+ );
122
+ }
123
+
124
+ export function setConversationPins(conversationId, pins, revision = 0) {
125
+ const id = String(conversationId || '').trim();
126
+ if (!id) return;
127
+ const nextRevision = Number(revision) || 0;
128
+ if (nextRevision) {
129
+ if (nextRevision < (pinRevisionByConversation.get(id) || 0)) return;
130
+ pinRevisionByConversation.set(id, nextRevision);
131
+ }
132
+ const normalized = normalizePins(pins);
133
+ // The list arrives with every conversation load, the live poll included: an
134
+ // unchanged one must not redraw the open modal under the user's finger.
135
+ if (JSON.stringify(normalized) === JSON.stringify(getConversationPins(id))) return;
136
+ if (normalized.length) pinsByConversation.set(id, normalized);
137
+ else pinsByConversation.delete(id);
138
+ if (id === currentConversationId) syncCurrentConversationPins();
139
+ }
140
+
141
+ export function setPinsConversation(conversationId) {
142
+ const id = String(conversationId || '').trim() || null;
143
+ if (id === currentConversationId) return;
144
+ currentConversationId = id;
145
+ // The conversation was closed (deleted, new chat): its list goes with it.
146
+ if (!id && summaryModalState.kind === PINNED_MESSAGES_MODAL_KIND) closeSummaryModal();
147
+ syncCurrentConversationPins();
148
+ }
149
+
150
+ export function openPinnedMessagesModal() {
151
+ if (IS_SHARED_VIEW) return;
152
+ openSummaryModal(pinnedModalContent());
153
+ }
154
+
155
+ function setPinButtonBusy(messageId, busy, pinned) {
156
+ const button = document.querySelector(`.msg-pin-btn[data-message-id="${CSS.escape(messageId)}"]`);
157
+ if (!button) return;
158
+ button.disabled = busy;
159
+ button.textContent = busy
160
+ ? (pinned ? 'Unpinning…' : 'Pinning…')
161
+ : (pinned ? 'Unpin' : 'Pin');
162
+ }
163
+
164
+ // `pinned` is the state the control showed when it was pressed; the request
165
+ // asks for the opposite. The answer carries the conversation's whole pin list,
166
+ // stored under that conversation's id: if the user has switched conversations
167
+ // meanwhile, the open one is not touched.
168
+ export async function toggleMessagePin(conversationId, messageId, pinned) {
169
+ const conversationKey = String(conversationId || '').trim();
170
+ const targetMessageId = String(messageId || '').trim();
171
+ if (!conversationKey || !targetMessageId) return;
172
+ const flightKey = `${conversationKey}:${targetMessageId}`;
173
+ if (pinTogglesInFlight.has(flightKey)) return;
174
+
175
+ pinTogglesInFlight.add(flightKey);
176
+ setPinButtonBusy(targetMessageId, true, pinned);
177
+ const result = await updateMessagePin(conversationKey, targetMessageId, !pinned);
178
+ pinTogglesInFlight.delete(flightKey);
179
+ if (!result?.ok) {
180
+ if (conversationKey === currentConversationId) setPinButtonBusy(targetMessageId, false, pinned);
181
+ showTransientRelayNotice(String(result?.error || '').trim() || (pinned
182
+ ? 'Could not unpin the message.'
183
+ : 'Could not pin the message.'));
184
+ return;
185
+ }
186
+
187
+ showTransientRelayNotice(result.pinned ? 'Message pinned.' : 'Message unpinned.');
188
+ if (conversationKey === currentConversationId) setPinButtonBusy(targetMessageId, false, result.pinned === true);
189
+ setConversationPins(conversationKey, result.pins, result.pinsRevision);
190
+ }
191
+
192
+ function isMessageOnPage(messageId) {
193
+ return !!document.querySelector(`#messages .msg[data-message-id="${CSS.escape(messageId)}"]`);
194
+ }
195
+
196
+ // On the page already: scroll there. Otherwise the path message search takes:
197
+ // the relay answers with the messages around the target and the history
198
+ // loaders page on from that window in both directions.
199
+ export async function jumpToPinnedMessage(messageId) {
200
+ const targetMessageId = String(messageId || '').trim();
201
+ const conversationId = currentConversationId;
202
+ if (!targetMessageId || !conversationId) return;
203
+ closeSummaryModal();
204
+ if (handlers.focusMessage?.(targetMessageId)) return;
205
+
206
+ await handlers.openConversation?.(conversationId, {
207
+ aroundMessageId: targetMessageId,
208
+ focusMessageId: targetMessageId,
209
+ });
210
+ if (currentConversationId !== conversationId || isMessageOnPage(targetMessageId)) return;
211
+ showTransientRelayNotice('That message is no longer in this conversation.');
212
+ // A message the relay no longer has answers with an empty window; put the
213
+ // end of the conversation back instead of leaving the page blank.
214
+ if (!document.querySelector('#messages .msg[data-message-id]')) {
215
+ await handlers.openConversation?.(conversationId);
216
+ }
217
+ }
218
+
219
+ function handlePinnedModalClick(event) {
220
+ if (summaryModalState.kind !== PINNED_MESSAGES_MODAL_KIND) return;
221
+ const target = event.target instanceof Element ? event.target : null;
222
+ const unpinBtn = target?.closest('[data-pin-unpin]');
223
+ if (unpinBtn) {
224
+ event.preventDefault();
225
+ void toggleMessagePin(currentConversationId, unpinBtn.getAttribute('data-pin-unpin'), true);
226
+ return;
227
+ }
228
+ const jumpBtn = target?.closest('[data-pin-jump]');
229
+ if (jumpBtn) {
230
+ event.preventDefault();
231
+ void jumpToPinnedMessage(jumpBtn.getAttribute('data-pin-jump'));
232
+ }
233
+ }
234
+
235
+ export function initPinnedMessagesView(nextHandlers = {}) {
236
+ handlers = { ...handlers, ...nextHandlers };
237
+ const button = document.getElementById('pinned-messages-btn');
238
+ if (button && button.dataset.bound !== '1') {
239
+ button.dataset.bound = '1';
240
+ button.addEventListener('click', (event) => {
241
+ event.preventDefault();
242
+ openPinnedMessagesModal();
243
+ });
244
+ }
245
+ // #summary-modal-body outlives every modal, so it is bound exactly once; the
246
+ // handler no-ops unless the pin list is the modal currently mounted.
247
+ const modalBody = document.getElementById('summary-modal-body');
248
+ if (modalBody && !modalBodyBound) {
249
+ modalBodyBound = true;
250
+ modalBody.addEventListener('click', handlePinnedModalClick);
251
+ }
252
+ syncPinnedHeaderButton();
253
+ }
@@ -25,7 +25,7 @@ function matchesDisplayMode(query) {
25
25
  }
26
26
  }
27
27
 
28
- function isInstalledAppMode() {
28
+ export function isInstalledAppMode() {
29
29
  const standalone = matchesDisplayMode('(display-mode: standalone)');
30
30
  const minimalUi = matchesDisplayMode('(display-mode: minimal-ui)');
31
31
  const launchedFromAndroidApp = String(document.referrer || '').startsWith('android-app://');
@@ -9,7 +9,9 @@
9
9
  * A managed-but-disconnected tunnel stays green rather than going grey: the
10
10
  * relay itself is still reachable, and grey would claim otherwise. The tooltip
11
11
  * carries the bad news instead. Note this state is only ever visible locally —
12
- * a remote browser whose tunnel is down cannot load the page at all.
12
+ * a remote browser whose tunnel is down cannot load the page at all. A tunnel
13
+ * that never started because cloudflared is not installed reads "off" there,
14
+ * followed by the relay's sentence on how to install it.
13
15
  *
14
16
  * `tone` is the same state as `className`, minus the dot's CSS baggage. The
15
17
  * mobile burger toggle dyes its bars from it, because the dot itself sits in
@@ -24,6 +26,7 @@ function normalizeTunnel(tunnel) {
24
26
  if (!managed) return null;
25
27
  return {
26
28
  connected: tunnel.connected === true,
29
+ binaryMissing: tunnel.binaryMissing === true,
27
30
  lastError: String(tunnel.lastError || '').trim(),
28
31
  };
29
32
  }
@@ -56,6 +59,10 @@ export function resolveRelayDotState({
56
59
  let reach = 'Web relay reachable';
57
60
  if (tunnelled) {
58
61
  reach = 'Web relay reachable via Cloudflare Tunnel';
62
+ } else if (tunnel?.binaryMissing) {
63
+ reach = tunnel.lastError
64
+ ? `Web relay reachable; Cloudflare tunnel off: ${tunnel.lastError}`
65
+ : 'Web relay reachable; Cloudflare tunnel off: cloudflared is not installed';
59
66
  } else if (tunnel) {
60
67
  reach = tunnel.lastError
61
68
  ? `Web relay reachable; Cloudflare tunnel disconnected (${tunnel.lastError})`
@@ -1,3 +1,4 @@
1
+ import { versionedFileHref } from './file-version.mjs';
1
2
  import {
2
3
  BASE,
3
4
  escHtml,
@@ -116,10 +117,15 @@ export function normalizeDriveBrowserPath(rawPath) {
116
117
  return rest ? `${drive}/${rest}` : drive;
117
118
  }
118
119
 
119
- export function driveFileHrefFromPath(rawPath) {
120
+ /**
121
+ * `version` is the file's version token when the caller knows it (a preview
122
+ * carries one): the address then changes with the file, so an older copy
123
+ * cannot be served for it. The server ignores the parameter.
124
+ */
125
+ export function driveFileHrefFromPath(rawPath, version = '') {
120
126
  const normalized = normalizeDriveBrowserPath(rawPath);
121
127
  if (!normalized) return '';
122
- return `${BASE}/api/drives/file?path=${encodeURIComponent(normalized)}`;
128
+ return versionedFileHref(`${BASE}/api/drives/file?path=${encodeURIComponent(normalized)}`, version);
123
129
  }
124
130
 
125
131
  /**
@@ -44,6 +44,7 @@ import {
44
44
  } from './ask-user-view.js';
45
45
  import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-board-view.js';
46
46
  import { setConversationBackgroundTasks } from './background-tasks-view.mjs';
47
+ import { setConversationPins } from './pinned-messages-view.mjs';
47
48
  import { setPreviews } from './preview-cards.mjs';
48
49
  import { setRemoteRelaysSnapshot } from './remote-relays-store.mjs';
49
50
  import { REMOTE_RELAY_SOCKET_EVENT } from './remote-relay-shared.mjs';
@@ -545,6 +546,11 @@ export async function connectSocket(overrideDeps) {
545
546
  socket.on('background_tasks', ({ conversationId, tasks }) => {
546
547
  setConversationBackgroundTasks(conversationId, tasks);
547
548
  });
549
+ // A message was pinned or unpinned (any client): the conversation's whole
550
+ // pin list, replacing what this page had.
551
+ socket.on('conversation_pins_updated', ({ conversationId, pins, revision }) => {
552
+ setConversationPins(conversationId, pins, revision);
553
+ });
548
554
  // REPLACE semantics for the whole set: the preview registry is relay-owned
549
555
  // and global, so every change ships the full list.
550
556
  socket.on('previews', ({ previews }) => {
@@ -133,6 +133,8 @@ export let filePreviewState = {
133
133
  preload: 'metadata',
134
134
  autoplay: false,
135
135
  },
136
+ // The files the viewer can step through (see viewer-gallery.mjs), or null.
137
+ gallery: null,
136
138
  };
137
139
  // Toolbar filters are sticky per browser (all default off); the toggles in
138
140
  // attachments-view.js are the only writers.
@@ -1418,6 +1420,9 @@ export function renderSummaryModalContent({ title, subtitle = '', bodyHtml = '',
1418
1420
  runSummaryModalLeave(String(kind || '').trim());
1419
1421
  summaryModalState.kind = String(kind || '').trim();
1420
1422
  summaryModalState.refresh = typeof refresh === 'function' ? refresh : null;
1423
+ // Lets the stylesheet tell one kind of this shared modal from another.
1424
+ const modalEl = document.getElementById('summary-modal');
1425
+ if (modalEl?.dataset) modalEl.dataset.kind = summaryModalState.kind;
1421
1426
 
1422
1427
  const titleEl = document.getElementById('summary-modal-title');
1423
1428
  const subtitleEl = document.getElementById('summary-modal-subtitle');
@@ -1445,6 +1450,7 @@ export function closeSummaryModal() {
1445
1450
  const modal = document.getElementById('summary-modal');
1446
1451
  modal?.classList.remove('visible');
1447
1452
  modal?.setAttribute('aria-hidden', 'true');
1453
+ if (modal?.dataset) delete modal.dataset.kind;
1448
1454
  const titleEl = document.getElementById('summary-modal-title');
1449
1455
  const subtitleEl = document.getElementById('summary-modal-subtitle');
1450
1456
  const bodyEl = document.getElementById('summary-modal-body');
@@ -0,0 +1,59 @@
1
+ // The media of a conversation as a gallery: every image, video and audio
2
+ // attachment and every picture embedded in a bubble, in the order they
3
+ // stand in the conversation, open at the one that was tapped.
4
+ //
5
+ // It is read from the rendered messages rather than from message records:
6
+ // an embedded picture only becomes an address the viewer can open once the
7
+ // bubble is rendered (local paths are rewritten then), and the rendered
8
+ // order is the conversation's order.
9
+
10
+ export const CHAT_MEDIA_SELECTOR = '.msg-attachment[data-media-url], .msg-bubble img';
11
+
12
+ function baseName(filePath) {
13
+ return String(filePath || '').split(/[\\/]/).filter(Boolean).pop() || 'image';
14
+ }
15
+
16
+ /** The viewer item behind a media node, or null for a node that is none. */
17
+ export function chatMediaItemFromNode(node, { parseAppFileHref } = {}) {
18
+ if (!node) return null;
19
+ const attachment = node.closest?.('.msg-attachment[data-media-url]');
20
+ if (attachment) {
21
+ return {
22
+ source: 'upload',
23
+ href: String(attachment.dataset.mediaUrl || ''),
24
+ name: String(attachment.dataset.mediaName || 'attachment'),
25
+ mime: String(attachment.dataset.mediaType || ''),
26
+ };
27
+ }
28
+ if (String(node.tagName || '').toUpperCase() !== 'IMG') return null;
29
+ const src = String(node.getAttribute('src') || '').trim();
30
+ if (!src) return null;
31
+ const target = parseAppFileHref?.(src) || null;
32
+ if (target?.kind === 'drive') return { source: 'drives', path: target.path, name: baseName(target.path) };
33
+ if (target?.kind === 'workspace') return { source: 'workspace', path: target.path, name: baseName(target.path) };
34
+ // A data: thumbnail or a picture from the web: shown as it is.
35
+ const dataMime = /^data:(image\/[a-z0-9.+-]+)/i.exec(src)?.[1] || '';
36
+ return { source: 'upload', href: src, name: String(node.getAttribute('alt') || 'image'), mime: dataMime || 'image/*' };
37
+ }
38
+
39
+ /**
40
+ * The gallery of `root`'s media open at `clickedNode`, or null when there
41
+ * is nothing else to move to (the viewer then shows the one file).
42
+ * Embedded pictures can be left out (the shared view cannot fetch them).
43
+ */
44
+ export function chatMediaGallery(root, clickedNode, { includeEmbedded = true, parseAppFileHref } = {}) {
45
+ if (!root || !clickedNode) return null;
46
+ const clicked = clickedNode.closest?.('.msg-attachment[data-media-url]') || clickedNode;
47
+ const items = [];
48
+ let index = -1;
49
+ for (const node of root.querySelectorAll(CHAT_MEDIA_SELECTOR)) {
50
+ const isAttachment = node.matches('.msg-attachment');
51
+ if (!isAttachment && (node.closest('.msg-attachment') || !includeEmbedded)) continue;
52
+ const item = chatMediaItemFromNode(node, { parseAppFileHref });
53
+ if (!item) continue;
54
+ if (node === clicked) index = items.length;
55
+ items.push(item);
56
+ }
57
+ if (index === -1 || items.length < 2) return null;
58
+ return { source: 'chat', items, index };
59
+ }
@@ -0,0 +1,59 @@
1
+ // When the fullscreen viewer's hovering controls are shown.
2
+ //
3
+ // A single tap on the content toggles them. While a video or audio file
4
+ // plays they also fade out on their own after a moment, as players do; any
5
+ // interaction brings them back and starts that clock again. Pausing shows
6
+ // them and stops the clock, so a paused player is never left bare. Images
7
+ // and text have no clock: their controls stay until the next tap.
8
+
9
+ export const CONTROLS_AUTO_HIDE_MS = 3000;
10
+
11
+ export function createControlsState({
12
+ autoHideMs = CONTROLS_AUTO_HIDE_MS,
13
+ onChange = () => {},
14
+ setTimeoutImpl = setTimeout,
15
+ clearTimeoutImpl = clearTimeout,
16
+ } = {}) {
17
+ const state = { visible: true, playing: false, timer: null };
18
+
19
+ const stopClock = () => {
20
+ if (state.timer === null) return;
21
+ clearTimeoutImpl(state.timer);
22
+ state.timer = null;
23
+ };
24
+
25
+ const set = (visible) => {
26
+ if (state.visible === visible) return;
27
+ state.visible = visible;
28
+ onChange(visible);
29
+ };
30
+
31
+ // The clock runs only while the controls are up and media plays.
32
+ const armClock = () => {
33
+ stopClock();
34
+ if (!state.playing || !state.visible) return;
35
+ state.timer = setTimeoutImpl(() => {
36
+ state.timer = null;
37
+ set(false);
38
+ }, autoHideMs);
39
+ };
40
+
41
+ return {
42
+ get visible() { return state.visible; },
43
+ get playing() { return state.playing; },
44
+ show() { set(true); armClock(); },
45
+ hide() { stopClock(); set(false); },
46
+ toggle() { if (state.visible) this.hide(); else this.show(); },
47
+ /** The user touched a control or moved the mouse: keep the controls a while longer. */
48
+ interaction() { if (state.visible) armClock(); else this.show(); },
49
+ setPlaying(playing) {
50
+ const next = playing === true;
51
+ if (state.playing === next) return;
52
+ state.playing = next;
53
+ if (next) armClock();
54
+ else { stopClock(); set(true); }
55
+ },
56
+ reset() { stopClock(); state.playing = false; set(true); },
57
+ dispose() { stopClock(); },
58
+ };
59
+ }
@@ -0,0 +1,46 @@
1
+ // The files a viewer can move between without being closed: the files of
2
+ // the folder a file was opened from, in the order the browser shows them.
3
+ // An index into that list is the open file; neighbours are the files on
4
+ // either side, and the list never wraps.
5
+
6
+ /**
7
+ * A gallery over a folder listing (`children` as the repo browser holds
8
+ * them; directories are skipped). `null` when the opened file is not in the
9
+ * list or has no neighbours — a viewer without a gallery shows one file.
10
+ */
11
+ export function galleryFromFolder(children, openedPath, { source = 'workspace' } = {}) {
12
+ const items = (Array.isArray(children) ? children : [])
13
+ .filter((child) => child && child.type !== 'dir' && String(child.path || ''))
14
+ .map((child) => ({ path: String(child.path), name: String(child.name || '') }));
15
+ const index = items.findIndex((item) => item.path === String(openedPath || ''));
16
+ if (index === -1 || items.length < 2) return null;
17
+ return { source: String(source || 'workspace'), items, index };
18
+ }
19
+
20
+ /** The index `delta` steps away, or -1 past either end. */
21
+ export function galleryNeighborIndex(gallery, delta) {
22
+ if (!gallery || !Array.isArray(gallery.items)) return -1;
23
+ const next = Number(gallery.index) + Number(delta);
24
+ return Number.isInteger(next) && next >= 0 && next < gallery.items.length ? next : -1;
25
+ }
26
+
27
+ /** The same gallery, open at another index. */
28
+ export function galleryAt(gallery, index) {
29
+ if (!gallery || index < 0 || index >= gallery.items.length) return null;
30
+ return { ...gallery, index };
31
+ }
32
+
33
+ export function galleryPositionLabel(gallery) {
34
+ if (!gallery || !Array.isArray(gallery.items) || !gallery.items.length) return '';
35
+ return `${gallery.index + 1} / ${gallery.items.length}`;
36
+ }
37
+
38
+ /** The items on either side of the open one, nearest first. */
39
+ export function galleryNeighbors(gallery) {
40
+ const out = [];
41
+ for (const delta of [1, -1]) {
42
+ const index = galleryNeighborIndex(gallery, delta);
43
+ if (index !== -1) out.push(gallery.items[index]);
44
+ }
45
+ return out;
46
+ }
@@ -0,0 +1,105 @@
1
+ // A swipe across the viewer: one pointer, moving mostly along one axis.
2
+ //
3
+ // The axis is decided once the pointer has moved a little, and stays: a
4
+ // drag that started sideways is a sideways drag even if it wanders. Each
5
+ // axis can be switched off for the moment (sideways while an image is
6
+ // zoomed in, since that is a pan; downwards while text is scrolled). The
7
+ // content follows the finger through `onMove`; on release the drag is a
8
+ // swipe when it went far enough or fast enough, else it springs back.
9
+
10
+ export const SWIPE_LOCK_PX = 12;
11
+ export const SWIPE_THRESHOLD_PX = 80;
12
+ export const SWIPE_FLICK_PX_PER_MS = 0.45;
13
+ export const SWIPE_MIN_FLICK_PX = 30;
14
+
15
+ export function createSwipeRecognizer({
16
+ onStart = () => {},
17
+ onMove = () => {},
18
+ onEnd = () => {},
19
+ canStart = () => true,
20
+ horizontalEnabled = () => true,
21
+ verticalEnabled = () => false,
22
+ lockPx = SWIPE_LOCK_PX,
23
+ thresholdPx = SWIPE_THRESHOLD_PX,
24
+ flickPxPerMs = SWIPE_FLICK_PX_PER_MS,
25
+ minFlickPx = SWIPE_MIN_FLICK_PX,
26
+ } = {}) {
27
+ const state = { pointer: null, pointers: 0 };
28
+
29
+ const finish = (event, { cancelled = false } = {}) => {
30
+ const p = state.pointer;
31
+ state.pointer = null;
32
+ if (!p || !p.axis) return;
33
+ const dx = event ? event.clientX - p.x : 0;
34
+ const dy = event ? event.clientY - p.y : 0;
35
+ const along = p.axis === 'x' ? dx : dy;
36
+ // Velocity from the last two samples; a finger that paused before
37
+ // lifting has none.
38
+ const dt = event ? event.timeStamp - p.lastAt : Infinity;
39
+ const velocity = dt > 0 && dt < 100 && Number.isFinite(dt)
40
+ ? ((p.axis === 'x' ? event.clientX - p.lastX : event.clientY - p.lastY) / dt)
41
+ : 0;
42
+ const farEnough = Math.abs(along) > thresholdPx;
43
+ const flicked = Math.abs(along) > minFlickPx && Math.abs(velocity) > flickPxPerMs
44
+ && Math.sign(velocity) === Math.sign(along);
45
+ let swipe = null;
46
+ if (!cancelled && (farEnough || flicked)) {
47
+ swipe = p.axis === 'x' ? (along < 0 ? 'left' : 'right') : (along < 0 ? 'up' : 'down');
48
+ }
49
+ onEnd({ axis: p.axis, dx, dy, swipe, cancelled });
50
+ };
51
+
52
+ return {
53
+ handle(event) {
54
+ const type = String(event?.type || '');
55
+ if (type === 'pointerdown') {
56
+ state.pointers += 1;
57
+ if (state.pointers > 1) {
58
+ // A second finger: a pinch, not a swipe.
59
+ finish(null, { cancelled: true });
60
+ return;
61
+ }
62
+ if (!canStart(event)) return;
63
+ state.pointer = {
64
+ id: event.pointerId,
65
+ x: event.clientX, y: event.clientY,
66
+ lastX: event.clientX, lastY: event.clientY, lastAt: event.timeStamp,
67
+ axis: null,
68
+ };
69
+ return;
70
+ }
71
+ const p = state.pointer;
72
+ if (type === 'pointermove') {
73
+ if (!p || event.pointerId !== p.id) return;
74
+ const dx = event.clientX - p.x;
75
+ const dy = event.clientY - p.y;
76
+ if (!p.axis) {
77
+ if (Math.max(Math.abs(dx), Math.abs(dy)) < lockPx) return;
78
+ const axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
79
+ if ((axis === 'x' && !horizontalEnabled()) || (axis === 'y' && !verticalEnabled())) {
80
+ state.pointer = null;
81
+ return;
82
+ }
83
+ p.axis = axis;
84
+ onStart({ axis });
85
+ }
86
+ p.lastX = event.clientX;
87
+ p.lastY = event.clientY;
88
+ p.lastAt = event.timeStamp;
89
+ onMove({ axis: p.axis, dx, dy });
90
+ return;
91
+ }
92
+ if (type === 'pointerup' || type === 'pointercancel') {
93
+ state.pointers = Math.max(0, state.pointers - 1);
94
+ if (!p || event.pointerId !== p.id) return;
95
+ finish(event, { cancelled: type === 'pointercancel' });
96
+ }
97
+ },
98
+ /** True while a swipe is being tracked along an axis. */
99
+ get active() { return Boolean(state.pointer?.axis); },
100
+ cancel() {
101
+ finish(null, { cancelled: true });
102
+ state.pointers = 0;
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,43 @@
1
+ // The system back gesture closes the viewer instead of leaving the app.
2
+ //
3
+ // Opening the viewer pushes one history entry marked as ours; back pops it
4
+ // and the pop closes the viewer. Closing by any other means (the × button,
5
+ // Escape, a swipe down) pops that entry itself so history is left as it
6
+ // was, and the pop that follows is recognised as our own and ignored. The
7
+ // address never changes: the entry carries only its state.
8
+
9
+ export const VIEWER_HISTORY_MARK = 'oarFileViewer';
10
+ const OWN_POP_WINDOW_MS = 1500;
11
+
12
+ export function createViewerHistory({ history, onBack, now = () => Date.now() } = {}) {
13
+ const state = { pushed: false, ownPopAt: 0 };
14
+ return {
15
+ get pushed() { return state.pushed; },
16
+ /** The viewer came up: one entry, however many files it then shows. */
17
+ opened() {
18
+ if (state.pushed) return;
19
+ history.pushState({ [VIEWER_HISTORY_MARK]: true }, '');
20
+ state.pushed = true;
21
+ },
22
+ /** The viewer went away by itself: take its entry back out of history. */
23
+ closed() {
24
+ if (!state.pushed) return;
25
+ state.pushed = false;
26
+ if (history.state?.[VIEWER_HISTORY_MARK]) {
27
+ state.ownPopAt = now();
28
+ history.back();
29
+ }
30
+ },
31
+ /** Feed every popstate here; the one answering our own back() is skipped. */
32
+ onPopState() {
33
+ if (state.ownPopAt && now() - state.ownPopAt < OWN_POP_WINDOW_MS) {
34
+ state.ownPopAt = 0;
35
+ return;
36
+ }
37
+ state.ownPopAt = 0;
38
+ if (!state.pushed) return;
39
+ state.pushed = false;
40
+ onBack?.();
41
+ },
42
+ };
43
+ }