Story 4.1: Tasks 3-6 Complete - Director's Board Integration & Settings Menu
- Task 3: Extended FoundryAdapter with user flag access methods
- Added getFlag(userId, scope, key) method
- Added setFlag(userId, scope, key, value) method
- Added getFlagModule(userId, key) convenience method
- Added setFlagModule(userId, key, value) convenience method
- Task 4: Integrated Privacy Settings with Director's Board
- Updated participant-card.hbs to show Reaction Cam badge
- Modified boardUtils.js to pass playerPrivacyManager through context
- Updated DirectorsBoard to accept and pass playerPrivacyManager
- Added CSS styles for Reaction Cam badge (SP accent color)
- Task 5: Registered PlayerPrivacyPanel in module settings
- Added settings menu registration in module.js Hooks.once('ready')
- Available to all users (restricted: false)
- Uses localized labels and hints
- Task 6: Added all localization strings
- Added SCRYING_POOL.PrivacyPanel.* strings for panel UI
- Added SCRYING_POOL.Settings.* strings for settings menu
- Updated story file with task completion status
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -102,6 +102,56 @@ export class FoundryAdapter {
|
||||
},
|
||||
/** @returns {object|null} */
|
||||
current: () => g.user ?? null,
|
||||
/**
|
||||
* Gets a user flag for a specific user.
|
||||
* @param {string} userId - The user ID to get the flag for.
|
||||
* @param {string} scope - The flag scope/namespace.
|
||||
* @param {string} key - The flag key.
|
||||
* @returns {unknown|null} The flag value, or null if not found.
|
||||
*/
|
||||
getFlag: (userId, scope, key) => {
|
||||
const user = g.users?.get(userId);
|
||||
if (user && typeof user.getFlag === 'function') {
|
||||
return user.getFlag(scope, key) ?? null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* Sets a user flag for a specific user.
|
||||
* Note: In FoundryVTT, users can only set their own flags. GM can set flags for any user.
|
||||
* This method wraps the native user.setFlag() which returns a Promise.
|
||||
* @param {string} userId - The user ID to set the flag for.
|
||||
* @param {string} scope - The flag scope/namespace.
|
||||
* @param {string} key - The flag key.
|
||||
* @param {unknown} value - The flag value to set.
|
||||
* @returns {Promise<unknown>|null} The promise from user.setFlag(), or null if user not found.
|
||||
*/
|
||||
setFlag: (userId, scope, key, value) => {
|
||||
const user = g.users?.get(userId);
|
||||
if (user && typeof user.setFlag === 'function') {
|
||||
return /** @type {Promise<unknown>} */ (user.setFlag(scope, key, value));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* Convenience method to get a module-scoped flag.
|
||||
* @param {string} userId - The user ID to get the flag for.
|
||||
* @param {string} key - The flag key (will be scoped to 'video-view-manager').
|
||||
* @returns {unknown|null} The flag value, or null if not found.
|
||||
*/
|
||||
getFlagModule: (userId, key) => {
|
||||
return this.getFlag(userId, 'video-view-manager', key);
|
||||
},
|
||||
/**
|
||||
* Convenience method to set a module-scoped flag.
|
||||
* @param {string} userId - The user ID to set the flag for.
|
||||
* @param {string} key - The flag key (will be scoped to 'video-view-manager').
|
||||
* @param {unknown} value - The flag value to set.
|
||||
* @returns {Promise<unknown>|null} The promise from user.setFlag(), or null if user not found.
|
||||
*/
|
||||
setFlagModule: (userId, key, value) => {
|
||||
return this.setFlag(userId, 'video-view-manager', key, value);
|
||||
},
|
||||
};
|
||||
|
||||
/** Scenes surface — wraps game.scenes. */
|
||||
|
||||
@@ -58,14 +58,16 @@ export class DirectorsBoard extends _AppBase {
|
||||
* @param {object} controller
|
||||
* @param {object} adapter
|
||||
* @param {import('../core/ScenePresetManager.js').ScenePresetManager} scenePresetManager
|
||||
* @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
|
||||
* @param {object} [options]
|
||||
*/
|
||||
constructor(stateStore, controller, adapter, scenePresetManager, options = {}) {
|
||||
constructor(stateStore, controller, adapter, scenePresetManager, playerPrivacyManager, options = {}) {
|
||||
super(options);
|
||||
this._stateStore = stateStore;
|
||||
this._controller = controller;
|
||||
this._adapter = adapter;
|
||||
this._scenePresetManager = scenePresetManager;
|
||||
this._playerPrivacyManager = playerPrivacyManager;
|
||||
this._hookId = null;
|
||||
/** @type {Map<string, string>|null} Pre-bulk-action snapshot for single-step undo */
|
||||
this._undoSnapshot = null;
|
||||
@@ -337,7 +339,7 @@ export class DirectorsBoard extends _AppBase {
|
||||
|
||||
/** @inheritdoc */
|
||||
async _prepareContext() {
|
||||
const base = buildBoardContext(this._stateStore, this._controller, this._adapter);
|
||||
const base = buildBoardContext(this._stateStore, this._controller, this._adapter, this._playerPrivacyManager);
|
||||
const presetCount = this._scenePresetManager?.list?.().length ?? 0;
|
||||
|
||||
// Get auto-apply config for current scene (Story 3.2)
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// @ts-nocheck
|
||||
|
||||
// Conditional base class — test environment lacks foundry globals.
|
||||
// At module load time in tests, foundry is undefined → fallback class is used.
|
||||
|
||||
/** @private */
|
||||
const _AppBase =
|
||||
typeof foundry !== 'undefined' &&
|
||||
foundry.applications?.api?.HandlebarsApplicationMixin &&
|
||||
foundry.applications?.api?.ApplicationV2
|
||||
? foundry.applications.api.HandlebarsApplicationMixin(
|
||||
foundry.applications.api.ApplicationV2
|
||||
)
|
||||
: class _FallbackApp {
|
||||
static DEFAULT_OPTIONS = {};
|
||||
static PARTS = {};
|
||||
get rendered() { return this._rendered ?? false; }
|
||||
set rendered(v) { this._rendered = v; }
|
||||
get element() { return this._element ?? null; }
|
||||
set element(v) { this._element = v; }
|
||||
async render() { this._rendered = true; }
|
||||
async close() { this._rendered = false; }
|
||||
async _prepareContext() { return {}; }
|
||||
_onRender() {}
|
||||
_onClose() {}
|
||||
_onPosition() {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Player Privacy Panel — UI component for players to control automation opt-ins.
|
||||
*
|
||||
* Extends ApplicationV2 via HandlebarsApplicationMixin.
|
||||
* Displays privacy settings and allows toggling (for own user) or read-only view (for other users).
|
||||
*
|
||||
* Import rule: may import from src/core/, src/foundry/, src/contracts/.
|
||||
* Constructors are side-effect free.
|
||||
*
|
||||
* @module ui/player/PlayerPrivacyPanel
|
||||
*/
|
||||
|
||||
/**
|
||||
* Player Privacy Panel dialog for managing automation opt-in settings.
|
||||
*/
|
||||
export class PlayerPrivacyPanel extends _AppBase {
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: 'scrying-pool-player-privacy-panel',
|
||||
classes: ['scrying-pool', 'player-privacy-panel'],
|
||||
window: {
|
||||
title: 'Player Privacy Panel',
|
||||
resizable: false,
|
||||
width: 380,
|
||||
height: 'auto',
|
||||
},
|
||||
position: {},
|
||||
};
|
||||
|
||||
static PARTS = {
|
||||
dialog: {
|
||||
template: 'modules/video-view-manager/templates/player-privacy-panel.hbs',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
|
||||
* Injected FoundryAdapter surface.
|
||||
* @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
|
||||
* Injected PlayerPrivacyManager for privacy settings operations.
|
||||
* @param {string} targetUserId - The user ID whose settings are being viewed/edited.
|
||||
* @param {object} [options]
|
||||
* @throws {TypeError} If adapter or playerPrivacyManager is invalid.
|
||||
*/
|
||||
constructor(adapter, playerPrivacyManager, targetUserId, options = {}) {
|
||||
// Validate dependencies
|
||||
if (!adapter || typeof adapter !== 'object') {
|
||||
throw new TypeError(
|
||||
'PlayerPrivacyPanel: adapter argument is required and must be an object'
|
||||
);
|
||||
}
|
||||
if (!playerPrivacyManager || typeof playerPrivacyManager !== 'object') {
|
||||
throw new TypeError(
|
||||
'PlayerPrivacyPanel: playerPrivacyManager argument is required and must be an object'
|
||||
);
|
||||
}
|
||||
if (typeof targetUserId !== 'string' || targetUserId.length === 0) {
|
||||
throw new TypeError(
|
||||
'PlayerPrivacyPanel: targetUserId must be a non-empty string'
|
||||
);
|
||||
}
|
||||
|
||||
super(options);
|
||||
|
||||
this._adapter = adapter;
|
||||
this._playerPrivacyManager = playerPrivacyManager;
|
||||
this._targetUserId = targetUserId;
|
||||
|
||||
// Cache for DOM elements
|
||||
/** @type {HTMLElement|null} */
|
||||
this._reactionCamToggle = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._hpReactiveCamToggle = null;
|
||||
|
||||
// Current settings state
|
||||
/** @type {import('../../contracts/privacy-settings.js').PrivacySettings|null} */
|
||||
this._currentSettings = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the template context with i18n labels and current settings.
|
||||
* @returns {Promise<object>} Template context.
|
||||
*/
|
||||
async _prepareContext() {
|
||||
const i18n = this._adapter.i18n;
|
||||
|
||||
// Get current settings for the target user
|
||||
const settings = this._playerPrivacyManager.getSettings(this._targetUserId);
|
||||
this._currentSettings = settings;
|
||||
|
||||
// Determine if this is the current user (editable) or another user (read-only)
|
||||
const currentUserId = this._adapter.users.current?.()?.id;
|
||||
const isOwnUser = this._targetUserId === currentUserId;
|
||||
const isReadOnly = !isOwnUser;
|
||||
|
||||
return {
|
||||
// Panel metadata
|
||||
title: i18n.localize('SCRYING_POOL.PrivacyPanel.title'),
|
||||
sectionHeader: i18n.localize('SCRYING_POOL.PrivacyPanel.sectionHeader'),
|
||||
readOnlyNotice: i18n.localize('SCRYING_POOL.PrivacyPanel.readOnlyNotice'),
|
||||
|
||||
// Automation effects
|
||||
automationEffects: [
|
||||
{
|
||||
key: 'reactionCam',
|
||||
label: i18n.localize('SCRYING_POOL.PrivacyPanel.reactionCamLabel'),
|
||||
description: i18n.localize('SCRYING_POOL.PrivacyPanel.reactionCamDescription'),
|
||||
enabled: settings.reactionCamEnabled,
|
||||
settingKey: 'reactionCamEnabled',
|
||||
},
|
||||
{
|
||||
key: 'hpReactiveCamStyling',
|
||||
label: i18n.localize('SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingLabel'),
|
||||
description: i18n.localize('SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingDescription'),
|
||||
enabled: settings.hpReactiveCamStylingEnabled,
|
||||
settingKey: 'hpReactiveCamStylingEnabled',
|
||||
},
|
||||
],
|
||||
|
||||
// Toggle labels
|
||||
toggleOnLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOn'),
|
||||
toggleOffLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOff'),
|
||||
|
||||
// State
|
||||
isReadOnly,
|
||||
isOwnUser,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event handlers after rendering.
|
||||
* @param {HTMLElement} element - The dialog element.
|
||||
*/
|
||||
_onRender(element) {
|
||||
// Cache toggle elements
|
||||
this._reactionCamToggle = element.querySelector('[data-setting="reactionCamEnabled"]');
|
||||
this._hpReactiveCamToggle = element.querySelector('[data-setting="hpReactiveCamStylingEnabled"]');
|
||||
|
||||
// Set up toggle change handlers
|
||||
this._setupToggleHandlers(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event handlers for toggle switches.
|
||||
* @param {HTMLElement} element - The dialog element.
|
||||
*/
|
||||
_setupToggleHandlers(element) {
|
||||
const toggles = element.querySelectorAll('.player-privacy-panel__toggle input[type="checkbox"]');
|
||||
|
||||
for (const toggle of toggles) {
|
||||
toggle.addEventListener('change', (event) => this._onToggleChange(event));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles toggle change events.
|
||||
* @param {Event} event - The change event from the checkbox.
|
||||
*/
|
||||
async _onToggleChange(event) {
|
||||
const checkbox = event.target;
|
||||
if (!checkbox || checkbox.type !== 'checkbox') return;
|
||||
|
||||
const settingKey = checkbox.getAttribute('data-setting');
|
||||
const newValue = checkbox.checked;
|
||||
|
||||
// Don't allow changes in read-only mode
|
||||
if (this._isReadOnlyMode()) {
|
||||
checkbox.checked = !newValue; // Revert the change
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settingKey) {
|
||||
console.warn('[ScryingPool] PlayerPrivacyPanel: toggle missing data-setting attribute');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update the setting via PlayerPrivacyManager
|
||||
await this._playerPrivacyManager.setSetting(
|
||||
this._targetUserId,
|
||||
settingKey,
|
||||
newValue
|
||||
);
|
||||
|
||||
// Update our cached settings
|
||||
if (this._currentSettings) {
|
||||
this._currentSettings[settingKey] = newValue;
|
||||
}
|
||||
|
||||
// Show success notification
|
||||
this._adapter.notifications.info(
|
||||
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.savedNotification')
|
||||
);
|
||||
} catch (err) {
|
||||
// Revert the checkbox if the update failed
|
||||
checkbox.checked = !newValue;
|
||||
|
||||
// Log error
|
||||
console.error('[ScryingPool] PlayerPrivacyPanel: failed to update setting:', err);
|
||||
|
||||
// Show error notification
|
||||
if (err instanceof TypeError) {
|
||||
this._adapter.notifications.error(err.message);
|
||||
} else {
|
||||
this._adapter.notifications.error(
|
||||
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.saveError')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the panel is in read-only mode.
|
||||
* @returns {boolean} True if in read-only mode.
|
||||
* @private
|
||||
*/
|
||||
_isReadOnlyMode() {
|
||||
const currentUserId = this._adapter.users.current?.()?.id;
|
||||
return this._targetUserId !== currentUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up event listeners.
|
||||
*/
|
||||
_onClose() {
|
||||
// Clear cached elements
|
||||
this._reactionCamToggle = null;
|
||||
this._hpReactiveCamToggle = null;
|
||||
this._currentSettings = null;
|
||||
}
|
||||
}
|
||||
+23
-4
@@ -21,15 +21,30 @@ export function resolveToggleTarget(currentState) {
|
||||
* Builds context for a single participant in the Director's Board.
|
||||
* @param {object} stateStore
|
||||
* @param {string} userId
|
||||
* @param {object} [user] - Optional user object for additional data
|
||||
* @param {object} [privacyManager] - Optional PlayerPrivacyManager for privacy settings
|
||||
* @returns {object} Participant context
|
||||
*/
|
||||
export function buildSimpleParticipantContext(stateStore, userId) {
|
||||
export function buildSimpleParticipantContext(stateStore, userId, user, privacyManager) {
|
||||
const state = stateStore.getState(userId);
|
||||
return {
|
||||
const context = {
|
||||
userId,
|
||||
state: state ?? 'active',
|
||||
isGhost: state === 'ghost',
|
||||
};
|
||||
|
||||
// Add privacy settings if privacyManager is provided
|
||||
if (privacyManager && user) {
|
||||
try {
|
||||
const settings = privacyManager.getSettings(userId);
|
||||
context.isReactionCamEnabled = settings.reactionCamEnabled;
|
||||
} catch {
|
||||
// If privacy manager fails, just skip adding the badge
|
||||
context.isReactionCamEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,12 +52,16 @@ export function buildSimpleParticipantContext(stateStore, userId) {
|
||||
* @param {object} stateStore
|
||||
* @param {object} controller
|
||||
* @param {object} adapter
|
||||
* @param {object} [privacyManager] - Optional PlayerPrivacyManager for privacy settings
|
||||
* @returns {object} Board context
|
||||
*/
|
||||
export function buildBoardContext(stateStore, controller, adapter) {
|
||||
export function buildBoardContext(stateStore, controller, adapter, privacyManager) {
|
||||
try {
|
||||
const users = adapter.users.all?.() ?? [];
|
||||
const participants = users.map(u => buildSimpleParticipantContext(stateStore, u.id ?? u));
|
||||
const participants = users.map(u => {
|
||||
const userId = u.id ?? u;
|
||||
return buildSimpleParticipantContext(stateStore, userId, u, privacyManager);
|
||||
});
|
||||
return { participants, isEmpty: participants.length === 0 };
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] buildBoardContext failed:', err);
|
||||
|
||||
Reference in New Issue
Block a user