@pageloop/client 0.5.0
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/LICENSE +94 -0
- package/README.md +97 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/.tsbuildinfo.preact +1 -0
- package/dist/.tsbuildinfo.react +1 -0
- package/dist/.tsbuildinfo.solid +1 -0
- package/dist/ApiClient.d.ts +121 -0
- package/dist/ApiClient.d.ts.map +1 -0
- package/dist/ApiClient.js +512 -0
- package/dist/ApiClient.js.map +1 -0
- package/dist/CommentEngine.d.ts +111 -0
- package/dist/CommentEngine.d.ts.map +1 -0
- package/dist/CommentEngine.js +277 -0
- package/dist/CommentEngine.js.map +1 -0
- package/dist/EventBus.d.ts +122 -0
- package/dist/EventBus.d.ts.map +1 -0
- package/dist/EventBus.js +34 -0
- package/dist/EventBus.js.map +1 -0
- package/dist/IdbCache.d.ts +22 -0
- package/dist/IdbCache.d.ts.map +1 -0
- package/dist/IdbCache.js +79 -0
- package/dist/IdbCache.js.map +1 -0
- package/dist/PageLoop.d.ts +424 -0
- package/dist/PageLoop.d.ts.map +1 -0
- package/dist/PageLoop.js +1092 -0
- package/dist/PageLoop.js.map +1 -0
- package/dist/PageTracker.d.ts +32 -0
- package/dist/PageTracker.d.ts.map +1 -0
- package/dist/PageTracker.js +105 -0
- package/dist/PageTracker.js.map +1 -0
- package/dist/UIRenderer.d.ts +218 -0
- package/dist/UIRenderer.d.ts.map +1 -0
- package/dist/UIRenderer.js +2 -0
- package/dist/UIRenderer.js.map +1 -0
- package/dist/auth/resolveInitialToken.d.ts +49 -0
- package/dist/auth/resolveInitialToken.d.ts.map +1 -0
- package/dist/auth/resolveInitialToken.js +97 -0
- package/dist/auth/resolveInitialToken.js.map +1 -0
- package/dist/errorCode.d.ts +12 -0
- package/dist/errorCode.d.ts.map +1 -0
- package/dist/errorCode.js +21 -0
- package/dist/errorCode.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications/BrowserNotifications.d.ts +68 -0
- package/dist/notifications/BrowserNotifications.d.ts.map +1 -0
- package/dist/notifications/BrowserNotifications.js +147 -0
- package/dist/notifications/BrowserNotifications.js.map +1 -0
- package/dist/preact/index.d.ts +37 -0
- package/dist/preact/index.d.ts.map +1 -0
- package/dist/preact/index.js +150 -0
- package/dist/preact/index.js.map +1 -0
- package/dist/preact/style.css +12 -0
- package/dist/react/index.d.ts +52 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +165 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/style.css +12 -0
- package/dist/safeStorage.d.ts +26 -0
- package/dist/safeStorage.d.ts.map +1 -0
- package/dist/safeStorage.js +78 -0
- package/dist/safeStorage.js.map +1 -0
- package/dist/solid/index.d.ts +40 -0
- package/dist/solid/index.d.ts.map +1 -0
- package/dist/solid/index.jsx +134 -0
- package/dist/solid/index.jsx.map +1 -0
- package/dist/solid/style.css +12 -0
- package/package.json +85 -0
package/dist/PageLoop.js
ADDED
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
import { ERROR_CODES } from '@pageloop/shared';
|
|
2
|
+
import { errorCode } from './errorCode.js';
|
|
3
|
+
import { ApiClient } from './ApiClient.js';
|
|
4
|
+
import { CommentEngine } from './CommentEngine.js';
|
|
5
|
+
import { PageTracker } from './PageTracker.js';
|
|
6
|
+
import { EventBus } from './EventBus.js';
|
|
7
|
+
import { IdbCache } from './IdbCache.js';
|
|
8
|
+
import { resolveInitialToken, SESSION_TOKEN_KEY } from './auth/resolveInitialToken.js';
|
|
9
|
+
import { BrowserNotifications } from './notifications/BrowserNotifications.js';
|
|
10
|
+
import { safeStorage } from './safeStorage.js';
|
|
11
|
+
/**
|
|
12
|
+
* localStorage key for the cached `ProjectSettings` blob. Keyed per
|
|
13
|
+
* project so multiple PageLoop widgets on the same origin don't
|
|
14
|
+
* stomp each other. The cache is read synchronously on init — that's
|
|
15
|
+
* the whole point: we paint the toolbar + sidebar in their final
|
|
16
|
+
* position before bootstrap returns.
|
|
17
|
+
*/
|
|
18
|
+
const SETTINGS_CACHE_PREFIX = 'pl_config:';
|
|
19
|
+
const PROJECT_CACHE_PREFIX = 'pl_project:';
|
|
20
|
+
const USER_SESSION_CACHE_PREFIX = 'pl_widget_user:';
|
|
21
|
+
/** Min gap between focus-driven session re-resolves (see `onVisible`). */
|
|
22
|
+
const SESSION_REFRESH_THROTTLE_MS = 60_000;
|
|
23
|
+
function readCachedSettings(projectId) {
|
|
24
|
+
// safeStorage swallows SecurityError / private-mode throws + falls back
|
|
25
|
+
// to an in-memory map; the try/catch here is purely for the JSON.parse.
|
|
26
|
+
const raw = safeStorage.getItem(SETTINGS_CACHE_PREFIX + projectId);
|
|
27
|
+
if (!raw)
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function writeCachedSettings(projectId, settings) {
|
|
37
|
+
safeStorage.setItem(SETTINGS_CACHE_PREFIX + projectId, JSON.stringify(settings));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Cache the full Project object so the renderer can mount on first
|
|
41
|
+
* paint without waiting for the bootstrap round-trip. Validated on
|
|
42
|
+
* read — a partial / malformed cache returns null so we fall back to
|
|
43
|
+
* the slow-path mount-after-bootstrap.
|
|
44
|
+
*
|
|
45
|
+
* Separate from the settings cache because settings get reconciled
|
|
46
|
+
* key-by-key (and emit `config:changed` for hot reload), whereas the
|
|
47
|
+
* Project object is a static identity blob — name, slug, ids — that
|
|
48
|
+
* the renderer reads through the `project()` getter.
|
|
49
|
+
*/
|
|
50
|
+
function readCachedProject(projectId) {
|
|
51
|
+
const raw = safeStorage.getItem(PROJECT_CACHE_PREFIX + projectId);
|
|
52
|
+
if (!raw)
|
|
53
|
+
return null;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
// Defensive: a malformed cache (truncated, schema-drift) would
|
|
57
|
+
// crash the renderer's getters on optimistic paint. Require the
|
|
58
|
+
// minimum keys the mount path reads before trusting it.
|
|
59
|
+
if (!parsed.id || !parsed.slug)
|
|
60
|
+
return null;
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function writeCachedProject(projectId, project) {
|
|
68
|
+
safeStorage.setItem(PROJECT_CACHE_PREFIX + projectId, JSON.stringify(project));
|
|
69
|
+
}
|
|
70
|
+
function readCachedUserSession(projectId) {
|
|
71
|
+
const raw = safeStorage.getItem(USER_SESSION_CACHE_PREFIX + projectId);
|
|
72
|
+
if (!raw)
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(raw);
|
|
76
|
+
// Defensive: require the role to be set; a malformed blob (truncated
|
|
77
|
+
// JSON, schema drift) returns null so the constructor falls back to
|
|
78
|
+
// the anonymous default rather than rendering with garbage.
|
|
79
|
+
if (!parsed || typeof parsed.role !== 'string')
|
|
80
|
+
return null;
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function writeCachedUserSession(projectId, value) {
|
|
88
|
+
safeStorage.setItem(USER_SESSION_CACHE_PREFIX + projectId, JSON.stringify(value));
|
|
89
|
+
}
|
|
90
|
+
function clearCachedUserSession(projectId) {
|
|
91
|
+
safeStorage.removeItem(USER_SESSION_CACHE_PREFIX + projectId);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Public entry point. Composes ApiClient + PageTracker + CommentEngine + the
|
|
95
|
+
* provided UIRenderer, and runs the bootstrap → mount → subscribe lifecycle.
|
|
96
|
+
*
|
|
97
|
+
* Each framework wrapper (vanilla, react, preact, solid) instantiates this
|
|
98
|
+
* class internally and wires its own renderer into `deps.renderer`.
|
|
99
|
+
*/
|
|
100
|
+
export class PageLoop {
|
|
101
|
+
config;
|
|
102
|
+
bus;
|
|
103
|
+
api;
|
|
104
|
+
tracker;
|
|
105
|
+
comments;
|
|
106
|
+
cache;
|
|
107
|
+
/** OS-level notification surface — listens for server-pushed
|
|
108
|
+
* `notification.show` events and surfaces them via the standard
|
|
109
|
+
* Web Notification API when the user has granted permission.
|
|
110
|
+
* Permission is requested explicitly from the settings UI; this
|
|
111
|
+
* manager never prompts silently. */
|
|
112
|
+
notifications;
|
|
113
|
+
renderer;
|
|
114
|
+
project = null;
|
|
115
|
+
currentPage = null;
|
|
116
|
+
/** Currently authenticated user, populated from bootstrap. */
|
|
117
|
+
currentUser = null;
|
|
118
|
+
/** Current user's role in the project; bootstrap doesn't return this directly,
|
|
119
|
+
* so we infer from `user.platformRole === 'superadmin'` || comment-create
|
|
120
|
+
* succeeded. For v1 we ask the server explicitly only when needed; the
|
|
121
|
+
* conservative default for new users is 'commenter' (most common case). */
|
|
122
|
+
currentRole = 'anonymous';
|
|
123
|
+
/** Signed-in user's preferred bubble colour (`#rrggbb`), resolved by the
|
|
124
|
+
* server at bootstrap from their account `appearance` settings. Null when
|
|
125
|
+
* anonymous or unset. Surfaced to the renderer via the `bubbleColor()`
|
|
126
|
+
* dep + kept fresh when the user recolours from the widget settings
|
|
127
|
+
* panel (the `bubbles:recolor` bus subscription below). */
|
|
128
|
+
currentBubbleColor = null;
|
|
129
|
+
/** Wall-clock of the last successful bootstrap — throttles the
|
|
130
|
+
* focus-driven session auto-refresh (`onVisible`). */
|
|
131
|
+
lastBootstrapAt = 0;
|
|
132
|
+
/** Guard so the visibilitychange listener is wired exactly once. */
|
|
133
|
+
visibilityWired = false;
|
|
134
|
+
/** Phase 2 subscriptions — wired by `runPhase2()`, dropped by
|
|
135
|
+
* `tearDownPhase2()`. The always-on `projects.updated` listener
|
|
136
|
+
* lives separately on `offProjectUpdated` so it survives transitions. */
|
|
137
|
+
offHandlers = [];
|
|
138
|
+
/** Always-on subscription to `projects.updated`. Survives phase 2
|
|
139
|
+
* teardown so a disabled-then-enabled cycle can wake back up. */
|
|
140
|
+
offProjectUpdated = null;
|
|
141
|
+
/** Always-on subscription to `bubbles:recolor` (widget settings panel).
|
|
142
|
+
* Keeps `currentBubbleColor` in sync so the renderer's `bubbleColor()`
|
|
143
|
+
* dep — and thus the colour picker on reopen — reflects the live value
|
|
144
|
+
* rather than the stale bootstrap one. */
|
|
145
|
+
offBubbleRecolor = null;
|
|
146
|
+
/**
|
|
147
|
+
* Tracks whether the user-facing widget (Phase 2) is currently
|
|
148
|
+
* mounted. The master kill-switch `behavior.enabled` (default true)
|
|
149
|
+
* gates this — when false, Phase 2 stays down: no renderer, no
|
|
150
|
+
* WebSocket, no event subscriptions, no DOM listeners on the host
|
|
151
|
+
* page. Phase 1 (bootstrap + cache reconcile) still runs so a
|
|
152
|
+
* subsequent flip-on via `config:changed` can transition us up
|
|
153
|
+
* without a page reload.
|
|
154
|
+
*/
|
|
155
|
+
phase2Active = false;
|
|
156
|
+
/**
|
|
157
|
+
* Set by `dismiss()` — the user hid all PageLoop UI (e.g. for a
|
|
158
|
+
* screenshot). Keeps `reEvaluatePhase2()` from silently re-mounting on
|
|
159
|
+
* the next settings reconcile / tab-refocus. Not persisted, so a real
|
|
160
|
+
* page reload starts fresh with the widget visible again.
|
|
161
|
+
*/
|
|
162
|
+
dismissed = false;
|
|
163
|
+
/** Captured from the renderer's `mount` return so deep-link
|
|
164
|
+
* handlers (`?pl-comment=<id>`) can call `focusComment` after the
|
|
165
|
+
* sidebar is up. Stays null when the renderer hasn't mounted yet
|
|
166
|
+
* (e.g. during a stop/restart). */
|
|
167
|
+
sidebarHandle = null;
|
|
168
|
+
/** Same idea for the toolbar handle — needed so refreshBubbles()
|
|
169
|
+
* can push the live comment count to the docked toolbar / mini
|
|
170
|
+
* widget bubble. Was previously discarded from the mount return,
|
|
171
|
+
* which is why the mini bubble's centered count stuck at 0. */
|
|
172
|
+
toolbarHandle = null;
|
|
173
|
+
/** Latest set of comment ids whose anchor couldn't resolve in the
|
|
174
|
+
* current DOM. Updated by the `bubbles:dangling` event the bubble
|
|
175
|
+
* layer emits at the end of every repaint. Threaded into
|
|
176
|
+
* SidebarState on the next refreshSidebar() so the comments tab
|
|
177
|
+
* can mark those rows as "no anchor". */
|
|
178
|
+
danglingCommentIds = new Set();
|
|
179
|
+
/**
|
|
180
|
+
* Server-provided settings, hydrated synchronously from localStorage on
|
|
181
|
+
* boot and revalidated by bootstrap. Snippet config still wins per-key
|
|
182
|
+
* via `effectiveUi()` — this is just the fallback set.
|
|
183
|
+
*/
|
|
184
|
+
serverSettings;
|
|
185
|
+
constructor(config, deps) {
|
|
186
|
+
this.config = config;
|
|
187
|
+
this.bus = new EventBus();
|
|
188
|
+
this.notifications = new BrowserNotifications({
|
|
189
|
+
bus: this.bus,
|
|
190
|
+
debug: (...args) => this.debug(...args),
|
|
191
|
+
});
|
|
192
|
+
this.notifications.start();
|
|
193
|
+
// Hydrate settings + project synchronously from localStorage so
|
|
194
|
+
// the toolbar can mount in its final position on the first
|
|
195
|
+
// frame, before any network round-trip. Revalidation may emit
|
|
196
|
+
// `config:changed` afterwards if the server sent something
|
|
197
|
+
// different; the project metadata changes silently (renderer
|
|
198
|
+
// reads via the `project()` getter on each event).
|
|
199
|
+
this.serverSettings = readCachedSettings(config.projectId) ?? {};
|
|
200
|
+
this.project = readCachedProject(config.projectId);
|
|
201
|
+
const cachedBehavior = this.serverSettings.behavior ?? {};
|
|
202
|
+
// Pick up the post-OAuth bearer token from the URL fragment +
|
|
203
|
+
// persist it to localStorage, OR re-read a saved token from a
|
|
204
|
+
// prior session. Falls back to whatever the embedder passed in.
|
|
205
|
+
// See resolveInitialToken() for the priority order + URL-cleanup
|
|
206
|
+
// semantics.
|
|
207
|
+
const initialToken = resolveInitialToken(config.token);
|
|
208
|
+
// Hydrate the cached user identity + role so the toolbar paints
|
|
209
|
+
// the signed-in pill on first frame, before bootstrap returns.
|
|
210
|
+
// ONLY trust the cache when a token also exists — without a
|
|
211
|
+
// token we have nothing to authenticate the cached identity
|
|
212
|
+
// against, so we have to start anonymous and let bootstrap
|
|
213
|
+
// figure it out. Sign-out also clears this cache below
|
|
214
|
+
// (`bootstrap returns anonymous` and the in-cleanup paths), so
|
|
215
|
+
// a deliberate sign-out can't be undone by a stale cache hit.
|
|
216
|
+
if (initialToken) {
|
|
217
|
+
const cachedSession = readCachedUserSession(config.projectId);
|
|
218
|
+
if (cachedSession) {
|
|
219
|
+
this.currentUser = cachedSession.user;
|
|
220
|
+
this.currentRole = cachedSession.role;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
this.api = new ApiClient({
|
|
224
|
+
endpoint: config.endpoint,
|
|
225
|
+
projectId: config.projectId,
|
|
226
|
+
token: initialToken,
|
|
227
|
+
transport: config.transport ?? cachedBehavior.transport,
|
|
228
|
+
}, this.bus);
|
|
229
|
+
this.tracker = new PageTracker(this.bus);
|
|
230
|
+
this.comments = new CommentEngine(this.api, this.bus);
|
|
231
|
+
this.cache = new IdbCache(config.projectId);
|
|
232
|
+
this.renderer = deps.renderer;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Effective UI config. Server wins per-key — admin is the source of
|
|
236
|
+
* truth for these knobs. Snippet values act as initial fallbacks for
|
|
237
|
+
* the synchronous first paint (before bootstrap returns); once
|
|
238
|
+
* reconciled, the server values overwrite. Identity / security keys
|
|
239
|
+
* (endpoint, projectId, token, debug) are snippet-only and live on
|
|
240
|
+
* `this.config` directly — they're never reachable from here.
|
|
241
|
+
*/
|
|
242
|
+
effectiveUi() {
|
|
243
|
+
const snippet = this.config.ui ?? {};
|
|
244
|
+
const server = this.serverSettings.ui ?? {};
|
|
245
|
+
// sidebarPosition rolls up the legacy sidebarSide on either side
|
|
246
|
+
// when the new field is missing. Renderer reads sidebarPosition
|
|
247
|
+
// exclusively; sidebarSide is preserved on the merged shape so
|
|
248
|
+
// older renderers in flight don't break.
|
|
249
|
+
const sidebarPosition = server.sidebarPosition ??
|
|
250
|
+
snippet.sidebarPosition ??
|
|
251
|
+
server.sidebarSide ??
|
|
252
|
+
snippet.sidebarSide;
|
|
253
|
+
return {
|
|
254
|
+
toolbarPosition: server.toolbarPosition ?? snippet.toolbarPosition,
|
|
255
|
+
widgetMode: server.widgetMode ?? snippet.widgetMode,
|
|
256
|
+
widgetCorner: server.widgetCorner ?? snippet.widgetCorner,
|
|
257
|
+
sidebarPosition,
|
|
258
|
+
sidebarSide: server.sidebarSide ?? snippet.sidebarSide,
|
|
259
|
+
sidebarDisplayMode: server.sidebarDisplayMode ?? snippet.sidebarDisplayMode,
|
|
260
|
+
theme: server.theme ?? snippet.theme,
|
|
261
|
+
allowInlineCommenting: server.allowInlineCommenting ?? snippet.allowInlineCommenting,
|
|
262
|
+
tooltipDelayMs: server.tooltipDelayMs ?? snippet.tooltipDelayMs,
|
|
263
|
+
bubbleFollowAnimations: server.bubbleFollowAnimations ?? snippet.bubbleFollowAnimations,
|
|
264
|
+
bubbleZIndex: server.bubbleZIndex ?? snippet.bubbleZIndex,
|
|
265
|
+
signInMode: server.signInMode ?? snippet.signInMode,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** Same precedence rule for behaviour knobs — server wins. */
|
|
269
|
+
effectiveBehavior() {
|
|
270
|
+
const server = this.serverSettings.behavior ?? {};
|
|
271
|
+
return {
|
|
272
|
+
enabled: server.enabled,
|
|
273
|
+
autoDiscover: server.autoDiscover ?? this.config.autoDiscover,
|
|
274
|
+
transport: server.transport ?? this.config.transport,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Master kill-switch reader. Defaults to true when the admin has
|
|
279
|
+
* never set the value (backward compat — existing projects keep
|
|
280
|
+
* working). Phase-2 mount is gated on this; on a live flip via
|
|
281
|
+
* `config:changed` the same getter drives the up/down transition.
|
|
282
|
+
*/
|
|
283
|
+
isWidgetEnabled() {
|
|
284
|
+
return this.effectiveBehavior().enabled !== false;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Reconcile server settings into the cache + emit `config:changed` if
|
|
288
|
+
* any visible knob actually moved. Called once after bootstrap; cheap
|
|
289
|
+
* enough to JSON.stringify-compare since the blob is tiny.
|
|
290
|
+
*/
|
|
291
|
+
reconcileSettings(next) {
|
|
292
|
+
const incoming = next ?? {};
|
|
293
|
+
const before = JSON.stringify(this.serverSettings);
|
|
294
|
+
const after = JSON.stringify(incoming);
|
|
295
|
+
if (before === after)
|
|
296
|
+
return;
|
|
297
|
+
this.serverSettings = incoming;
|
|
298
|
+
writeCachedSettings(this.config.projectId, incoming);
|
|
299
|
+
this.bus.emit('config:changed', { settings: incoming, ui: this.effectiveUi() });
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Mount the renderer with the standard dep-getter set. Factored out
|
|
303
|
+
* of `start()` so both the optimistic-paint path (cached project,
|
|
304
|
+
* before bootstrap) and the slow path (no cache, after bootstrap)
|
|
305
|
+
* share one mount call site. Captures the sidebar handle + replays
|
|
306
|
+
* the connection state so the toolbar's "online" badge picks up
|
|
307
|
+
* any already-completed `api.connect()` resolution.
|
|
308
|
+
*/
|
|
309
|
+
async mountRenderer() {
|
|
310
|
+
const mounted = await this.renderer.mount({
|
|
311
|
+
bus: this.bus,
|
|
312
|
+
api: this.api,
|
|
313
|
+
comments: this.comments,
|
|
314
|
+
notifications: this.notifications,
|
|
315
|
+
page: () => this.currentPage,
|
|
316
|
+
project: () => this.project,
|
|
317
|
+
user: () => this.currentUser,
|
|
318
|
+
role: () => this.currentRole,
|
|
319
|
+
bubbleColor: () => this.currentBubbleColor,
|
|
320
|
+
applyToken: (token) => this.applyToken(token),
|
|
321
|
+
dismiss: () => {
|
|
322
|
+
void this.dismiss();
|
|
323
|
+
},
|
|
324
|
+
ui: () => this.effectiveUi(),
|
|
325
|
+
registerCurrentPage: () => this.registerCurrentPage().catch((err) => {
|
|
326
|
+
console.warn('[PageLoop] registerCurrentPage failed:', err);
|
|
327
|
+
return null;
|
|
328
|
+
}),
|
|
329
|
+
pageKey: () => this.tracker.pageKey(),
|
|
330
|
+
debug: (...args) => this.debug(...args),
|
|
331
|
+
release: () => ({
|
|
332
|
+
version: this.config.version,
|
|
333
|
+
notesUrl: this.config.releaseNotesUrl,
|
|
334
|
+
}),
|
|
335
|
+
});
|
|
336
|
+
this.sidebarHandle = mounted.sidebar;
|
|
337
|
+
this.toolbarHandle = mounted.toolbar;
|
|
338
|
+
// Re-emit connection state — the renderer just registered its
|
|
339
|
+
// listener, and `api.connect()` may have already resolved before
|
|
340
|
+
// this mount call.
|
|
341
|
+
this.api.replayConnectionState();
|
|
342
|
+
}
|
|
343
|
+
async start() {
|
|
344
|
+
this.debug('start: config', {
|
|
345
|
+
endpoint: this.config.endpoint,
|
|
346
|
+
projectId: this.config.projectId,
|
|
347
|
+
hasToken: !!this.config.token,
|
|
348
|
+
});
|
|
349
|
+
// Optimistic paint from IDB while the network call is in flight. The
|
|
350
|
+
// bootstrap call below reconciles afterward and emits events that update
|
|
351
|
+
// both UI and cache. This means a cold reload of a known page renders
|
|
352
|
+
// comments immediately without waiting for a round-trip.
|
|
353
|
+
const cachedKey = this.tracker.pageKey();
|
|
354
|
+
this.debug('start: pageKey', cachedKey);
|
|
355
|
+
const cachedPage = await this.cache.getPage(cachedKey);
|
|
356
|
+
if (cachedPage) {
|
|
357
|
+
this.debug('start: idb cache hit, optimistic paint', { pageId: cachedPage.id });
|
|
358
|
+
this.currentPage = cachedPage;
|
|
359
|
+
const cachedComments = await this.cache.getComments(cachedPage.id);
|
|
360
|
+
if (cachedComments)
|
|
361
|
+
this.comments.prime(cachedPage.id, cachedComments);
|
|
362
|
+
}
|
|
363
|
+
// Phase-2 gate: master kill-switch. When the admin has flipped
|
|
364
|
+
// `behavior.enabled` to false the widget renders nothing and
|
|
365
|
+
// opens no connections; we still let bootstrap run below so a
|
|
366
|
+
// future flip-on (next page load) sees the latest server value.
|
|
367
|
+
const cachedEnabled = this.isWidgetEnabled();
|
|
368
|
+
// If we have a cached Project (constructor hydrated `this.project`
|
|
369
|
+
// from localStorage) AND the cache says the widget is enabled,
|
|
370
|
+
// mount the renderer + open the WS NOW — before bootstrap —
|
|
371
|
+
// so the toolbar appears on the first frame instead of waiting
|
|
372
|
+
// for the network round-trip. The deps' getters return live
|
|
373
|
+
// values, so updates from bootstrap flow through naturally
|
|
374
|
+
// (`config:changed` + `auth:changed` events trigger re-render).
|
|
375
|
+
//
|
|
376
|
+
// First-visit users (no cached project) OR projects whose
|
|
377
|
+
// cached settings say enabled=false take the slow path below —
|
|
378
|
+
// toolbar stays hidden until bootstrap returns; if bootstrap
|
|
379
|
+
// confirms the widget is enabled the post-reconcile re-eval
|
|
380
|
+
// flips on Phase 2 then. If bootstrap says disabled, we stay
|
|
381
|
+
// inert.
|
|
382
|
+
if (cachedEnabled && this.project) {
|
|
383
|
+
this.debug('start: optimistic Phase 2 from cached project + enabled', {
|
|
384
|
+
slug: this.project.slug,
|
|
385
|
+
});
|
|
386
|
+
try {
|
|
387
|
+
await this.runPhase2();
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
this.debug('start: optimistic Phase 2 failed, will retry post-bootstrap', err);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else if (!cachedEnabled) {
|
|
394
|
+
this.debug('start: widget disabled in cache, deferring Phase 2');
|
|
395
|
+
}
|
|
396
|
+
// Bootstrap: project + (maybe) page + comments + me + prefs in one round-trip.
|
|
397
|
+
// Resilience principle: PageLoop must never block the host page. A
|
|
398
|
+
// stale/foreign-secret token in localStorage (common when a user has
|
|
399
|
+
// hit multiple PageLoop servers — local + prod — from the same
|
|
400
|
+
// origin) used to leave the widget unmounted. Now we recover:
|
|
401
|
+
// 1. If bootstrap throws UNAUTHORIZED *and* we sent a token, clear
|
|
402
|
+
// it and retry anonymous. Project publicRead determines whether
|
|
403
|
+
// the retry succeeds.
|
|
404
|
+
// 2. If the retry (or any other failure) still throws, log + bail
|
|
405
|
+
// gracefully — no UI mounted, no thrown promise, host page is
|
|
406
|
+
// untouched.
|
|
407
|
+
let initial = null;
|
|
408
|
+
let lastError = null;
|
|
409
|
+
try {
|
|
410
|
+
initial = await this.api.call('bootstrap', {
|
|
411
|
+
projectId: this.config.projectId,
|
|
412
|
+
pageKey: cachedKey,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
lastError = err;
|
|
417
|
+
const isUnauthorized = errorCode(err) === ERROR_CODES.UNAUTHORIZED;
|
|
418
|
+
if (isUnauthorized && this.api.getToken()) {
|
|
419
|
+
// The widget can't authenticate to this project — could be a
|
|
420
|
+
// stale dev token, a real session that lacks membership, or
|
|
421
|
+
// a token minted under a different `PAGELOOP_JWT_SECRET`.
|
|
422
|
+
//
|
|
423
|
+
// Critically: DO NOT clear localStorage here. The widget and
|
|
424
|
+
// the host page (admin SPA, an embedder app) share the same
|
|
425
|
+
// `pl_token` key by design — wiping it would sign the user
|
|
426
|
+
// out of the admin or any other PageLoop surface that's open
|
|
427
|
+
// in the same browser. Drop the widget's in-memory token so
|
|
428
|
+
// THIS bootstrap retry runs anonymously, but leave the
|
|
429
|
+
// persisted token alone. If the token is genuinely stale, the
|
|
430
|
+
// admin's own session-resolution path (`me`) will detect that
|
|
431
|
+
// and clear it cleanly through the proper signed-out flow.
|
|
432
|
+
this.debug('start: bootstrap UNAUTHORIZED — dropping in-memory token, retrying anon');
|
|
433
|
+
this.api.setToken(null);
|
|
434
|
+
// Cached user identity is now stale — the server rejected
|
|
435
|
+
// the token that authenticated it. Clear so the next cold
|
|
436
|
+
// load doesn't paint the old user pill while bootstrap
|
|
437
|
+
// re-resolves as anonymous.
|
|
438
|
+
clearCachedUserSession(this.config.projectId);
|
|
439
|
+
this.currentUser = null;
|
|
440
|
+
this.currentRole = 'anonymous';
|
|
441
|
+
try {
|
|
442
|
+
initial = await this.api.call('bootstrap', {
|
|
443
|
+
projectId: this.config.projectId,
|
|
444
|
+
pageKey: cachedKey,
|
|
445
|
+
});
|
|
446
|
+
lastError = null;
|
|
447
|
+
}
|
|
448
|
+
catch (err2) {
|
|
449
|
+
lastError = err2;
|
|
450
|
+
this.debug('start: anonymous bootstrap also failed', err2);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
this.debug('start: bootstrap failed', err);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (!initial) {
|
|
458
|
+
// Final fallback — the widget can't mount, but the host page is
|
|
459
|
+
// untouched. Surface enough detail that an embedder can debug
|
|
460
|
+
// without enabling debug=true.
|
|
461
|
+
const errMsg = lastError instanceof Error ? lastError.message : String(lastError);
|
|
462
|
+
const code = errorCode(lastError);
|
|
463
|
+
const hint = errMsg.includes('requires a project scope')
|
|
464
|
+
? `Project "${this.config.projectId}" not found at ${this.config.endpoint}. Check projectId or confirm the server hosting this project is the one running on that endpoint.`
|
|
465
|
+
: code === ERROR_CODES.UNAUTHORIZED
|
|
466
|
+
? `Server rejected the request (UNAUTHORIZED) and anonymous fallback also failed. The project likely requires sign-in (publicRead is off).`
|
|
467
|
+
: code === ERROR_CODES.FORBIDDEN
|
|
468
|
+
? `Anonymous access denied. Either sign in, or enable publicRead in the project settings.`
|
|
469
|
+
: `Could not reach ${this.config.endpoint}: ${errMsg}`;
|
|
470
|
+
console.warn(`[PageLoop] widget will not mount — ${hint}`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
this.project = initial.project;
|
|
474
|
+
this.currentPage = initial.page;
|
|
475
|
+
this.currentUser = initial.user;
|
|
476
|
+
this.currentRole = initial.role;
|
|
477
|
+
this.currentBubbleColor = initial.bubbleColor ?? null;
|
|
478
|
+
// Persist the fresh Project blob so the NEXT cold load can mount
|
|
479
|
+
// optimistically. Done BEFORE reconcileSettings so a partial
|
|
480
|
+
// failure mid-reconcile still leaves us with cache consistency.
|
|
481
|
+
writeCachedProject(this.config.projectId, initial.project);
|
|
482
|
+
// Persist the user + role so the NEXT cold load can paint the
|
|
483
|
+
// signed-in toolbar pill on first frame instead of flashing
|
|
484
|
+
// "Sign in" until bootstrap returns. Clears the cache when the
|
|
485
|
+
// server resolved this token as anonymous (token expired,
|
|
486
|
+
// revoked, or DB-deleted user) so the cached identity can't
|
|
487
|
+
// resurrect a session that the server no longer honors.
|
|
488
|
+
if (initial.user) {
|
|
489
|
+
writeCachedUserSession(this.config.projectId, {
|
|
490
|
+
user: initial.user,
|
|
491
|
+
role: initial.role,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
clearCachedUserSession(this.config.projectId);
|
|
496
|
+
}
|
|
497
|
+
// Reconcile server-driven config + persist for the next cold load.
|
|
498
|
+
// Emits `config:changed` if anything visible moved.
|
|
499
|
+
this.reconcileSettings(initial.project.settings);
|
|
500
|
+
// Surface auth state to wrappers so React/Preact/Solid hooks can
|
|
501
|
+
// re-render without polling. Anonymous → role='anonymous', user=null.
|
|
502
|
+
this.bus.emit('auth:changed', { user: this.currentUser, role: this.currentRole });
|
|
503
|
+
this.lastBootstrapAt = Date.now();
|
|
504
|
+
// Re-resolve the session when the user returns to the tab (throttled),
|
|
505
|
+
// so an org/role change made while they were away is picked up without
|
|
506
|
+
// a manual reload. Wired once, dropped in stop().
|
|
507
|
+
if (!this.visibilityWired && typeof document !== 'undefined') {
|
|
508
|
+
this.visibilityWired = true;
|
|
509
|
+
document.addEventListener('visibilitychange', this.onVisible);
|
|
510
|
+
}
|
|
511
|
+
this.debug('start: bootstrap', {
|
|
512
|
+
project: initial.project.slug,
|
|
513
|
+
page: initial.page?.id ?? null,
|
|
514
|
+
comments: initial.comments.length,
|
|
515
|
+
user: initial.user?.email ?? null,
|
|
516
|
+
role: initial.role,
|
|
517
|
+
});
|
|
518
|
+
if (initial.page) {
|
|
519
|
+
this.comments.prime(initial.page.id, initial.comments);
|
|
520
|
+
await this.cache.putPage(initial.page);
|
|
521
|
+
await this.cache.putComments(initial.page.id, initial.comments);
|
|
522
|
+
}
|
|
523
|
+
// Project metadata can change at runtime (rename, settings
|
|
524
|
+
// toggle, master kill-switch flip). Wire this UNCONDITIONALLY
|
|
525
|
+
// — even when Phase 2 is currently down — because this is the
|
|
526
|
+
// signal the disabled-at-boot widget uses to wake up when the
|
|
527
|
+
// admin flips it back on. Lives on its own slot so phase 2
|
|
528
|
+
// teardown doesn't drop it.
|
|
529
|
+
if (!this.offProjectUpdated) {
|
|
530
|
+
this.offProjectUpdated = this.bus.on('projects.updated', (event) => {
|
|
531
|
+
if (!this.project || event?.project?.id !== this.project.id)
|
|
532
|
+
return;
|
|
533
|
+
this.project = event.project;
|
|
534
|
+
this.reconcileSettings(event.project.settings);
|
|
535
|
+
this.bus.emit('project:updated', event.project);
|
|
536
|
+
void this.reEvaluatePhase2();
|
|
537
|
+
if (this.phase2Active)
|
|
538
|
+
this.refreshSidebar();
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
// Keep the cached bubble colour fresh when the user recolours from
|
|
542
|
+
// the widget settings panel (the renderer persists to the server +
|
|
543
|
+
// re-tints live; we just track the value so `bubbleColor()` stays
|
|
544
|
+
// current for a settings-panel reopen). Own slot, wired once.
|
|
545
|
+
if (!this.offBubbleRecolor) {
|
|
546
|
+
this.offBubbleRecolor = this.bus.on('bubbles:recolor', (event) => {
|
|
547
|
+
this.currentBubbleColor = (event?.color ?? null);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
// Reconcile complete. Re-evaluate the Phase 2 gate against the
|
|
551
|
+
// freshly-merged server settings:
|
|
552
|
+
// - cached said disabled, server says enabled → mount now
|
|
553
|
+
// - cached said enabled (we already mounted optimistically),
|
|
554
|
+
// server says disabled → tear down
|
|
555
|
+
// - both agree → no-op
|
|
556
|
+
await this.reEvaluatePhase2();
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Phase 2 — the user-facing widget. Mounts the renderer, opens the
|
|
560
|
+
* WebSocket, starts the page tracker, and subscribes the bus to
|
|
561
|
+
* comment/page events. Idempotent: subsequent calls while
|
|
562
|
+
* `phase2Active === true` are no-ops.
|
|
563
|
+
*
|
|
564
|
+
* Kept separate from `start()` so the master kill-switch
|
|
565
|
+
* (`behavior.enabled`) can gate this whole surface without
|
|
566
|
+
* threading branches through every step. Cold-boot enters here
|
|
567
|
+
* once when enabled; live admin flips re-enter via
|
|
568
|
+
* `reEvaluatePhase2()`.
|
|
569
|
+
*/
|
|
570
|
+
async runPhase2() {
|
|
571
|
+
if (this.phase2Active)
|
|
572
|
+
return;
|
|
573
|
+
this.phase2Active = true;
|
|
574
|
+
this.debug('phase2: starting');
|
|
575
|
+
// WebSocket dial — scheduled via requestIdleCallback so it
|
|
576
|
+
// yields to host-page first-paint / interactivity work. End
|
|
577
|
+
// state flips to online via the existing `connection:open`
|
|
578
|
+
// event.
|
|
579
|
+
this.scheduleIdle(() => {
|
|
580
|
+
this.api.connect().catch((err) => {
|
|
581
|
+
this.debug('phase2: connect failed', err);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
await this.mountRenderer();
|
|
585
|
+
// Auto-discover: if no tracked page exists for this URL and the
|
|
586
|
+
// config permits it, register the page so visitors can
|
|
587
|
+
// immediately comment.
|
|
588
|
+
if (!this.currentPage && (this.effectiveBehavior().autoDiscover ?? true)) {
|
|
589
|
+
this.debug('phase2: auto-discovering current page');
|
|
590
|
+
try {
|
|
591
|
+
await this.registerCurrentPage();
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
this.debug('phase2: auto-discover failed', err);
|
|
595
|
+
// FORBIDDEN / UNAUTHORIZED is the EXPECTED outcome on a private
|
|
596
|
+
// project viewed anonymously — the widget stays mounted in its
|
|
597
|
+
// anonymous state (Sign-in still available), so stay quiet. Any
|
|
598
|
+
// other failure (network, 5xx, …) is unexpected: surface a toast
|
|
599
|
+
// so the visitor/operator knows the page didn't get tracked and
|
|
600
|
+
// comments may be unavailable. (Errors arrive as `CODE: message`.)
|
|
601
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
602
|
+
const isAuthError = /^(FORBIDDEN|UNAUTHORIZED)\b/.test(message);
|
|
603
|
+
if (!isAuthError) {
|
|
604
|
+
this.notify("Couldn't set up PageLoop on this page — comments may be unavailable. Try reloading.", { kind: 'error', durationMs: 6000 });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
this.refreshSidebar();
|
|
609
|
+
this.refreshBubbles();
|
|
610
|
+
// Deep-link: `?pl-comment=<commentId>` on the current URL means
|
|
611
|
+
// "open the page focused on that comment".
|
|
612
|
+
this.maybeFocusFromUrl();
|
|
613
|
+
// Start tracking SPA navigation; resolve a tracked page on every change.
|
|
614
|
+
this.tracker.start();
|
|
615
|
+
const offChange = this.bus.on('page:change', async ({ key }) => {
|
|
616
|
+
await this.resolvePage(key);
|
|
617
|
+
});
|
|
618
|
+
// Live updates: re-render bubbles + sidebar when comments change.
|
|
619
|
+
const offCreated = this.bus.on('comment:created', () => this.refreshAfterChange());
|
|
620
|
+
const offUpdated = this.bus.on('comment:updated', () => this.refreshAfterChange());
|
|
621
|
+
const offDeleted = this.bus.on('comment:deleted', () => this.refreshAfterChange());
|
|
622
|
+
const offBulkDeleted = this.bus.on('comment:bulkDeleted', () => this.refreshAfterChange());
|
|
623
|
+
// Bubble layer emits the dangling set at the end of every
|
|
624
|
+
// anchor-resolve pass. Cache it + refresh the sidebar so the
|
|
625
|
+
// "no anchor" indicator flips on/off without waiting for the
|
|
626
|
+
// next user action.
|
|
627
|
+
const offDangling = this.bus.on('bubbles:dangling', (event) => {
|
|
628
|
+
const ids = event?.ids instanceof Set ? event.ids : new Set();
|
|
629
|
+
const changed = ids.size !== this.danglingCommentIds.size ||
|
|
630
|
+
[...ids].some((id) => !this.danglingCommentIds.has(id));
|
|
631
|
+
this.danglingCommentIds = ids;
|
|
632
|
+
if (changed)
|
|
633
|
+
this.refreshSidebar();
|
|
634
|
+
});
|
|
635
|
+
this.offHandlers.push(offChange, offCreated, offUpdated, offDeleted, offBulkDeleted, offDangling);
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Phase 2 teardown — symmetric inverse of `runPhase2()`. Unmounts
|
|
639
|
+
* the renderer, stops the tracker, disconnects the WS, drops every
|
|
640
|
+
* Phase 2 subscription. The always-on `projects.updated` listener
|
|
641
|
+
* lives on `offProjectUpdated` and is untouched here so it can
|
|
642
|
+
* wake us back up when the admin flips the kill-switch on.
|
|
643
|
+
*/
|
|
644
|
+
async tearDownPhase2() {
|
|
645
|
+
if (!this.phase2Active)
|
|
646
|
+
return;
|
|
647
|
+
this.debug('phase2: tearing down');
|
|
648
|
+
this.phase2Active = false;
|
|
649
|
+
this.tracker.stop();
|
|
650
|
+
for (const off of this.offHandlers)
|
|
651
|
+
off();
|
|
652
|
+
this.offHandlers = [];
|
|
653
|
+
await this.renderer.unmount();
|
|
654
|
+
this.sidebarHandle = null;
|
|
655
|
+
this.toolbarHandle = null;
|
|
656
|
+
this.api.disconnect();
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Remove ALL PageLoop UI from the page — toolbar/mini, sidebar, bubbles,
|
|
660
|
+
* tooltips, chat, toasts — and disconnect the socket, until the next page
|
|
661
|
+
* load. Nothing is persisted: a refresh re-initializes the widget
|
|
662
|
+
* normally. Intended for screenshots / temporarily clearing the page; the
|
|
663
|
+
* toolbar + mini both expose an "X" that calls this. Idempotent.
|
|
664
|
+
*/
|
|
665
|
+
async dismiss() {
|
|
666
|
+
this.dismissed = true;
|
|
667
|
+
await this.tearDownPhase2();
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Drive Phase 2 to whatever state the latest effective
|
|
671
|
+
* `behavior.enabled` value implies. Called after every
|
|
672
|
+
* reconcileSettings so a live admin flip transitions us up or
|
|
673
|
+
* down without a page reload.
|
|
674
|
+
*/
|
|
675
|
+
async reEvaluatePhase2() {
|
|
676
|
+
// `dismissed` wins until the next reload — a settings reconcile or
|
|
677
|
+
// tab-refocus must not resurrect the UI the user explicitly cleared.
|
|
678
|
+
const want = this.isWidgetEnabled() && !this.dismissed;
|
|
679
|
+
if (want && !this.phase2Active) {
|
|
680
|
+
await this.runPhase2();
|
|
681
|
+
}
|
|
682
|
+
else if (!want && this.phase2Active) {
|
|
683
|
+
await this.tearDownPhase2();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
async stop() {
|
|
687
|
+
this.notifications.stop();
|
|
688
|
+
if (this.offProjectUpdated) {
|
|
689
|
+
this.offProjectUpdated();
|
|
690
|
+
this.offProjectUpdated = null;
|
|
691
|
+
}
|
|
692
|
+
if (this.offBubbleRecolor) {
|
|
693
|
+
this.offBubbleRecolor();
|
|
694
|
+
this.offBubbleRecolor = null;
|
|
695
|
+
}
|
|
696
|
+
if (this.visibilityWired && typeof document !== 'undefined') {
|
|
697
|
+
document.removeEventListener('visibilitychange', this.onVisible);
|
|
698
|
+
this.visibilityWired = false;
|
|
699
|
+
}
|
|
700
|
+
// `tearDownPhase2` drops Phase 2 subscriptions, stops the
|
|
701
|
+
// tracker, unmounts the renderer, and disconnects the WS. When
|
|
702
|
+
// Phase 2 was never started (widget disabled at boot) this
|
|
703
|
+
// no-ops cleanly.
|
|
704
|
+
if (this.phase2Active) {
|
|
705
|
+
await this.tearDownPhase2();
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
getCurrentPage() {
|
|
709
|
+
return this.currentPage;
|
|
710
|
+
}
|
|
711
|
+
/** Project this PageLoop instance is bound to (null until bootstrap). */
|
|
712
|
+
getProject() {
|
|
713
|
+
return this.project;
|
|
714
|
+
}
|
|
715
|
+
/** Authenticated user — null when anonymous. Updated after bootstrap. */
|
|
716
|
+
getCurrentUser() {
|
|
717
|
+
return this.currentUser;
|
|
718
|
+
}
|
|
719
|
+
/** Caller's role in the project — `'anonymous'` when not signed in. */
|
|
720
|
+
getCurrentRole() {
|
|
721
|
+
return this.currentRole;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Apply a freshly-obtained session token (sign-in modal, email/password
|
|
725
|
+
* login, etc.) WITHOUT reloading the host page: set the bearer, persist
|
|
726
|
+
* it, then re-resolve identity + comments via bootstrap and repaint.
|
|
727
|
+
* Emits `auth:changed` so the toolbar/mini swap to the signed-in state.
|
|
728
|
+
* Pass null to sign out. Because the server's JWT is identity-only
|
|
729
|
+
* (`{sub}`) and resolves org/role per-request from the DB, this also
|
|
730
|
+
* picks up any membership/role change the user has accrued since their
|
|
731
|
+
* last bootstrap — no special token-refresh dance required.
|
|
732
|
+
*/
|
|
733
|
+
async applyToken(token) {
|
|
734
|
+
this.api.setToken(token);
|
|
735
|
+
try {
|
|
736
|
+
if (token)
|
|
737
|
+
localStorage.setItem(SESSION_TOKEN_KEY, token);
|
|
738
|
+
else
|
|
739
|
+
localStorage.removeItem(SESSION_TOKEN_KEY);
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
/* private mode — in-memory token still works for this tab */
|
|
743
|
+
}
|
|
744
|
+
await this.reBootstrap();
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Re-run bootstrap under the CURRENT token and fold the result into live
|
|
748
|
+
* state (identity, role, project settings, comments) without remounting.
|
|
749
|
+
* Powers both `applyToken` (after a sign-in) and the focus-driven
|
|
750
|
+
* auto-refresh below. Because the JWT is identity-only and the server
|
|
751
|
+
* resolves org/role per-request, this is also how a user picks up a
|
|
752
|
+
* membership/role change (e.g. an admin adding them to an org) — no token
|
|
753
|
+
* reissue needed; the next bootstrap simply resolves the new permissions.
|
|
754
|
+
*/
|
|
755
|
+
async reBootstrap() {
|
|
756
|
+
try {
|
|
757
|
+
const initial = await this.api.call('bootstrap', {
|
|
758
|
+
projectId: this.config.projectId,
|
|
759
|
+
pageKey: this.tracker.pageKey(),
|
|
760
|
+
});
|
|
761
|
+
this.lastBootstrapAt = Date.now();
|
|
762
|
+
this.project = initial.project;
|
|
763
|
+
this.currentPage = initial.page;
|
|
764
|
+
this.currentUser = initial.user;
|
|
765
|
+
this.currentRole = initial.role;
|
|
766
|
+
this.currentBubbleColor = initial.bubbleColor ?? null;
|
|
767
|
+
writeCachedProject(this.config.projectId, initial.project);
|
|
768
|
+
if (initial.user) {
|
|
769
|
+
writeCachedUserSession(this.config.projectId, {
|
|
770
|
+
user: initial.user,
|
|
771
|
+
role: initial.role,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
clearCachedUserSession(this.config.projectId);
|
|
776
|
+
}
|
|
777
|
+
this.reconcileSettings(initial.project.settings);
|
|
778
|
+
if (initial.page) {
|
|
779
|
+
this.comments.prime(initial.page.id, initial.comments);
|
|
780
|
+
await this.cache.putPage(initial.page);
|
|
781
|
+
await this.cache.putComments(initial.page.id, initial.comments);
|
|
782
|
+
}
|
|
783
|
+
this.bus.emit('auth:changed', { user: this.currentUser, role: this.currentRole });
|
|
784
|
+
await this.reEvaluatePhase2();
|
|
785
|
+
if (this.phase2Active) {
|
|
786
|
+
this.refreshSidebar();
|
|
787
|
+
this.refreshBubbles();
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
catch (err) {
|
|
791
|
+
this.debug('reBootstrap failed', err);
|
|
792
|
+
// Still surface the current auth identity best-effort.
|
|
793
|
+
this.bus.emit('auth:changed', { user: this.currentUser, role: this.currentRole });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Tab-focus auto-refresh. When a signed-in user returns to the tab and
|
|
798
|
+
* it's been a while since the last bootstrap, re-resolve their session so
|
|
799
|
+
* any org/role change an admin made while they were away (e.g. adding a
|
|
800
|
+
* just-registered user to an organization) is picked up without a manual
|
|
801
|
+
* reload. Throttled so routine tab-switching doesn't spam the server.
|
|
802
|
+
*/
|
|
803
|
+
onVisible = () => {
|
|
804
|
+
if (typeof document === 'undefined' || document.visibilityState !== 'visible')
|
|
805
|
+
return;
|
|
806
|
+
if (!this.currentUser)
|
|
807
|
+
return; // anonymous → nothing to re-resolve
|
|
808
|
+
if (Date.now() - this.lastBootstrapAt < SESSION_REFRESH_THROTTLE_MS)
|
|
809
|
+
return;
|
|
810
|
+
void this.reBootstrap();
|
|
811
|
+
};
|
|
812
|
+
/** Auto-register the current URL as a tracked page (for autoDiscover mode). */
|
|
813
|
+
async registerCurrentPage(title) {
|
|
814
|
+
if (!this.project)
|
|
815
|
+
throw new Error('PageLoop not started');
|
|
816
|
+
const key = this.tracker.pageKey();
|
|
817
|
+
const page = await this.api.call('pages.register', {
|
|
818
|
+
projectId: this.project.id,
|
|
819
|
+
sourceKind: 'live',
|
|
820
|
+
// For live URLs without an Endpoint, source_id == projectId — flat-mode default.
|
|
821
|
+
sourceId: this.project.id,
|
|
822
|
+
key,
|
|
823
|
+
title: title ?? document.title,
|
|
824
|
+
});
|
|
825
|
+
this.currentPage = page;
|
|
826
|
+
await this.comments.loadFromServer(page.id);
|
|
827
|
+
this.refreshSidebar();
|
|
828
|
+
this.refreshBubbles();
|
|
829
|
+
return page;
|
|
830
|
+
}
|
|
831
|
+
// --- internals ---
|
|
832
|
+
async resolvePage(key) {
|
|
833
|
+
if (!this.project)
|
|
834
|
+
return;
|
|
835
|
+
// Optimistic paint from cache, then reconcile.
|
|
836
|
+
const cached = await this.cache.getPage(key);
|
|
837
|
+
if (cached) {
|
|
838
|
+
this.currentPage = cached;
|
|
839
|
+
const cachedComments = await this.cache.getComments(cached.id);
|
|
840
|
+
if (cachedComments)
|
|
841
|
+
this.comments.prime(cached.id, cachedComments);
|
|
842
|
+
this.refreshSidebar();
|
|
843
|
+
this.refreshBubbles();
|
|
844
|
+
}
|
|
845
|
+
const page = await this.api.call('pages.byKey', { projectId: this.project.id, key });
|
|
846
|
+
// `displayVisibility === 'disabled'` is a hard refuse-to-mount. The
|
|
847
|
+
// widget treats the page as if it doesn't exist — no bubbles, no
|
|
848
|
+
// sidebar, no compose. Server still rejects `comments.create` for
|
|
849
|
+
// disabled pages; the client mirror keeps the offline-after-load
|
|
850
|
+
// state honest.
|
|
851
|
+
if (page && page.settings.displayVisibility === 'disabled') {
|
|
852
|
+
this.currentPage = null;
|
|
853
|
+
this.refreshSidebar();
|
|
854
|
+
this.refreshBubbles();
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
this.currentPage = page;
|
|
858
|
+
if (page) {
|
|
859
|
+
const list = await this.comments.loadFromServer(page.id);
|
|
860
|
+
await this.cache.putPage(page);
|
|
861
|
+
await this.cache.putComments(page.id, list);
|
|
862
|
+
}
|
|
863
|
+
this.refreshSidebar();
|
|
864
|
+
this.refreshBubbles();
|
|
865
|
+
}
|
|
866
|
+
debug(...args) {
|
|
867
|
+
if (this.config.debug)
|
|
868
|
+
console.debug('[PageLoop]', ...args);
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Schedule non-critical work for after the host page has settled.
|
|
872
|
+
* Uses `requestIdleCallback` where available (most modern browsers
|
|
873
|
+
* outside Safari pre-17), `setTimeout(0)` as a fallback. The
|
|
874
|
+
* timeout cap ensures the callback eventually runs even if the
|
|
875
|
+
* host stays busy — we don't want widget features to silently
|
|
876
|
+
* never activate.
|
|
877
|
+
*/
|
|
878
|
+
scheduleIdle(fn) {
|
|
879
|
+
if (typeof window === 'undefined')
|
|
880
|
+
return;
|
|
881
|
+
const ric = window.requestIdleCallback;
|
|
882
|
+
if (typeof ric === 'function') {
|
|
883
|
+
ric(fn, { timeout: 3000 });
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
setTimeout(fn, 50);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Consume `?pl-comment=<id>` (and optional `?pl-reply=<id>`) on
|
|
890
|
+
* the current URL: open the sidebar focused on that comment +
|
|
891
|
+
* animate the bubble. When `pl-reply` is also present, expand
|
|
892
|
+
* the comment's replies thread and scroll to the specific reply.
|
|
893
|
+
* Strips the params from the URL on success so a refresh doesn't
|
|
894
|
+
* re-focus.
|
|
895
|
+
*
|
|
896
|
+
* No-op when params are missing, the renderer isn't mounted, or
|
|
897
|
+
* the comment id isn't part of the currently-loaded page's set —
|
|
898
|
+
* the latter case means a stale deep-link; we silently drop it
|
|
899
|
+
* rather than confuse the user with an error toast.
|
|
900
|
+
*/
|
|
901
|
+
maybeFocusFromUrl() {
|
|
902
|
+
if (typeof window === 'undefined')
|
|
903
|
+
return;
|
|
904
|
+
const params = new URLSearchParams(window.location.search);
|
|
905
|
+
const commentId = params.get('pl-comment');
|
|
906
|
+
const replyId = params.get('pl-reply');
|
|
907
|
+
if (!commentId)
|
|
908
|
+
return;
|
|
909
|
+
if (!this.sidebarHandle)
|
|
910
|
+
return;
|
|
911
|
+
// The bubble layer's `focusComment` / `focusReply` walk the
|
|
912
|
+
// cached anchor map, so we only need to ensure the comment is
|
|
913
|
+
// loaded. The optimistic IDB paint above usually has it; if
|
|
914
|
+
// the server hasn't returned yet, a subsequent comment-list
|
|
915
|
+
// repaint won't auto-focus — but the first attempt covers the
|
|
916
|
+
// common case (warm cache or fast network).
|
|
917
|
+
try {
|
|
918
|
+
if (replyId)
|
|
919
|
+
this.sidebarHandle.focusReply(commentId, replyId);
|
|
920
|
+
else
|
|
921
|
+
this.sidebarHandle.focusComment(commentId);
|
|
922
|
+
}
|
|
923
|
+
catch (err) {
|
|
924
|
+
this.debug('maybeFocusFromUrl: focus failed', err);
|
|
925
|
+
}
|
|
926
|
+
// Strip the params so a refresh stays where the user landed.
|
|
927
|
+
try {
|
|
928
|
+
params.delete('pl-comment');
|
|
929
|
+
params.delete('pl-reply');
|
|
930
|
+
const next = `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ''}${window.location.hash}`;
|
|
931
|
+
window.history.replaceState({}, '', next);
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
/* replaceState unavailable (SSR, sandboxed iframe) — fine */
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Programmatic entry point matching the URL deep-link semantics.
|
|
939
|
+
* Hosts that own their own routing can call this instead of (or
|
|
940
|
+
* after) appending `?pl-comment=<id>` to the URL — same end-state:
|
|
941
|
+
* sidebar opens, comment scrolls into view, bubble flashes. When
|
|
942
|
+
* `replyId` is set, also expands the replies thread + scrolls.
|
|
943
|
+
*
|
|
944
|
+
* No-op until `start()` resolves and the renderer is mounted.
|
|
945
|
+
*/
|
|
946
|
+
focusComment(commentId, replyId) {
|
|
947
|
+
if (!this.sidebarHandle)
|
|
948
|
+
return;
|
|
949
|
+
try {
|
|
950
|
+
if (replyId)
|
|
951
|
+
this.sidebarHandle.focusReply(commentId, replyId);
|
|
952
|
+
else
|
|
953
|
+
this.sidebarHandle.focusComment(commentId);
|
|
954
|
+
}
|
|
955
|
+
catch (err) {
|
|
956
|
+
this.debug('focusComment: failed', err);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Build a same-page deep-link URL pointing at a specific comment
|
|
961
|
+
* (and optionally a reply within it). Hosts use this to construct
|
|
962
|
+
* notification links / "permalink" buttons / cross-page navigation
|
|
963
|
+
* targets without re-deriving the param convention each time.
|
|
964
|
+
*
|
|
965
|
+
* Returns a same-origin URL with the existing path/hash preserved
|
|
966
|
+
* and `pl-comment` / `pl-reply` query params appended.
|
|
967
|
+
*/
|
|
968
|
+
static buildDeepLink(input) {
|
|
969
|
+
const baseStr = input.url ??
|
|
970
|
+
(typeof window === 'undefined'
|
|
971
|
+
? (() => {
|
|
972
|
+
throw new Error('PageLoop.buildDeepLink: no `url` in non-browser context');
|
|
973
|
+
})()
|
|
974
|
+
: window.location.href);
|
|
975
|
+
// `URL` accepts relative inputs when given a base; fall back to
|
|
976
|
+
// the current origin so callers can pass `/foo/bar`.
|
|
977
|
+
const base = typeof window === 'undefined'
|
|
978
|
+
? new URL(baseStr)
|
|
979
|
+
: new URL(baseStr, window.location.href);
|
|
980
|
+
base.searchParams.set('pl-comment', input.commentId);
|
|
981
|
+
if (input.replyId)
|
|
982
|
+
base.searchParams.set('pl-reply', input.replyId);
|
|
983
|
+
else
|
|
984
|
+
base.searchParams.delete('pl-reply');
|
|
985
|
+
return base.toString();
|
|
986
|
+
}
|
|
987
|
+
refreshAfterChange() {
|
|
988
|
+
this.refreshSidebar();
|
|
989
|
+
this.refreshBubbles();
|
|
990
|
+
// Sync the updated comment set back to cache so the next cold reload
|
|
991
|
+
// shows the right state without a round-trip.
|
|
992
|
+
if (this.currentPage) {
|
|
993
|
+
void this.cache.putComments(this.currentPage.id, this.comments.load(this.currentPage.id));
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
refreshSidebar() {
|
|
997
|
+
const list = this.currentPage ? this.comments.load(this.currentPage.id) : [];
|
|
998
|
+
this.renderer.renderSidebar({
|
|
999
|
+
page: this.currentPage,
|
|
1000
|
+
comments: list,
|
|
1001
|
+
danglingCommentIds: this.danglingCommentIds,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
refreshBubbles() {
|
|
1005
|
+
const list = this.currentPage ? this.comments.load(this.currentPage.id) : [];
|
|
1006
|
+
this.renderer.renderBubbles(list);
|
|
1007
|
+
// Push the live count to the toolbar handle. Comments are plain by
|
|
1008
|
+
// default now (no open/resolved issue status), so the badge counts
|
|
1009
|
+
// every comment EXCEPT completed tasks (a resolved task is done —
|
|
1010
|
+
// no "pressure"). The mini widget paints this in the center of the
|
|
1011
|
+
// corner bubble; the docked Toolbar is a no-op. The handle is null
|
|
1012
|
+
// until the renderer's first mount completes, so guard accordingly.
|
|
1013
|
+
const count = list.reduce((n, c) => (c.isTask && c.status === 'resolved' ? n : n + 1), 0);
|
|
1014
|
+
this.toolbarHandle?.setUnreadCount(count);
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Public hook for hosts that have just performed a layout change
|
|
1018
|
+
* the widget can't observe on its own (SPA route swap, accordion
|
|
1019
|
+
* open, a custom width-toggle on an admin shell, anything that
|
|
1020
|
+
* shifts anchored elements without firing scroll/resize/CSS-
|
|
1021
|
+
* transition events). Re-runs the bubble positioning routine
|
|
1022
|
+
* without rebuilding the bubble DOM. Safe to call before mount
|
|
1023
|
+
* — no-op until the renderer is up.
|
|
1024
|
+
*
|
|
1025
|
+
* Background work the widget runs anyway:
|
|
1026
|
+
* - A `ResizeObserver` on `<body>` + a 3s baseline poll catch
|
|
1027
|
+
* most cases without needing this call.
|
|
1028
|
+
* - The `bubbleFollowAnimations` project setting layers a rAF
|
|
1029
|
+
* loop on top during CSS transitions for sub-frame precision.
|
|
1030
|
+
* This API is for the cases neither of the above covers — when
|
|
1031
|
+
* you definitively know a layout shift just happened.
|
|
1032
|
+
*/
|
|
1033
|
+
reflowBubbles() {
|
|
1034
|
+
this.renderer.reflowBubbles?.();
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Re-resolve every bubble's anchor against the current DOM and
|
|
1038
|
+
* re-render. Use this AFTER a page-level content change (saved
|
|
1039
|
+
* edit, in-place rewrite, etc.) where the cached anchor ranges
|
|
1040
|
+
* are now stale — `reflowBubbles()` only re-measures positions of
|
|
1041
|
+
* the EXISTING anchors and would leave them clustered at wherever
|
|
1042
|
+
* the disappeared element used to be.
|
|
1043
|
+
*
|
|
1044
|
+
* Pushes the current comment list back through the renderer's
|
|
1045
|
+
* `renderBubbles` + `renderSidebar` pipeline; the bubble layer's
|
|
1046
|
+
* repaint re-runs anchor resolution from scratch and emits the
|
|
1047
|
+
* `bubbles:dangling` event so the sidebar can mark unresolved
|
|
1048
|
+
* comments.
|
|
1049
|
+
*/
|
|
1050
|
+
repositionBubbles() {
|
|
1051
|
+
this.refreshBubbles();
|
|
1052
|
+
this.refreshSidebar();
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Programmatically hide or show the bubble overlay. Orthogonal to
|
|
1056
|
+
* the user's toolbar toggle — both have to be "shown" for bubbles
|
|
1057
|
+
* to appear. Use this when the host puts the page into a state
|
|
1058
|
+
* where anchors can't resolve (admin's raw textarea editor mode,
|
|
1059
|
+
* a lazy viewer hydrating), then pair the un-hide with
|
|
1060
|
+
* `repositionBubbles()` so the layer reseats against the new DOM.
|
|
1061
|
+
*
|
|
1062
|
+
* `animationMs` overrides the fade duration for this transition
|
|
1063
|
+
* (default 150ms). Pass `0` to hide/show instantly — handy when
|
|
1064
|
+
* the host page is mid-animation and a cross-fade would visually
|
|
1065
|
+
* compete (e.g. width toggles that re-flow anchored content).
|
|
1066
|
+
*
|
|
1067
|
+
* No-op for renderers that don't implement an overlay layer.
|
|
1068
|
+
*/
|
|
1069
|
+
setBubblesHidden(hidden, animationMs) {
|
|
1070
|
+
this.renderer.setBubblesHidden?.(hidden, animationMs);
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Show a transient notification in the bottom-right corner.
|
|
1074
|
+
* Stacks with other in-flight messages and auto-dismisses after
|
|
1075
|
+
* `durationMs` (default 2000; pass 0 to pin until the user
|
|
1076
|
+
* clicks). `kind` colors the left-edge stripe (info/success/error).
|
|
1077
|
+
*
|
|
1078
|
+
* The underlying surface is the renderer's Toaster, driven by the
|
|
1079
|
+
* `toast:show` bus channel. This method is the canonical entry
|
|
1080
|
+
* point for widget code that wants to fire a notification; both
|
|
1081
|
+
* vanilla calls and framework-wrapper consumers reach the same
|
|
1082
|
+
* stack.
|
|
1083
|
+
*/
|
|
1084
|
+
notify(message, opts) {
|
|
1085
|
+
this.bus.emit('toast:show', {
|
|
1086
|
+
kind: opts?.kind ?? 'info',
|
|
1087
|
+
message,
|
|
1088
|
+
duration: opts?.durationMs,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
//# sourceMappingURL=PageLoop.js.map
|