@oar-sh/oar 0.9.20 → 0.9.22
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 +325 -0
- package/README.md +13 -13
- package/bin/oar.js +2 -2
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +16 -4
- package/server/claude-worker/claude-session-worker-main.mjs +18 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
- package/server/cursor-worker/cursor-session-worker.mjs +21 -2
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +10 -0
- package/server/grok-worker/grok-session-worker.mjs +22 -2
- package/server/grok-worker/grok-turn-runner.mjs +4 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +19 -0
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +11 -2
- package/server/public/app/ask-user-view.js +24 -9
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +618 -72
- package/server/public/app/chat-actions-menu-state.mjs +141 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +309 -49
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +238 -0
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +88 -81
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +328 -58
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +38 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +251 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +67 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +47 -13
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +35 -8
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2603 -1767
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +33 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +92 -15
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +117 -12
- package/server/server-runtime.mjs +367 -170
- package/server/services/about-service.mjs +22 -53
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +84 -18
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +183 -31
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/build-id.mjs +128 -0
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +31 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// "Tool lines shown while an agent works" (Settings › General ›
|
|
2
|
+
// Appearance): how many of its newest tool lines each agent of a running
|
|
3
|
+
// turn keeps in the live bubble, the main agent and every sub-agent apart.
|
|
4
|
+
// A setting of this browser, kept in localStorage like the scroll-on-send
|
|
5
|
+
// one. The slider runs from 10 to 500 by 10 and has one more step at the far
|
|
6
|
+
// end, no limit (stored as '0'). The document and the storage are passed in
|
|
7
|
+
// by the tests.
|
|
8
|
+
|
|
9
|
+
import { IN_FLIGHT_ACTIVITY_LIMIT_PER_THREAD, LIVE_ACTIVITY_CAP } from './activity-replay-state.mjs';
|
|
10
|
+
|
|
11
|
+
export const LIVE_ACTIVITY_CAP_STORAGE_KEY = 'copilot_live_activity_cap';
|
|
12
|
+
export const LIVE_ACTIVITY_CAP_SLIDER_ID = 'live-activity-cap-slider';
|
|
13
|
+
export const LIVE_ACTIVITY_CAP_VALUE_ID = 'live-activity-cap-value';
|
|
14
|
+
// One source with the relay's default and its most per thread.
|
|
15
|
+
export const LIVE_ACTIVITY_CAP_DEFAULT = LIVE_ACTIVITY_CAP;
|
|
16
|
+
export const LIVE_ACTIVITY_CAP_MIN = 10;
|
|
17
|
+
export const LIVE_ACTIVITY_CAP_MAX = IN_FLIGHT_ACTIVITY_LIMIT_PER_THREAD;
|
|
18
|
+
export const LIVE_ACTIVITY_CAP_STEP = 10;
|
|
19
|
+
/** The slider's last step: no limit. */
|
|
20
|
+
export const LIVE_ACTIVITY_CAP_UNLIMITED_POSITION = LIVE_ACTIVITY_CAP_MAX + LIVE_ACTIVITY_CAP_STEP;
|
|
21
|
+
/** The cap that keeps every line. */
|
|
22
|
+
export const LIVE_ACTIVITY_CAP_UNLIMITED = 0;
|
|
23
|
+
|
|
24
|
+
function clampToStep(value) {
|
|
25
|
+
const stepped = Math.round(value / LIVE_ACTIVITY_CAP_STEP) * LIVE_ACTIVITY_CAP_STEP;
|
|
26
|
+
return Math.min(LIVE_ACTIVITY_CAP_MAX, Math.max(LIVE_ACTIVITY_CAP_MIN, stepped));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The cap a stored value stands for: a count, or 0 for no limit. */
|
|
30
|
+
export function parseLiveActivityCap(stored) {
|
|
31
|
+
const text = String(stored ?? '').trim();
|
|
32
|
+
if (!text) return LIVE_ACTIVITY_CAP_DEFAULT;
|
|
33
|
+
const value = Number(text);
|
|
34
|
+
if (!Number.isFinite(value) || value < 0) return LIVE_ACTIVITY_CAP_DEFAULT;
|
|
35
|
+
if (value === 0) return LIVE_ACTIVITY_CAP_UNLIMITED;
|
|
36
|
+
return clampToStep(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function serializeLiveActivityCap(cap) {
|
|
40
|
+
const value = Number(cap);
|
|
41
|
+
if (!Number.isFinite(value) || value <= 0) return '0';
|
|
42
|
+
return String(clampToStep(value));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The cap at a slider position; the far end is no limit. */
|
|
46
|
+
export function liveActivityCapFromSlider(position) {
|
|
47
|
+
const value = Number(position);
|
|
48
|
+
if (!Number.isFinite(value)) return LIVE_ACTIVITY_CAP_DEFAULT;
|
|
49
|
+
if (value >= LIVE_ACTIVITY_CAP_UNLIMITED_POSITION) return LIVE_ACTIVITY_CAP_UNLIMITED;
|
|
50
|
+
return clampToStep(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sliderPositionForLiveActivityCap(cap) {
|
|
54
|
+
const value = Number(cap);
|
|
55
|
+
return Number.isFinite(value) && value > 0 ? clampToStep(value) : LIVE_ACTIVITY_CAP_UNLIMITED_POSITION;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatLiveActivityCap(cap) {
|
|
59
|
+
const value = Number(cap);
|
|
60
|
+
if (!Number.isFinite(value) || value <= 0) return 'No limit';
|
|
61
|
+
return `${clampToStep(value)} lines`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function storage() {
|
|
65
|
+
try { return globalThis.localStorage || null; } catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The cap in force: a count per agent, or 0 for no limit. */
|
|
69
|
+
export function liveActivityCap(store = storage()) {
|
|
70
|
+
try {
|
|
71
|
+
return parseLiveActivityCap(store?.getItem(LIVE_ACTIVITY_CAP_STORAGE_KEY));
|
|
72
|
+
} catch {
|
|
73
|
+
return LIVE_ACTIVITY_CAP_DEFAULT;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function showValue(doc, cap) {
|
|
78
|
+
const text = formatLiveActivityCap(cap);
|
|
79
|
+
const label = doc?.getElementById?.(LIVE_ACTIVITY_CAP_VALUE_ID);
|
|
80
|
+
if (label) label.textContent = text;
|
|
81
|
+
doc?.getElementById?.(LIVE_ACTIVITY_CAP_SLIDER_ID)?.setAttribute?.('aria-valuetext', text);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function syncLiveActivityCapSlider(doc = globalThis.document, store = storage()) {
|
|
85
|
+
const cap = liveActivityCap(store);
|
|
86
|
+
const slider = doc?.getElementById?.(LIVE_ACTIVITY_CAP_SLIDER_ID);
|
|
87
|
+
if (slider) slider.value = String(sliderPositionForLiveActivityCap(cap));
|
|
88
|
+
showValue(doc, cap);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** While the thumb moves: the label follows, nothing is saved. */
|
|
92
|
+
export function previewLiveActivityCap(position, { doc = globalThis.document } = {}) {
|
|
93
|
+
showValue(doc, liveActivityCapFromSlider(position));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Saves the slider's value; returns the cap. */
|
|
97
|
+
export function updateLiveActivityCap(position, { doc = globalThis.document, store = storage(), onChange = null } = {}) {
|
|
98
|
+
const cap = liveActivityCapFromSlider(position);
|
|
99
|
+
try { store?.setItem(LIVE_ACTIVITY_CAP_STORAGE_KEY, serializeLiveActivityCap(cap)); } catch {}
|
|
100
|
+
syncLiveActivityCapSlider(doc, store);
|
|
101
|
+
if (typeof onChange === 'function') onChange(cap);
|
|
102
|
+
return cap;
|
|
103
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// A phone turned sideways: keeps :root[data-phone-landscape] in step
|
|
2
|
+
// (phone-landscape.mjs) and runs its typing mode (phone-typing-state.mjs).
|
|
3
|
+
//
|
|
4
|
+
// While the keyboard is up (body.phone-typing):
|
|
5
|
+
// - the label row folds into a floating ⚙ (#composer-float-settings) that
|
|
6
|
+
// opens the ⚙ panel and leaves the focus in the message box;
|
|
7
|
+
// - the background-task line folds into a floating 🤖
|
|
8
|
+
// (#background-tasks-float), shown only while the panel would show; a tap
|
|
9
|
+
// shows the panel as an overlay above the composer
|
|
10
|
+
// (body.phone-typing-tasks-open), a second tap or a tap elsewhere hides it.
|
|
11
|
+
// Both buttons sit on the composer's top edge on the right (index.html).
|
|
12
|
+
// Everything comes back when the keyboard goes down; an open ⚙ panel holds
|
|
13
|
+
// typing mode until it closes (phone-typing-state.mjs).
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
closeComposerSettings,
|
|
17
|
+
isComposerSettingsOpen,
|
|
18
|
+
openComposerSettings,
|
|
19
|
+
repositionComposerSettings,
|
|
20
|
+
} from './composer-settings-ui.js';
|
|
21
|
+
import { PHONE_LANDSCAPE_CHANGE_EVENT, syncPhoneLandscapeAttribute } from './phone-landscape.mjs';
|
|
22
|
+
import { createPhoneTypingBaseline, resolvePhoneTyping } from './phone-typing-state.mjs';
|
|
23
|
+
|
|
24
|
+
export const PHONE_TYPING_CLASS = 'phone-typing';
|
|
25
|
+
export const PHONE_TYPING_TASKS_OPEN_CLASS = 'phone-typing-tasks-open';
|
|
26
|
+
|
|
27
|
+
let deps = {};
|
|
28
|
+
let bound = false;
|
|
29
|
+
let typing = false;
|
|
30
|
+
let taskOverlayOpen = false;
|
|
31
|
+
let baseline = createPhoneTypingBaseline();
|
|
32
|
+
|
|
33
|
+
function el(id) {
|
|
34
|
+
return typeof document === 'undefined' ? null : document.getElementById(id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isShared() {
|
|
38
|
+
try {
|
|
39
|
+
if (deps.isSharedReaderMode?.() === true) return true;
|
|
40
|
+
} catch {}
|
|
41
|
+
return document.body?.classList?.contains('shared-reader-mode') === true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The visible height, and the tallest seen at this width (the layout height
|
|
45
|
+
// with no keyboard under it; a turn of the phone starts from the old one).
|
|
46
|
+
function measureViewport() {
|
|
47
|
+
const innerHeight = Number(window.innerHeight) || 0;
|
|
48
|
+
const visualHeight = Number(window.visualViewport?.height) || innerHeight;
|
|
49
|
+
const layoutHeight = baseline.note(Number(window.innerWidth) || 0, Math.max(innerHeight, visualHeight));
|
|
50
|
+
return { layoutHeight, visualHeight };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function syncFloats() {
|
|
54
|
+
const gear = el('composer-float-settings');
|
|
55
|
+
if (gear) {
|
|
56
|
+
gear.hidden = !typing;
|
|
57
|
+
gear.setAttribute('aria-expanded', isComposerSettingsOpen() ? 'true' : 'false');
|
|
58
|
+
}
|
|
59
|
+
const robot = el('background-tasks-float');
|
|
60
|
+
const panel = el('background-tasks-panel');
|
|
61
|
+
const panelShows = !!panel && !panel.hidden;
|
|
62
|
+
if (taskOverlayOpen && !(typing && panelShows)) setTaskOverlayOpen(false);
|
|
63
|
+
if (robot) {
|
|
64
|
+
robot.hidden = !(typing && panelShows);
|
|
65
|
+
robot.setAttribute('aria-expanded', taskOverlayOpen ? 'true' : 'false');
|
|
66
|
+
const summary = String(el('background-tasks-summary')?.textContent || '').trim();
|
|
67
|
+
const label = summary ? `Background tasks: ${summary}` : 'Background tasks';
|
|
68
|
+
if (robot.title !== label) robot.title = label;
|
|
69
|
+
if (robot.getAttribute('aria-label') !== label) robot.setAttribute('aria-label', label);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setTaskOverlayOpen(open) {
|
|
74
|
+
taskOverlayOpen = !!open && typing;
|
|
75
|
+
document.body?.classList?.toggle(PHONE_TYPING_TASKS_OPEN_CLASS, taskOverlayOpen);
|
|
76
|
+
el('background-tasks-float')?.setAttribute('aria-expanded', taskOverlayOpen ? 'true' : 'false');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function setTyping(next) {
|
|
80
|
+
if (next === typing) return;
|
|
81
|
+
typing = next;
|
|
82
|
+
document.body?.classList?.toggle(PHONE_TYPING_CLASS, typing);
|
|
83
|
+
if (!typing) setTaskOverlayOpen(false);
|
|
84
|
+
// An open ⚙ panel moves between the label row and the floating ⚙.
|
|
85
|
+
syncFloats();
|
|
86
|
+
repositionComposerSettings();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Recomputes phone landscape and typing mode; returns whether typing mode is on. */
|
|
90
|
+
export function syncPhoneTyping() {
|
|
91
|
+
if (typeof document === 'undefined' || !document.body) return false;
|
|
92
|
+
const phoneLandscape = syncPhoneLandscapeAttribute(window);
|
|
93
|
+
const input = el('msg-input');
|
|
94
|
+
const inputFocused = !!input && document.activeElement === input;
|
|
95
|
+
const { layoutHeight, visualHeight } = measureViewport();
|
|
96
|
+
// Only the ⚙ panel holds: its selects take the focus (the keyboard goes),
|
|
97
|
+
// while a press in the task overlay keeps it (bindEvents).
|
|
98
|
+
const held = typing && isComposerSettingsOpen();
|
|
99
|
+
setTyping(!isShared() && resolvePhoneTyping({ phoneLandscape, inputFocused, layoutHeight, visualHeight, held }));
|
|
100
|
+
syncFloats();
|
|
101
|
+
return typing;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isPhoneTypingActive() {
|
|
105
|
+
return typing;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function isPhoneTypingTaskOverlayOpen() {
|
|
109
|
+
return taskOverlayOpen;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The floating 🤖's tap: shows or hides the task panel over the composer. */
|
|
113
|
+
export function togglePhoneTypingTaskOverlay() {
|
|
114
|
+
setTaskOverlayOpen(!taskOverlayOpen);
|
|
115
|
+
syncPhoneTyping();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The floating ⚙'s tap: opens the ⚙ panel, the message box keeping the focus. */
|
|
119
|
+
export function togglePhoneTypingSettings() {
|
|
120
|
+
if (isComposerSettingsOpen()) closeComposerSettings();
|
|
121
|
+
else openComposerSettings({ keepComposerFocus: true });
|
|
122
|
+
syncPhoneTyping();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function eventPath(event) {
|
|
126
|
+
return typeof event.composedPath === 'function' ? event.composedPath() : [event.target];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A press that would take the focus from the message box (and the keyboard
|
|
130
|
+
// with it) is cancelled; the click still comes.
|
|
131
|
+
function keepComposerFocus(event) {
|
|
132
|
+
if (event.button !== undefined && event.button !== 0) return;
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isTextEntry(node) {
|
|
137
|
+
return !!node && typeof node.closest === 'function'
|
|
138
|
+
&& !!node.closest('input, textarea, select, [contenteditable="true"]');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function bindEvents() {
|
|
142
|
+
if (bound) return;
|
|
143
|
+
bound = true;
|
|
144
|
+
const sync = () => { syncPhoneTyping(); };
|
|
145
|
+
const gear = el('composer-float-settings');
|
|
146
|
+
if (gear) {
|
|
147
|
+
gear.addEventListener('pointerdown', keepComposerFocus);
|
|
148
|
+
gear.addEventListener('mousedown', keepComposerFocus);
|
|
149
|
+
gear.addEventListener('click', (event) => {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
togglePhoneTypingSettings();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const robot = el('background-tasks-float');
|
|
155
|
+
if (robot) {
|
|
156
|
+
robot.addEventListener('pointerdown', keepComposerFocus);
|
|
157
|
+
robot.addEventListener('mousedown', keepComposerFocus);
|
|
158
|
+
robot.addEventListener('click', (event) => {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
togglePhoneTypingTaskOverlay();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const panel = el('background-tasks-panel');
|
|
164
|
+
if (panel) {
|
|
165
|
+
// In the overlay the summary and the rows keep the keyboard up, so the
|
|
166
|
+
// panel does not move under the finger; a text field still takes the focus.
|
|
167
|
+
const holdFocus = (event) => {
|
|
168
|
+
if (!taskOverlayOpen || isTextEntry(event.target)) return;
|
|
169
|
+
keepComposerFocus(event);
|
|
170
|
+
};
|
|
171
|
+
panel.addEventListener('pointerdown', holdFocus);
|
|
172
|
+
panel.addEventListener('mousedown', holdFocus);
|
|
173
|
+
}
|
|
174
|
+
// A press anywhere but the overlay and its 🤖 hides the overlay. Capture
|
|
175
|
+
// phase, so a control that stops the event still counts.
|
|
176
|
+
document.addEventListener('pointerdown', (event) => {
|
|
177
|
+
if (!taskOverlayOpen) return;
|
|
178
|
+
const path = eventPath(event);
|
|
179
|
+
if ((panel && path.includes(panel)) || (robot && path.includes(robot))) return;
|
|
180
|
+
setTaskOverlayOpen(false);
|
|
181
|
+
syncPhoneTyping();
|
|
182
|
+
}, true);
|
|
183
|
+
document.addEventListener('keydown', (event) => {
|
|
184
|
+
if (!taskOverlayOpen || event.key !== 'Escape') return;
|
|
185
|
+
setTaskOverlayOpen(false);
|
|
186
|
+
syncPhoneTyping();
|
|
187
|
+
}, true);
|
|
188
|
+
|
|
189
|
+
const input = el('msg-input');
|
|
190
|
+
input?.addEventListener('focus', sync);
|
|
191
|
+
input?.addEventListener('blur', sync);
|
|
192
|
+
window.addEventListener('resize', sync, { passive: true });
|
|
193
|
+
window.addEventListener('orientationchange', sync, { passive: true });
|
|
194
|
+
window.visualViewport?.addEventListener?.('resize', sync, { passive: true });
|
|
195
|
+
// store.js flips the attribute on its own viewport pass too.
|
|
196
|
+
window.addEventListener(PHONE_LANDSCAPE_CHANGE_EVENT, sync);
|
|
197
|
+
try {
|
|
198
|
+
window.matchMedia?.('(pointer: coarse)')?.addEventListener?.('change', sync);
|
|
199
|
+
} catch {}
|
|
200
|
+
if (typeof MutationObserver === 'function') {
|
|
201
|
+
// The ⚙ panel closing ends a hold; the task panel showing or going
|
|
202
|
+
// shows or hides the 🤖; the summary names it.
|
|
203
|
+
const settings = el('composer-settings');
|
|
204
|
+
if (settings) new MutationObserver(sync).observe(settings, { attributes: true, attributeFilter: ['hidden'] });
|
|
205
|
+
if (panel) new MutationObserver(() => syncFloats()).observe(panel, { attributes: true, attributeFilter: ['hidden'] });
|
|
206
|
+
const summary = el('background-tasks-summary');
|
|
207
|
+
if (summary) new MutationObserver(() => syncFloats()).observe(summary, { childList: true, characterData: true, subtree: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function initPhoneLandscape(options = {}) {
|
|
212
|
+
if (typeof document === 'undefined') return;
|
|
213
|
+
deps = options && typeof options === 'object' ? options : {};
|
|
214
|
+
bindEvents();
|
|
215
|
+
syncPhoneTyping();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function __resetPhoneLandscapeForTests() {
|
|
219
|
+
deps = {};
|
|
220
|
+
bound = false;
|
|
221
|
+
typing = false;
|
|
222
|
+
taskOverlayOpen = false;
|
|
223
|
+
baseline = createPhoneTypingBaseline();
|
|
224
|
+
document.body?.classList?.remove(PHONE_TYPING_CLASS, PHONE_TYPING_TASKS_OPEN_CLASS);
|
|
225
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// "Phone landscape": a touch phone turned sideways. Its width is often over
|
|
2
|
+
// 680px, so it gets the desktop layout; :root[data-phone-landscape] lets the
|
|
3
|
+
// stylesheet and the views treat it as the phone it is (one-line header,
|
|
4
|
+
// typing mode, no composer focus on a session pick, the list closing after a
|
|
5
|
+
// pick).
|
|
6
|
+
//
|
|
7
|
+
// The same phone as :root[data-phone-screen] (the screen's short side is
|
|
8
|
+
// 500px or less) with a coarse pointer, held in landscape. The orientation
|
|
9
|
+
// comes from the screen, never from the viewport: the on-screen keyboard
|
|
10
|
+
// shrinks the viewport (interactive-widget=resizes-content), and a portrait
|
|
11
|
+
// phone mid-typing would read as landscape.
|
|
12
|
+
|
|
13
|
+
export const PHONE_SCREEN_MAX_SHORT_SIDE_PX = 500;
|
|
14
|
+
export const PHONE_LANDSCAPE_ATTRIBUTE = 'data-phone-landscape';
|
|
15
|
+
/** Fired on window when the attribute flips. */
|
|
16
|
+
export const PHONE_LANDSCAPE_CHANGE_EVENT = 'oar:phone-landscape-change';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} input
|
|
20
|
+
* @param {boolean} [input.coarsePointer] `(pointer: coarse)` matches
|
|
21
|
+
* @param {number} [input.screenWidth] screen.width
|
|
22
|
+
* @param {number} [input.screenHeight] screen.height
|
|
23
|
+
* @param {string} [input.orientationType] screen.orientation.type, when known
|
|
24
|
+
* @param {number|null} [input.orientationAngle] window.orientation (old iOS), when known
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function isPhoneLandscape({
|
|
28
|
+
coarsePointer = false,
|
|
29
|
+
screenWidth = 0,
|
|
30
|
+
screenHeight = 0,
|
|
31
|
+
orientationType = '',
|
|
32
|
+
orientationAngle = null,
|
|
33
|
+
} = {}) {
|
|
34
|
+
if (!coarsePointer) return false;
|
|
35
|
+
const width = Number(screenWidth) || 0;
|
|
36
|
+
const height = Number(screenHeight) || 0;
|
|
37
|
+
const shortSide = Math.min(width, height);
|
|
38
|
+
if (!(shortSide > 0) || shortSide > PHONE_SCREEN_MAX_SHORT_SIDE_PX) return false;
|
|
39
|
+
const type = String(orientationType || '');
|
|
40
|
+
if (type.startsWith('landscape')) return true;
|
|
41
|
+
if (type.startsWith('portrait')) return false;
|
|
42
|
+
// iOS before 16.4 has no screen.orientation and never swaps screen.width
|
|
43
|
+
// and screen.height; its window.orientation is 90 or -90 on the side.
|
|
44
|
+
const angle = Number(orientationAngle);
|
|
45
|
+
if (orientationAngle !== null && orientationAngle !== undefined && Number.isFinite(angle)) {
|
|
46
|
+
return Math.abs(angle) === 90;
|
|
47
|
+
}
|
|
48
|
+
return width > height;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The inputs of isPhoneLandscape, read from a window. */
|
|
52
|
+
export function readPhoneLandscapeInputs(win = globalThis.window) {
|
|
53
|
+
if (!win) return {};
|
|
54
|
+
let coarsePointer = false;
|
|
55
|
+
try { coarsePointer = !!win.matchMedia?.('(pointer: coarse)')?.matches; } catch {}
|
|
56
|
+
const screenRef = win.screen || {};
|
|
57
|
+
const rawAngle = win.orientation;
|
|
58
|
+
return {
|
|
59
|
+
coarsePointer,
|
|
60
|
+
screenWidth: Number(screenRef.width) || 0,
|
|
61
|
+
screenHeight: Number(screenRef.height) || 0,
|
|
62
|
+
orientationType: String(screenRef.orientation?.type || ''),
|
|
63
|
+
orientationAngle: typeof rawAngle === 'number' ? rawAngle : null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sets or clears :root[data-phone-landscape] from the window; announces a
|
|
69
|
+
* flip with PHONE_LANDSCAPE_CHANGE_EVENT. Returns the current value.
|
|
70
|
+
*/
|
|
71
|
+
export function syncPhoneLandscapeAttribute(win = globalThis.window) {
|
|
72
|
+
const root = win?.document?.documentElement;
|
|
73
|
+
if (!root) return false;
|
|
74
|
+
const next = isPhoneLandscape(readPhoneLandscapeInputs(win));
|
|
75
|
+
const before = root.hasAttribute(PHONE_LANDSCAPE_ATTRIBUTE);
|
|
76
|
+
if (next !== before) {
|
|
77
|
+
root.toggleAttribute(PHONE_LANDSCAPE_ATTRIBUTE, next);
|
|
78
|
+
try {
|
|
79
|
+
if (typeof win.CustomEvent === 'function') {
|
|
80
|
+
win.dispatchEvent(new win.CustomEvent(PHONE_LANDSCAPE_CHANGE_EVENT, { detail: { phoneLandscape: next } }));
|
|
81
|
+
}
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
return next;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether the page is in phone landscape now (the attribute). */
|
|
88
|
+
export function isPhoneLandscapeLayout(doc = globalThis.document) {
|
|
89
|
+
return !!doc?.documentElement?.hasAttribute?.(PHONE_LANDSCAPE_ATTRIBUTE);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether a pick from the session list collapses the list. On a portrait
|
|
94
|
+
* phone the list is an overlay that openConversation closes; a phone turned
|
|
95
|
+
* sideways has the desktop's persistent list, which a pick now collapses too.
|
|
96
|
+
*/
|
|
97
|
+
export function shouldCollapseListAfterPick({ phoneLandscape = false, overlayList = false, listOpen = false } = {}) {
|
|
98
|
+
return !!phoneLandscape && !overlayList && !!listOpen;
|
|
99
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Typing mode on a phone turned sideways (body.phone-typing): while the
|
|
2
|
+
// on-screen keyboard is up, the composer's label row and the background-task
|
|
3
|
+
// line fold into two small floating buttons, so the few rows the keyboard
|
|
4
|
+
// leaves go to the transcript and the message box. DOM-free; the wiring is in
|
|
5
|
+
// phone-landscape-ui.js.
|
|
6
|
+
//
|
|
7
|
+
// The keyboard is up when the message box has the focus and the visible area
|
|
8
|
+
// is clearly shorter than the layout height. The layout height is the
|
|
9
|
+
// tallest visible height seen at the current width (composer-keyboard.mjs's
|
|
10
|
+
// baseline): on Android the keyboard shrinks the layout viewport with the
|
|
11
|
+
// visual one (interactive-widget=resizes-content), so window.innerHeight
|
|
12
|
+
// alone would never show it. A browser's URL bar coming and going moves the
|
|
13
|
+
// height by about 56px, well under the threshold. A turn of the phone seeds
|
|
14
|
+
// the new width's height from the old one (createPhoneTypingBaseline).
|
|
15
|
+
|
|
16
|
+
/** A visible area this much shorter has a keyboard under it (either test). */
|
|
17
|
+
export const PHONE_TYPING_MIN_SHRINK_PX = 150;
|
|
18
|
+
export const PHONE_TYPING_MIN_SHRINK_RATIO = 0.25;
|
|
19
|
+
|
|
20
|
+
/** Whether the visible height is clearly shorter than the layout height. */
|
|
21
|
+
export function isKeyboardShrink({ layoutHeight = 0, visualHeight = 0 } = {}) {
|
|
22
|
+
const layout = Number(layoutHeight) || 0;
|
|
23
|
+
const visual = Number(visualHeight) || 0;
|
|
24
|
+
if (!(layout > 0) || !(visual > 0)) return false;
|
|
25
|
+
const shrink = layout - visual;
|
|
26
|
+
if (shrink <= 0) return false;
|
|
27
|
+
return shrink > PHONE_TYPING_MIN_SHRINK_PX || shrink >= layout * PHONE_TYPING_MIN_SHRINK_RATIO;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The layout height a turn of the phone should give, from the tallest height
|
|
32
|
+
* seen before it (`previous`: `{ width, tallest }`), or 0 when the new width
|
|
33
|
+
* is no turn of the old one. The bars (status, URL) cost about the same
|
|
34
|
+
* either way up, so the old width less those bars:
|
|
35
|
+
* `previous.width - (newWidth - previous.tallest)`.
|
|
36
|
+
*/
|
|
37
|
+
export function rotatedLayoutHeight(previous, newWidth) {
|
|
38
|
+
const width = Math.round(Number(previous?.width) || 0);
|
|
39
|
+
const tallest = Number(previous?.tallest) || 0;
|
|
40
|
+
const next = Math.round(Number(newWidth) || 0);
|
|
41
|
+
if (!(width > 0) || !(tallest > 0) || !(next > 0) || next === width) return 0;
|
|
42
|
+
const wasPortrait = tallest > width;
|
|
43
|
+
// Upright to sideways makes the width grow, and back makes it shrink.
|
|
44
|
+
if (wasPortrait ? next <= width : next >= width) return 0;
|
|
45
|
+
const estimate = width + tallest - next;
|
|
46
|
+
if (!(estimate > 0)) return 0;
|
|
47
|
+
// The estimate must stand the other way up than before.
|
|
48
|
+
if (wasPortrait ? estimate >= next : estimate <= next) return 0;
|
|
49
|
+
return estimate;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The tallest visible height per width, like composer-keyboard.mjs's
|
|
54
|
+
* baseline, but a turn of the phone starts the new width from the old one
|
|
55
|
+
* (rotatedLayoutHeight) instead of from nothing: turned with the keyboard
|
|
56
|
+
* up, the first height seen is already the short one, and typing mode would
|
|
57
|
+
* stay off until the keyboard went down once. A height seen later that is
|
|
58
|
+
* taller still wins.
|
|
59
|
+
*/
|
|
60
|
+
export function createPhoneTypingBaseline() {
|
|
61
|
+
const tallestByWidth = new Map();
|
|
62
|
+
let last = null;
|
|
63
|
+
return {
|
|
64
|
+
note(width, height) {
|
|
65
|
+
const key = Math.round(Number(width) || 0);
|
|
66
|
+
const value = Math.max(0, Number(height) || 0);
|
|
67
|
+
let tallest = tallestByWidth.get(key) || 0;
|
|
68
|
+
if (!tallest && last && last.width !== key) tallest = rotatedLayoutHeight(last, key);
|
|
69
|
+
tallest = Math.max(tallest, value);
|
|
70
|
+
tallestByWidth.set(key, tallest);
|
|
71
|
+
last = { width: key, tallest };
|
|
72
|
+
return tallest;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Typing mode on or off.
|
|
79
|
+
*
|
|
80
|
+
* `held`: the ⚙ panel, opened in typing mode, is still open. Typing mode
|
|
81
|
+
* then stays on until it closes, so the panel does not jump from above the
|
|
82
|
+
* floating ⚙ to above the label row when a select in it takes the focus
|
|
83
|
+
* from the message box and the keyboard goes down.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} input
|
|
86
|
+
* @param {boolean} [input.phoneLandscape] :root[data-phone-landscape]
|
|
87
|
+
* @param {boolean} [input.inputFocused] #msg-input has the focus
|
|
88
|
+
* @param {number} [input.layoutHeight] the tallest visible height at this width
|
|
89
|
+
* @param {number} [input.visualHeight] window.visualViewport.height
|
|
90
|
+
* @param {boolean} [input.held] typing mode is on and the ⚙ panel is open
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function resolvePhoneTyping({
|
|
94
|
+
phoneLandscape = false,
|
|
95
|
+
inputFocused = false,
|
|
96
|
+
layoutHeight = 0,
|
|
97
|
+
visualHeight = 0,
|
|
98
|
+
held = false,
|
|
99
|
+
} = {}) {
|
|
100
|
+
if (!phoneLandscape) return false;
|
|
101
|
+
if (held) return true;
|
|
102
|
+
if (!inputFocused) return false;
|
|
103
|
+
return isKeyboardShrink({ layoutHeight, visualHeight });
|
|
104
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
summaryModalState,
|
|
17
17
|
} from './store.js';
|
|
18
18
|
import { updateMessagePin } from './api-client.js';
|
|
19
|
+
import { notifyHeaderActionsChanged } from './header-priority-actions.mjs';
|
|
19
20
|
|
|
20
21
|
export const PINNED_MESSAGES_MODAL_KIND = 'pinned-messages';
|
|
21
22
|
|
|
@@ -93,12 +94,16 @@ function syncPinnedHeaderButton() {
|
|
|
93
94
|
const button = document.getElementById('pinned-messages-btn');
|
|
94
95
|
if (!button) return;
|
|
95
96
|
const count = getConversationPins(currentConversationId).length;
|
|
97
|
+
// Available or not; whether it fits in the header or goes into ︙ is the
|
|
98
|
+
// priority+ pass's call, which reads the count from here.
|
|
96
99
|
button.hidden = IS_SHARED_VIEW || !count;
|
|
100
|
+
button.dataset.count = IS_SHARED_VIEW ? '0' : String(count);
|
|
97
101
|
const countEl = button.querySelector('.header-icon-count');
|
|
98
102
|
if (countEl) countEl.textContent = count ? String(count) : '';
|
|
99
103
|
const label = count ? `Pinned messages (${count})` : 'Pinned messages';
|
|
100
104
|
button.setAttribute('title', label);
|
|
101
105
|
button.setAttribute('aria-label', label);
|
|
106
|
+
notifyHeaderActionsChanged();
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
function pinnedModalContent() {
|
|
@@ -130,7 +130,7 @@ function renderDeviceList() {
|
|
|
130
130
|
|| (localSubscriptionEndpoint && device.endpoint === localSubscriptionEndpoint);
|
|
131
131
|
const row = document.createElement('div');
|
|
132
132
|
row.className = 'push-device-row settings-row';
|
|
133
|
-
row.style.gap = '
|
|
133
|
+
row.style.gap = 'calc(5px * var(--ui-scale))';
|
|
134
134
|
|
|
135
135
|
const info = document.createElement('div');
|
|
136
136
|
info.style.flex = '1';
|
|
@@ -140,7 +140,7 @@ function renderDeviceList() {
|
|
|
140
140
|
if (!device.preferences?.enabled) name.textContent += ' — disabled';
|
|
141
141
|
const meta = document.createElement('div');
|
|
142
142
|
meta.className = 'settings-help';
|
|
143
|
-
meta.style.marginTop = '2px';
|
|
143
|
+
meta.style.marginTop = 'calc(2px * var(--ui-scale))';
|
|
144
144
|
meta.textContent = `Last delivery: ${formatDeviceTimestamp(device.lastSuccessAt)}${device.lastError ? ` · last error: ${device.lastError}` : ''}`;
|
|
145
145
|
info.appendChild(name);
|
|
146
146
|
info.appendChild(meta);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BASE, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
|
|
2
2
|
import { loadPwaAppNameSetting, updatePwaAppNameSetting } from './api-client.js';
|
|
3
|
+
import { notifyHeaderActionsChanged } from './header-priority-actions.mjs';
|
|
3
4
|
|
|
4
5
|
const THEME_COLOR_BASE = '#0d1117';
|
|
5
6
|
const THEME_COLOR_IMMERSIVE = '#161b22';
|
|
@@ -265,31 +266,51 @@ export function updateFullscreenButton() {
|
|
|
265
266
|
const btn = document.getElementById('fullscreen-btn');
|
|
266
267
|
if (!btn) return;
|
|
267
268
|
syncInstalledAppUiState();
|
|
269
|
+
// The header's priority+ pass (header-priority-actions.mjs) places the
|
|
270
|
+
// button or its ︙ twin; this only says whether fullscreen is offered at
|
|
271
|
+
// all (data-header-available) and keeps both labels current.
|
|
272
|
+
const twin = document.getElementById('chat-menu-fullscreen');
|
|
273
|
+
const offer = (available) => {
|
|
274
|
+
const next = available ? '1' : '0';
|
|
275
|
+
btn.style.display = '';
|
|
276
|
+
if (!available) btn.hidden = true;
|
|
277
|
+
if (btn.dataset.headerAvailable !== next) {
|
|
278
|
+
btn.dataset.headerAvailable = next;
|
|
279
|
+
notifyHeaderActionsChanged();
|
|
280
|
+
}
|
|
281
|
+
};
|
|
268
282
|
if (isInstalledAppMode() || isDisplayModeFullscreen()) {
|
|
269
|
-
|
|
283
|
+
offer(false);
|
|
270
284
|
return;
|
|
271
285
|
}
|
|
272
286
|
|
|
273
287
|
const mobile = window.matchMedia('(max-width: 680px)').matches;
|
|
274
288
|
if (!mobile) {
|
|
275
|
-
|
|
289
|
+
offer(false);
|
|
276
290
|
return;
|
|
277
291
|
}
|
|
278
292
|
|
|
279
293
|
const full = !!document.fullscreenElement;
|
|
280
294
|
const supported = canToggleFullscreen();
|
|
281
|
-
|
|
295
|
+
const wasDisabled = btn.disabled;
|
|
282
296
|
btn.disabled = !supported;
|
|
297
|
+
// The ︙ twin is disabled with the button (chat-actions-menu-state.mjs reads
|
|
298
|
+
// btn.disabled) and says why.
|
|
299
|
+
if (twin) twin.title = supported ? '' : 'Fullscreen not supported on this browser';
|
|
300
|
+
if (wasDisabled !== btn.disabled) notifyHeaderActionsChanged();
|
|
283
301
|
|
|
284
302
|
if (full) {
|
|
285
303
|
btn.textContent = '⤢';
|
|
286
304
|
btn.title = 'Exit fullscreen';
|
|
305
|
+
if (twin) twin.textContent = '⤢ Exit fullscreen';
|
|
287
306
|
} else {
|
|
288
307
|
btn.textContent = '⛶';
|
|
289
308
|
btn.title = isInstalledAppMode()
|
|
290
309
|
? (supported ? 'Enter fullscreen (recommended for installed app)' : 'Fullscreen not supported on this browser')
|
|
291
310
|
: (supported ? 'Enter fullscreen' : 'Fullscreen not supported on this browser');
|
|
311
|
+
if (twin) twin.textContent = '⛶ Fullscreen';
|
|
292
312
|
}
|
|
313
|
+
offer(true);
|
|
293
314
|
}
|
|
294
315
|
|
|
295
316
|
export function initFullscreenButton() {
|