estreui 1.2.6 → 1.4.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.
@@ -0,0 +1,539 @@
1
+ /*
2
+ EstreUI rimwork — Notification Banner
3
+ Part of the split from estreUi.js (roadmap #002 phase 2; roadmap #009).
4
+
5
+ This file is loaded as a plain <script> tag and shares the global scope
6
+ with the other estreUi-*.js files. Load order matters: see index.html.
7
+ */
8
+
9
+ // MODULE: Notification -- EstreNotificationManager, noti(), push adapters
10
+ // ======================================================================
11
+
12
+ /**
13
+ * iOS-style notification banner queue manager.
14
+ * Mirrors EstreNotationManager (note toast) — one-at-a-time, per-item showTime.
15
+ */
16
+ class EstreNotificationManager {
17
+
18
+ // static
19
+ static #page = "popNoti";
20
+
21
+ static #queue = [];
22
+ static current = null;
23
+
24
+ static postHandle = null;
25
+
26
+ /** Default auto-dismiss ms for banners (longer than note toast). */
27
+ static defaultShowTime = 4500;
28
+
29
+ static get noInteraction() { return (intent) => {}; }
30
+
31
+ /**
32
+ * Enqueue a banner.
33
+ * @param {object} options - Normalized banner options (see EstreNotificationManager#data).
34
+ * @returns {Promise<EstreNotificationManager>|undefined}
35
+ */
36
+ static post(options) {
37
+ if (options != null && typeof options === "object") {
38
+ if (this.#isTimelineVisible()) {
39
+ this.#appendToTimelineDirect(options);
40
+ return Promise.resolve(null);
41
+ }
42
+ return new Promise((resolve) => {
43
+ const it = new EstreNotificationManager(options, resolve);
44
+ this.#queue.push(it);
45
+ if (window.isDebug) console.log(this.#page + " posted: ", it);
46
+ postQueue(_ => this.postHandler());
47
+ });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * True when the overwatchPanel is open and the timeline section is currently showing.
53
+ * In that case banner posts are diverted into the timeline list directly (iOS behavior).
54
+ * @returns {boolean}
55
+ */
56
+ static #isTimelineVisible() {
57
+ if (typeof estreUi === "undefined" || !estreUi.isOpenOverwatchPanel) return false;
58
+ if (typeof EstreTimelineStore === "undefined") return false;
59
+ return estreUi.$overwatchPanel.find('.host_item[data-showing="1"][data-id="timeline"]').length > 0;
60
+ }
61
+
62
+ /**
63
+ * Build a timeline entry directly from noti options and append to the store.
64
+ * Mirrors the shape used in checkOut().
65
+ */
66
+ static #appendToTimelineDirect(options) {
67
+ const ui = options.ui ?? {};
68
+ EstreTimelineStore.append({
69
+ postedAt: Date.now(),
70
+ title: options.title,
71
+ body: options.body,
72
+ subtitle: options.subtitle,
73
+ iconSrc: options.icon,
74
+ largeIconSrc: options.largeIcon,
75
+ url: options.url,
76
+ payload: options.data,
77
+ bgColor: ui.bgColor,
78
+ textColor: ui.textColor,
79
+ });
80
+ }
81
+
82
+ static postHandler() {
83
+ if (window.isDebug) console.log("queue: ", this.#queue);
84
+ if (this.postHandle == null && this.current == null && this.#queue.length > 0) {
85
+ const handle = Date.now();
86
+ this.postHandle = handle;
87
+ const current = this.#queue.splice(0, 1)[0];
88
+ current.data.posted = handle;
89
+ if (window.isDebug) console.log(this.#page + " bring: ", current);
90
+ return pageManager.bringPage("!" + this.#page, current, handle);
91
+ }
92
+ }
93
+
94
+ /** True when at least one banner is waiting to be brought. */
95
+ static get hasQueued() { return this.#queue.length > 0; }
96
+
97
+ /**
98
+ * Release the queue lock at close-start so the next banner can begin its
99
+ * enter animation in parallel with the outgoing banner's exit animation.
100
+ * Does the timeline-append + resolver work; checkOut() becomes a noop
101
+ * afterwards via the `_earlyCheckedOut` flag.
102
+ */
103
+ static beginCheckOut(intent) {
104
+ if (intent == null || intent._earlyCheckedOut) return;
105
+ if (intent.data.posted != null && this.postHandle == intent.data.posted) {
106
+ if (this.current == intent) this.current = null;
107
+ this.postHandle = null;
108
+ if (typeof EstreTimelineStore !== "undefined") {
109
+ const d = intent.data;
110
+ EstreTimelineStore.append({
111
+ postedAt: d.posted,
112
+ title: d.contentTitle,
113
+ body: d.content,
114
+ subtitle: d.subtitle,
115
+ iconSrc: d.iconSrc,
116
+ largeIconSrc: d.largeIconSrc,
117
+ url: d.url,
118
+ payload: d.payload,
119
+ bgColor: d.bgColor,
120
+ textColor: d.textColor,
121
+ });
122
+ }
123
+ intent._earlyCheckedOut = true;
124
+ intent.resolver?.(intent);
125
+ if (window.isDebug) console.log(this.#page + " early checked out: ", intent);
126
+ postQueue(_ => this.postHandler());
127
+ }
128
+ }
129
+
130
+ static checkOut(intent) {
131
+ if (intent?._earlyCheckedOut) return;
132
+ if (intent.data.posted != null && this.postHandle == intent.data.posted) {
133
+ if (this.current == intent) {
134
+ this.current = null;
135
+ }
136
+ this.postHandle = null;
137
+ if (window.isDebug) console.log(this.#page + " checked out: ", intent);
138
+ postQueue(_ => this.postHandler());
139
+ }
140
+ if (typeof EstreTimelineStore !== "undefined") {
141
+ const d = intent.data;
142
+ EstreTimelineStore.append({
143
+ postedAt: d.posted,
144
+ title: d.contentTitle,
145
+ body: d.content,
146
+ subtitle: d.subtitle,
147
+ iconSrc: d.iconSrc,
148
+ largeIconSrc: d.largeIconSrc,
149
+ url: d.url,
150
+ payload: d.payload,
151
+ bgColor: d.bgColor,
152
+ textColor: d.textColor,
153
+ });
154
+ }
155
+ intent.resolver?.(intent);
156
+ }
157
+
158
+
159
+ // instance property
160
+ data = {
161
+ posted: undefined,
162
+ contentTitle: undefined,
163
+ subtitle: undefined,
164
+ content: undefined,
165
+ showTime: undefined,
166
+ interactive: undefined,
167
+
168
+ // icons
169
+ iconSrc: undefined, // small / sub icon
170
+ largeIconSrc: undefined, // large / main icon (subIconSrc naming kept for template)
171
+
172
+ // payload / routing
173
+ buttons: undefined,
174
+ url: undefined,
175
+ payload: undefined,
176
+
177
+ // visual tokens
178
+ textSize: undefined,
179
+ textWeight: undefined,
180
+ textColor: undefined,
181
+ bgColor: undefined,
182
+ };
183
+
184
+ onTakeInteraction = undefined;
185
+ resolver = undefined;
186
+
187
+ /**
188
+ * @param {object} options - Normalized banner options.
189
+ * @param {Function} resolver - Promise resolver injected by post().
190
+ */
191
+ constructor(options, resolver) {
192
+ const d = this.data;
193
+ d.contentTitle = options.title;
194
+ d.content = options.body;
195
+ d.subtitle = options.subtitle;
196
+ d.iconSrc = options.icon;
197
+ d.largeIconSrc = options.largeIcon;
198
+ d.buttons = options.buttons;
199
+ d.url = options.url;
200
+ d.payload = options.data;
201
+
202
+ const ui = options.ui ?? {};
203
+ d.showTime = ui.showTime ?? EstreNotificationManager.defaultShowTime;
204
+ d.textSize = ui.textSize;
205
+ d.textWeight = ui.textWeight;
206
+ d.textColor = ui.textColor;
207
+ d.bgColor = ui.bgColor;
208
+
209
+ this.onTakeInteraction = options.onTakeInteraction ?? EstreNotificationManager.noInteraction;
210
+ const hasInteraction = this.onTakeInteraction !== EstreNotificationManager.noInteraction
211
+ || options.url != null
212
+ || (Array.isArray(options.buttons) && options.buttons.length > 0);
213
+ d.interactive = (ui.interactive ?? hasInteraction) ? "" : undefined;
214
+
215
+ this.resolver = resolver;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Post an iOS-style notification banner.
221
+ *
222
+ * Positional form (frequency-first, matches the original stub):
223
+ * noti(title, body, onTakeInteraction, mainIconSrc, subIconSrc)
224
+ *
225
+ * Object overload — passes through to EstreNotificationManager.post():
226
+ * noti({ title, body, subtitle, icon, largeIcon, data, url, buttons, onTakeInteraction, ui })
227
+ *
228
+ * @param {string|object} title - Title text, or the full options object (overload).
229
+ * @param {string} [body] - Body text (HTML-capable).
230
+ * @param {Function} [onTakeInteraction] - Tap callback. intent is passed.
231
+ * @param {string} [mainIconSrc] - Large/leading icon src (maps to largeIcon).
232
+ * @param {string} [subIconSrc] - Small/trailing icon src (maps to icon).
233
+ * @returns {Promise<EstreNotificationManager>|undefined}
234
+ */
235
+ const noti = function (title, body, onTakeInteraction, mainIconSrc, subIconSrc) {
236
+ if (title != null && typeof title === "object") {
237
+ return EstreNotificationManager.post(title);
238
+ }
239
+ return EstreNotificationManager.post({
240
+ title,
241
+ body,
242
+ onTakeInteraction,
243
+ largeIcon: mainIconSrc,
244
+ icon: subIconSrc,
245
+ });
246
+ }
247
+
248
+ /**
249
+ * FCM payload adapter.
250
+ * Accepts either the full message (`{ notification, data }`) or the `notification` object directly.
251
+ * @param {object} payload
252
+ */
253
+ noti.fromFcm = function (payload) {
254
+ if (payload == null) return;
255
+ const n = payload.notification ?? payload;
256
+ const data = payload.data ?? (payload !== n ? undefined : undefined);
257
+ return noti({
258
+ title: n.title,
259
+ body: n.body,
260
+ icon: n.icon,
261
+ largeIcon: n.image,
262
+ url: n.click_action ?? payload.fcm_options?.link,
263
+ data,
264
+ });
265
+ }
266
+
267
+ /**
268
+ * APNs payload adapter.
269
+ * Accepts the outer aps-bearing object (`{ aps: { alert: ... }, ...custom }`).
270
+ * `alert` may be a string or object.
271
+ * @param {object} payload
272
+ */
273
+ noti.fromApns = function (payload) {
274
+ if (payload == null) return;
275
+ const aps = payload.aps ?? {};
276
+ const alert = typeof aps.alert === "string" ? { body: aps.alert } : (aps.alert ?? {});
277
+ return noti({
278
+ title: alert.title,
279
+ body: alert.body,
280
+ subtitle: alert.subtitle,
281
+ data: payload,
282
+ });
283
+ }
284
+
285
+ /**
286
+ * OneSignal payload adapter.
287
+ * `headings` / `contents` may be locale maps; first value is picked.
288
+ * @param {object} payload
289
+ */
290
+ noti.fromOneSignal = function (payload) {
291
+ if (payload == null) return;
292
+ const pick = (v) => (v != null && typeof v === "object") ? Object.values(v)[0] : v;
293
+ return noti({
294
+ title: pick(payload.headings),
295
+ body: pick(payload.contents),
296
+ subtitle: pick(payload.subtitle),
297
+ largeIcon: payload.big_picture ?? payload.chrome_big_picture,
298
+ icon: payload.small_icon ?? payload.chrome_icon,
299
+ url: payload.url,
300
+ data: payload.data ?? payload.additionalData,
301
+ });
302
+ }
303
+
304
+
305
+ /**
306
+ * Persistent history for dismissed notification banners (roadmap #010).
307
+ * Backed by ECLS (localStorage, JSON). Retains up to maxEntries for ttlMs.
308
+ */
309
+ class EstreTimelineStore {
310
+
311
+ static #key = "timelineEntries";
312
+ static maxEntries = 100;
313
+ static ttlMs = 7 * 24 * 60 * 60 * 1000;
314
+
315
+ static #listeners = new Set();
316
+
317
+ /** @returns {Array<object>} entries newest-first, TTL-pruned. */
318
+ static load() {
319
+ const raw = ECLS.get(this.#key, []);
320
+ if (!Array.isArray(raw)) return [];
321
+ const now = Date.now();
322
+ return raw.filter(it => it != null && (now - (it.postedAt ?? 0)) < this.ttlMs);
323
+ }
324
+
325
+ static save(entries) {
326
+ ECLS.set(this.#key, entries);
327
+ }
328
+
329
+ /**
330
+ * Append an entry (newest-first). Assigns id/postedAt if missing. Enforces cap + TTL.
331
+ * @param {object} entry
332
+ */
333
+ static append(entry) {
334
+ if (entry == null) return;
335
+ const now = Date.now();
336
+ const normalized = { ...entry,
337
+ id: entry.id ?? String(entry.postedAt ?? now),
338
+ postedAt: entry.postedAt ?? now,
339
+ };
340
+ const entries = this.load().filter(it => it.id !== normalized.id);
341
+ entries.unshift(normalized);
342
+ if (entries.length > this.maxEntries) entries.length = this.maxEntries;
343
+ this.save(entries);
344
+ this.#emit();
345
+ }
346
+
347
+ static remove(id) {
348
+ const entries = this.load().filter(it => it.id !== id);
349
+ this.save(entries);
350
+ this.#emit();
351
+ }
352
+
353
+ static clear() {
354
+ this.save([]);
355
+ this.#emit();
356
+ }
357
+
358
+ /**
359
+ * Subscribe to store changes. Callback receives the current entries array.
360
+ * @param {Function} cb
361
+ * @returns {Function} unsubscribe
362
+ */
363
+ static subscribe(cb) {
364
+ this.#listeners.add(cb);
365
+ return () => this.#listeners.delete(cb);
366
+ }
367
+
368
+ static #emit() {
369
+ const entries = this.load();
370
+ for (const cb of this.#listeners) {
371
+ try { cb(entries); } catch (ex) { if (window.isLogging) console.error(ex); }
372
+ }
373
+ }
374
+ }
375
+
376
+
377
+ /**
378
+ * Renders EstreTimelineStore entries into a host element (e.g. overwatchPanel #timeline).
379
+ * Groups by date bucket (Today / Yesterday / Older), item visuals share banner styles.
380
+ */
381
+ class EstreTimelineView {
382
+
383
+ #$host;
384
+ #unsubscribe;
385
+ #lastIds = new Set();
386
+
387
+ /**
388
+ * @param {Element|JQuery} host - Container element for the list.
389
+ */
390
+ constructor(host) {
391
+ this.#$host = host instanceof jQuery ? host : $(host);
392
+ this.#$host.addClass("timeline_host");
393
+ this.render(EstreTimelineStore.load());
394
+ this.#unsubscribe = EstreTimelineStore.subscribe((entries) => this.render(entries));
395
+ }
396
+
397
+ destroy() {
398
+ this.#unsubscribe?.();
399
+ this.#$host.empty();
400
+ this.#$host.removeClass("timeline_host");
401
+ }
402
+
403
+ render(entries) {
404
+ const $host = this.#$host;
405
+ $host.empty();
406
+
407
+ if (!entries || entries.length === 0) {
408
+ this.#lastIds = new Set();
409
+ $host.append('<div class="timeline_empty">No notifications</div>');
410
+ return;
411
+ }
412
+
413
+ const currentIds = new Set(entries.map(e => e.id));
414
+ const lastIds = this.#lastIds;
415
+ const isFirstRender = lastIds.size === 0;
416
+
417
+ const groups = this.#groupByDate(entries);
418
+ for (let gi = 0; gi < groups.length; gi++) {
419
+ const group = groups[gi];
420
+ const $group = $('<div class="timeline_group"></div>');
421
+ const $header = $('<div class="timeline_group_header"></div>');
422
+ $header.append($('<span class="timeline_group_label"></span>').text(group.label));
423
+ if (gi === 0) {
424
+ const $btn = $('<button type="button" class="timeline_clear_all">Clear All</button>');
425
+ $btn.on("click", (e) => {
426
+ e.preventDefault();
427
+ e.stopPropagation();
428
+ this.#clearAllWithCascade();
429
+ });
430
+ $header.append($btn);
431
+ }
432
+ $group.append($header);
433
+ for (const entry of group.entries) {
434
+ const isNew = !isFirstRender && !lastIds.has(entry.id);
435
+ $group.append(this.#buildItem(entry, isNew));
436
+ }
437
+ $host.append($group);
438
+ }
439
+
440
+ this.#lastIds = currentIds;
441
+ }
442
+
443
+ #clearAllWithCascade() {
444
+ const $items = this.#$host.find(".timeline_item");
445
+ if ($items.length === 0) {
446
+ EstreTimelineStore.clear();
447
+ return;
448
+ }
449
+ const stagger = 50;
450
+ const maxDelay = 400;
451
+ $items.each(function (i) {
452
+ const delay = Math.min(i * stagger, maxDelay);
453
+ $(this).css("--exit-delay", delay + "ms").addClass("timeline_item_exit");
454
+ });
455
+ setTimeout(() => EstreTimelineStore.clear(), maxDelay + 300);
456
+ }
457
+
458
+ #groupByDate(entries) {
459
+ const now = new Date();
460
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
461
+ const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
462
+
463
+ const today = [], yesterday = [], older = [];
464
+ for (const entry of entries) {
465
+ const t = entry.postedAt ?? 0;
466
+ if (t >= startOfToday) today.push(entry);
467
+ else if (t >= startOfYesterday) yesterday.push(entry);
468
+ else older.push(entry);
469
+ }
470
+
471
+ const groups = [];
472
+ if (today.length) groups.push({ label: "Today", entries: today });
473
+ if (yesterday.length) groups.push({ label: "Yesterday", entries: yesterday });
474
+ if (older.length) groups.push({ label: "Older", entries: older });
475
+ return groups;
476
+ }
477
+
478
+ #buildItem(entry, isNew = false) {
479
+ const $item = $('<div class="h_icon_set post_block timeline_item"></div>');
480
+ $item.attr("data-id", entry.id);
481
+ if (entry.bgColor) $item.css("--bg-color", entry.bgColor);
482
+ if (isNew) $item.addClass("timeline_item_enter");
483
+
484
+ if (entry.largeIconSrc) {
485
+ const $mainIcon = $('<div class="icon_place"></div>');
486
+ $mainIcon.append($('<img />').attr("src", entry.largeIconSrc));
487
+ $item.append($mainIcon);
488
+ }
489
+
490
+ const $content = $('<div class="content_place"></div>');
491
+ if (entry.title) $content.append($('<div class="title_line"></div>').append($('<span></span>').text(entry.title)));
492
+ if (entry.subtitle) $content.append($('<div class="subtitle_line"></div>').append($('<span></span>').text(entry.subtitle)));
493
+ if (entry.body || entry.iconSrc) {
494
+ const $area = $('<div class="content_area"></div>');
495
+ if (entry.body) {
496
+ const $body = $('<div class="content_place"></div>').html(entry.body);
497
+ if (entry.textColor) $body.css("--color", entry.textColor);
498
+ $area.append($body);
499
+ }
500
+ if (entry.iconSrc) {
501
+ const $subIcon = $('<div class="icon_place"></div>');
502
+ $subIcon.append($('<img />').attr("src", entry.iconSrc));
503
+ $area.append($subIcon);
504
+ }
505
+ $content.append($area);
506
+ }
507
+ $item.append($content);
508
+
509
+ $item.on("click", (e) => {
510
+ e.preventDefault();
511
+ if (entry.url) {
512
+ window.open(entry.url, "_blank", "noopener");
513
+ }
514
+ });
515
+
516
+ // Left→right swipe to delete. Opposite direction to the parent's horizontal
517
+ // scroll-snap (quick panel switch), so the two gestures don't collide.
518
+ if (typeof EstreSwipeHandler !== "undefined") {
519
+ new EstreSwipeHandler($item)
520
+ .setStopPropagation()
521
+ .unuseY()
522
+ .setThresholdX(1)
523
+ .setDropStrayed(false)
524
+ .setResponseBound($item)
525
+ .setOnUp(function (grabX, grabY, handled, canceled, directed) {
526
+ if (!handled) return;
527
+ if (this.handledDirection === "right" && grabX > 80) {
528
+ $item.css("--exit-delay", "0ms").addClass("timeline_item_exit");
529
+ setTimeout(() => EstreTimelineStore.remove(entry.id), 300);
530
+ }
531
+ });
532
+ }
533
+
534
+ return $item;
535
+ }
536
+ }
537
+
538
+
539
+ // ======================================================================