@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,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
|
-
|
|
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
|
+
}
|