Story 3.2 done
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* NotificationBus — coalesced toast layer above ui.notifications.
|
||||
*
|
||||
* Subscribes to `scrying-pool:stateChanged` and coalesces rapid GM visibility
|
||||
* changes for the same participant into a single toast per 3-second window.
|
||||
*
|
||||
* Verbosity rules (AC-4, AC-5):
|
||||
* - 'all' → every client sees every general notification
|
||||
* - 'gm-only' → only the GM + the affected participant are notified
|
||||
* - 'silent' → only the affected participant is notified (personal only)
|
||||
*
|
||||
* Personal notification (own camera changed) always fires — never suppressed
|
||||
* by verbosity setting (AC-2).
|
||||
*
|
||||
* Import boundary: src/notifications/ → src/core/, src/contracts/, src/utils/ ONLY.
|
||||
* Uses Hooks as a FoundryVTT global (same pattern as StateStore).
|
||||
*
|
||||
* Constructors are side-effect-free. Call init() from module.js Hooks.once('ready').
|
||||
*
|
||||
* @module notifications/NotificationBus
|
||||
*/
|
||||
|
||||
/** Coalescing window in milliseconds. Matches AC-3 ("within 3 seconds"). */
|
||||
const COALESCE_WINDOW_MS = 3_000;
|
||||
|
||||
/**
|
||||
* Coalesced toast notification layer over ui.notifications.
|
||||
*
|
||||
* Subscribes to `scrying-pool:stateChanged` and debounces GM visibility
|
||||
* changes per participant into a single toast per 3-second window.
|
||||
*/
|
||||
export class NotificationBus {
|
||||
/**
|
||||
* @param {{ notifications: {info(m:string):void, warn(m:string):void, error(m:string):void},
|
||||
* users: {get(id:string):{id:string,name:string}|null, current():{id:string}|null, isGM():boolean},
|
||||
* settings: {get(key:string):unknown},
|
||||
* i18n: {localize(key:string, data?:object):string} }} adapter
|
||||
*/
|
||||
constructor(adapter) {
|
||||
this._adapter = adapter;
|
||||
/** @type {Map<string, {timer: ReturnType<typeof setTimeout>|null, prevState: string, lastState: string, changeCount: number}>} */
|
||||
this._coalesceMap = new Map();
|
||||
this._hookId = null;
|
||||
this._disconnectHookId = null;
|
||||
}
|
||||
|
||||
/** Register hook listener. Call from module.js Hooks.once('ready'). */
|
||||
init() {
|
||||
// Prevent multiple init calls without teardown
|
||||
if (this._hookId != null) {
|
||||
console.warn('[ScryingPool] NotificationBus.init: already initialized, call teardown first');
|
||||
return;
|
||||
}
|
||||
this._hookId = Hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(data));
|
||||
|
||||
// Clean up coalesceMap entries for disconnected users
|
||||
this._disconnectHookId = this._adapter.hooks.on('userConnected', (user, connected) => {
|
||||
if (!connected && user?.id) {
|
||||
const entry = this._coalesceMap.get(user.id);
|
||||
if (entry) {
|
||||
clearTimeout(entry.timer);
|
||||
this._coalesceMap.delete(user.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Unregister listeners and cancel all pending timers. Safe to call before init(). */
|
||||
teardown() {
|
||||
if (this._hookId != null) {
|
||||
Hooks.off('scrying-pool:stateChanged', this._hookId);
|
||||
this._hookId = null;
|
||||
}
|
||||
if (this._disconnectHookId != null) {
|
||||
this._adapter.hooks.off('userConnected', this._disconnectHookId);
|
||||
this._disconnectHookId = null;
|
||||
}
|
||||
for (const entry of this._coalesceMap.values()) {
|
||||
clearTimeout(entry.timer);
|
||||
}
|
||||
this._coalesceMap.clear();
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handler for `scrying-pool:stateChanged` hook.
|
||||
* @param {{ userId?: string, state: string, previousState: string }} data
|
||||
*/
|
||||
_onStateChanged(data) {
|
||||
const { userId, state: newState, previousState } = data ?? {};
|
||||
if (!userId) return;
|
||||
if (typeof newState !== 'string') return;
|
||||
|
||||
const currentUserId = this._adapter.users.current()?.id ?? null;
|
||||
|
||||
// AC-2: Personal notification — fires immediately, never suppressed by verbosity
|
||||
if (userId === currentUserId) {
|
||||
this._notifyPersonal(newState);
|
||||
return;
|
||||
}
|
||||
|
||||
// AC-4/AC-5: Verbosity gate for non-personal notifications
|
||||
const verbosity = this._adapter.settings.get('notificationVerbosity') ?? 'all';
|
||||
// Validate verbosity is one of the allowed choices
|
||||
const validVerbosity = ['all', 'gm-only', 'silent'].includes(verbosity) ? verbosity : 'all';
|
||||
if (validVerbosity === 'silent') return;
|
||||
if (validVerbosity === 'gm-only' && !this._adapter.users.isGM()) return;
|
||||
|
||||
this._enqueue(userId, newState, previousState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire an immediate personal notification for the current user's own camera change.
|
||||
* @param {string} newState
|
||||
*/
|
||||
_notifyPersonal(newState) {
|
||||
const key = newState === 'hidden'
|
||||
? 'video-view-manager.notifications.personalHidden'
|
||||
: 'video-view-manager.notifications.personalShowed';
|
||||
const msg = this._adapter.i18n.localize(key);
|
||||
this._adapter.notifications.info(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a coalescing entry for the given participant.
|
||||
* Resets the 3-second debounce window on each call.
|
||||
* @param {string} userId
|
||||
* @param {string} newState
|
||||
* @param {string} prevState
|
||||
*/
|
||||
_enqueue(userId, newState, prevState) {
|
||||
// Validate required parameters
|
||||
if (typeof userId !== 'string' || !userId) return;
|
||||
if (typeof newState !== 'string' || typeof prevState !== 'string') return;
|
||||
|
||||
const existing = this._coalesceMap.get(userId);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
existing.lastState = newState;
|
||||
existing.changeCount += 1;
|
||||
} else {
|
||||
this._coalesceMap.set(userId, {
|
||||
timer: null,
|
||||
prevState,
|
||||
lastState: newState,
|
||||
changeCount: 1,
|
||||
});
|
||||
}
|
||||
const entry = this._coalesceMap.get(userId);
|
||||
entry.timer = setTimeout(() => this._flush(userId), COALESCE_WINDOW_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the coalesced notification for a participant, then remove the entry.
|
||||
* Net-zero suppression: if final state equals original state, no notification fires.
|
||||
* @param {string} userId
|
||||
*/
|
||||
_flush(userId) {
|
||||
const entry = this._coalesceMap.get(userId);
|
||||
if (!entry) return; // Entry may have been deleted by teardown or disconnect cleanup
|
||||
this._coalesceMap.delete(userId);
|
||||
|
||||
// AC-3: Net-zero suppression
|
||||
if (entry.lastState === entry.prevState) return;
|
||||
|
||||
// Additional safety: ensure we have valid timer to prevent stale closure issues
|
||||
if (entry.timer == null) return;
|
||||
|
||||
const name = this._adapter.users.get(userId)?.name ?? userId;
|
||||
const count = entry.changeCount > 1 ? ` (${entry.changeCount} changes)` : '';
|
||||
const key = entry.lastState === 'hidden'
|
||||
? 'video-view-manager.notifications.gmHid'
|
||||
: 'video-view-manager.notifications.gmShowed';
|
||||
// Note: changeCount is included in the message suffix for AC-3 compliance
|
||||
const msg = this._adapter.i18n.localize(key, { name }) + count;
|
||||
|
||||
this._adapter.notifications.info(msg);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user