Story 4.2 completed

This commit is contained in:
2026-05-24 00:37:21 +02:00
parent de1b33c453
commit 56eeb7cc83
21 changed files with 3836 additions and 56 deletions
+111 -13
View File
@@ -5,15 +5,34 @@
* their on-screen presence. Settings are stored as user flags on the user document.
*
* Storage key: game.user.setFlag('video-view-manager', key, value)
* Shape: { reactionCamEnabled: boolean, hpReactiveCamStylingEnabled: boolean }
* Shape: { reactionCamEnabled: boolean, hpReactiveCamStylingEnabled: boolean, customPortraitFallback: string | null }
*
* @module contracts/privacy-settings
*/
/**
* Maximum portrait file size in bytes (5MB).
* Note: FoundryVTT user flags typically have a ~50KB limit, so images should be optimized.
* @type {number}
*/
export const MAX_PORTRAIT_SIZE = 5 * 1024 * 1024;
/**
* Supported portrait image MIME types.
* @type {string[]}
*/
export const VALID_PORTRAIT_FORMATS = Object.freeze([
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
]);
/**
* @typedef {Object} PrivacySettings
* @property {boolean} reactionCamEnabled - Whether Reaction Cam automation is enabled for this user.
* @property {boolean} hpReactiveCamStylingEnabled - Whether HP-Reactive Cam Styling is enabled for this user.
* @property {string|null} customPortraitFallback - DataURL string for custom portrait fallback image, or null if not set.
*/
export const PRIVACY_SETTINGS_VERSION = 1;
@@ -25,6 +44,7 @@ export const PRIVACY_SETTINGS_VERSION = 1;
export const PRIVACY_SETTINGS_DEFAULT = {
reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: null,
};
/**
@@ -34,6 +54,7 @@ export const PRIVACY_SETTINGS_DEFAULT = {
export const PRIVACY_SETTING_KEYS = Object.freeze([
"reactionCamEnabled",
"hpReactiveCamStylingEnabled",
"customPortraitFallback",
]);
/**
@@ -45,6 +66,55 @@ export const FEATURE_NAME_MAP = Object.freeze({
hpReactiveCamStyling: "hpReactiveCamStylingEnabled",
});
/**
* Validates a DataURL for portrait images.
* Accepts DataURLs with supported MIME types or null/undefined/empty string.
* @param {unknown} dataURL - The DataURL string to validate.
* @returns {string|null|undefined} The validated DataURL (or null/undefined if valid).
* @throws {TypeError} If the DataURL format is invalid or uses unsupported MIME type.
*/
export function validatePortraitDataURL(dataURL) {
// Accept null, undefined, or empty string as valid (no custom portrait)
if (dataURL === null || dataURL === undefined) {
return dataURL;
}
if (typeof dataURL !== "string") {
throw new TypeError(`Invalid DataURL: expected string, got ${typeof dataURL}`);
}
if (dataURL === "") {
return dataURL;
}
// Validate DataURL format: must start with "data:"
if (!dataURL.startsWith("data:")) {
throw new TypeError("Invalid DataURL format: must start with 'data:'");
}
// Extract MIME type from DataURL (format: data:<mediatype>;base64,... or data:<mediatype>,...)
// Match image MIME type after data: (captures the part before ; or ,)
const mimeMatch = dataURL.match(/^data:(image\/[a-zA-Z0-9+\-.]+)/);
if (!mimeMatch) {
throw new TypeError("Invalid DataURL format: missing or invalid MIME type");
}
const mimeType = mimeMatch[1].toLowerCase();
// Validate against supported formats
if (!VALID_PORTRAIT_FORMATS.includes(mimeType)) {
throw new TypeError(
`Unsupported portrait format: ${mimeType}. Supported: ${VALID_PORTRAIT_FORMATS.join(", ")}`
);
}
// Verify actual data content exists after MIME header
const dataPart = dataURL.substring(dataURL.indexOf(",") + 1);
if (!dataPart || dataPart.length === 0) {
throw new TypeError("Invalid DataURL: empty image data");
}
return dataURL;
}
/**
* Creates a new PrivacySettings object with defaults.
* Only known keys from PRIVACY_SETTINGS_DEFAULT are included; extra properties are ignored.
@@ -63,6 +133,7 @@ export function createPrivacySettings(overrides = {}) {
/**
* Validates a PrivacySettings DTO. Throws TypeError on any violation.
* Backward compatible: accepts settings without customPortraitFallback key.
* @param {unknown} data - Value to validate.
* @returns {PrivacySettings} The validated settings.
* @throws {TypeError} If data fails validation.
@@ -75,23 +146,50 @@ export function isValidPrivacySettings(data) {
throw new TypeError("PrivacySettings: must be an object");
}
const obj = /** @type {Record<string, unknown>} */ (data);
const { reactionCamEnabled, hpReactiveCamStylingEnabled, ...rest } = obj;
if (Object.keys(rest).length > 0) {
// Check for unknown keys (keys not in PRIVACY_SETTING_KEYS)
const knownKeys = new Set(PRIVACY_SETTING_KEYS);
const unknownKeys = Object.keys(obj).filter((k) => !knownKeys.has(k));
if (unknownKeys.length > 0) {
throw new TypeError(
`PrivacySettings: unknown keys: ${Object.keys(rest).join(", ")}`
`PrivacySettings: unknown keys: ${unknownKeys.join(", ")}`
);
}
if (typeof reactionCamEnabled !== "boolean") {
throw new TypeError(
`PrivacySettings: reactionCamEnabled must be a boolean, got ${typeof reactionCamEnabled}`
);
// Validate each known key if present
if ("reactionCamEnabled" in obj) {
if (typeof obj.reactionCamEnabled !== "boolean") {
throw new TypeError(
`PrivacySettings: reactionCamEnabled must be a boolean, got ${typeof obj.reactionCamEnabled}`
);
}
}
if (typeof hpReactiveCamStylingEnabled !== "boolean") {
throw new TypeError(
`PrivacySettings: hpReactiveCamStylingEnabled must be a boolean, got ${typeof hpReactiveCamStylingEnabled}`
);
if ("hpReactiveCamStylingEnabled" in obj) {
if (typeof obj.hpReactiveCamStylingEnabled !== "boolean") {
throw new TypeError(
`PrivacySettings: hpReactiveCamStylingEnabled must be a boolean, got ${typeof obj.hpReactiveCamStylingEnabled}`
);
}
}
return /** @type {PrivacySettings} */ (data);
if ("customPortraitFallback" in obj) {
if (obj.customPortraitFallback !== null && typeof obj.customPortraitFallback !== "string") {
throw new TypeError(
`PrivacySettings: customPortraitFallback must be a string or null, got ${typeof obj.customPortraitFallback}`
);
}
// If it's a string, validate it's a valid DataURL
if (typeof obj.customPortraitFallback === "string") {
try {
validatePortraitDataURL(obj.customPortraitFallback);
} catch (e) {
throw new TypeError(
`PrivacySettings: customPortraitFallback ${e.message}`
);
}
}
}
return /** @type {PrivacySettings} */ (obj);
}
/**
+176 -1
View File
@@ -15,9 +15,11 @@ import {
PRIVACY_SETTINGS_DEFAULT,
PRIVACY_SETTING_KEYS,
FEATURE_NAME_MAP,
MAX_PORTRAIT_SIZE,
validateSettingKey,
validateSettingValue,
validateFeatureName,
validatePortraitDataURL,
} from "../contracts/privacy-settings.js";
/**
@@ -118,12 +120,19 @@ export class PlayerPrivacyManager {
* Emits change event to subscribers after successful update.
*
* @param {string} userId - The user ID to update settings for.
* @param {string} key - The setting key (must be in PRIVACY_SETTING_KEYS).
* @param {string} key - The setting key (must be in PRIVACY_SETTING_KEYS and not customPortraitFallback).
* @param {boolean} value - The new setting value.
* @returns {Promise<void>} Resolves when the setting is persisted.
* @throws {TypeError} If key is invalid, value is not boolean, or user doesn't exist.
*/
async setSetting(userId, key, value) {
// Reject customPortraitFallback - use dedicated method instead
if (key === "customPortraitFallback") {
throw new TypeError(
"PlayerPrivacyManager: customPortraitFallback must use setPortraitFallback() method"
);
}
// Validate key
validateSettingKey(key);
@@ -245,6 +254,172 @@ export class PlayerPrivacyManager {
}
}
/**
* Sets a custom portrait fallback DataURL for a user.
*
* Validates the DataURL format and MIME type before persistence.
* Emits change event with type 'portrait' to subscribers after successful update.
*
* @param {string} userId - The user ID to set portrait for.
* @param {string} dataURL - The DataURL string for the portrait image.
* @returns {Promise<void>} Resolves when the setting is persisted.
* @throws {TypeError} If dataURL is invalid, user doesn't exist, or user doesn't support setFlag.
*/
async setPortraitFallback(userId, dataURL) {
// Reject null/undefined/empty — use removePortraitFallback instead
if (dataURL === null || dataURL === undefined || dataURL === "") {
throw new TypeError(
"PlayerPrivacyManager: setPortraitFallback requires a non-empty DataURL string. Use removePortraitFallback() to remove the custom portrait."
);
}
// Validate DataURL format
validatePortraitDataURL(dataURL);
// Check DataURL size before storage
// Measure decoded binary size (base64 ~33% overhead)
const commaIndex = dataURL.indexOf(",");
const base64Data = commaIndex !== -1 ? dataURL.substring(commaIndex + 1) : dataURL;
const decodedBytes = Math.ceil(base64Data.length * 3 / 4);
if (decodedBytes > MAX_PORTRAIT_SIZE) {
throw new TypeError(
`PlayerPrivacyManager: DataURL decoded size (${decodedBytes} bytes) exceeds MAX_PORTRAIT_SIZE (${MAX_PORTRAIT_SIZE} bytes)`
);
}
// Get user
const user = this._adapter.users.get(userId);
if (!user) {
throw new TypeError(
`PlayerPrivacyManager: User '${userId}' not found`
);
}
// Validate user has setFlag method
if (typeof user.setFlag !== "function") {
throw new TypeError(
`PlayerPrivacyManager: User '${userId}' does not support setFlag`
);
}
// Get previous value for change event
const previousValue = this.getPortraitFallback(userId);
// Persist the setting via user flag
await user.setFlag("video-view-manager", "customPortraitFallback", dataURL);
// Notify subscribers with special portrait type
this._notifyPortraitChange(userId, dataURL, previousValue);
}
/**
* Retrieves the custom portrait fallback DataURL for a user.
*
* @param {string} userId - The user ID to retrieve portrait for.
* @returns {string|null} The DataURL string, or null if not set.
*/
getPortraitFallback(userId) {
const user = this._adapter.users.get(userId);
// Return null if user doesn't exist or has no getFlag
if (!user || typeof user.getFlag !== "function") {
return null;
}
const dataURL = user.getFlag("video-view-manager", "customPortraitFallback");
// Validate the stored DataURL (defensive programming)
if (dataURL !== null && dataURL !== undefined) {
try {
// Only accept string values
if (typeof dataURL !== "string") {
return null;
}
// Normalize empty string to null
if (dataURL === "") {
return null;
}
validatePortraitDataURL(dataURL);
return dataURL;
} catch (e) {
// Invalid stored DataURL - treat as null
console.warn(
`[ScryingPool] PlayerPrivacyManager: Invalid stored portrait DataURL for user '${userId}': ${e.message}`
);
return null;
}
}
return null;
}
/**
* Convenience method to get portrait fallback as DataURL directly.
* Same as getPortraitFallback but with explicit null return type.
*
* @param {string} userId - The user ID to retrieve portrait for.
* @returns {string|null} The DataURL string, or null if not set.
*/
getPortraitFallbackDataURL(userId) {
return this.getPortraitFallback(userId);
}
/**
* Removes the custom portrait fallback for a user.
*
* Emits change event with type 'portrait' to subscribers after successful removal.
*
* @param {string} userId - The user ID to remove portrait for.
* @returns {Promise<void>} Resolves when the setting is removed.
* @throws {TypeError} If user doesn't exist or user doesn't support unsetFlag.
*/
async removePortraitFallback(userId) {
const user = this._adapter.users.get(userId);
if (!user) {
throw new TypeError(
`PlayerPrivacyManager: User '${userId}' not found`
);
}
// Validate user has unsetFlag method
if (typeof user.unsetFlag !== "function") {
throw new TypeError(
`PlayerPrivacyManager: User '${userId}' does not support unsetFlag`
);
}
// Get previous value for change event
const previousValue = this.getPortraitFallback(userId);
// Remove the setting via user flag
await user.unsetFlag("video-view-manager", "customPortraitFallback");
// Notify subscribers with special portrait type
this._notifyPortraitChange(userId, null, previousValue);
}
/**
* Notifies all subscribers of a portrait change.
*
* @private
* @param {string} userId - The user ID whose portrait changed.
* @param {string|null} newValue - The new portrait DataURL (or null if removed).
* @param {string|null} previousValue - The previous portrait DataURL (or null).
*/
_notifyPortraitChange(userId, newValue, previousValue) {
for (const callback of this._subscribers) {
try {
callback(userId, "customPortraitFallback", newValue, previousValue);
} catch (err) {
// Swallow subscriber errors to prevent one bad subscriber from breaking others
console.error(
`[ScryingPool] PlayerPrivacyManager portrait subscriber error:`,
err
);
}
}
}
/**
* Cleans up internal state.
* Safe to call multiple times.
+273
View File
@@ -0,0 +1,273 @@
/**
* PortraitFallbackHandler — Manages portrait fallback image resolution and file handling.
*
* Owns: portrait fallback URL resolution, file validation, DataURL conversion, and change notifications.
* No direct game.* access — all Foundry API access via injected adapter.
* No socket broadcasting — portrait settings are client-local (user flags).
*
* Import rule: may only import from src/contracts/ and src/utils/.
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
*
* @module core/PortraitFallbackHandler
*/
import {
MAX_PORTRAIT_SIZE,
VALID_PORTRAIT_FORMATS,
} from "../contracts/privacy-settings.js";
/**
* Default system placeholder avatar URL.
* Falls back to FoundryVTT's default avatar if available.
* @type {string}
*/
const DEFAULT_PLACEHOLDER_URL = "icons/svg/mystery-man.svg";
/**
* Manages portrait fallback image resolution for participants.
*
* Resolves the portrait to display when a participant's camera is unavailable
* (never-connected or cam-lost states) using the priority:
* 1. Custom Portrait Fallback (from user flag)
* 2. FoundryVTT user avatar
* 3. System placeholder
*
* Handles file upload, validation, and conversion to DataURL.
* Emits change events for UI updates.
*/
export class PortraitFallbackHandler {
/**
* @param {import('../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* Injected FoundryAdapter surface for user/avatar access.
* @param {import('./PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
* Injected PlayerPrivacyManager for custom portrait access.
* @throws {TypeError} If adapter or playerPrivacyManager is invalid.
*/
constructor(adapter, playerPrivacyManager) {
// Validate adapter
if (!adapter || typeof adapter !== "object") {
throw new TypeError(
"PortraitFallbackHandler: adapter argument is required and must be an object"
);
}
// Validate adapter.users
if (!adapter.users || typeof adapter.users !== "object") {
throw new TypeError(
"PortraitFallbackHandler: adapter.users must be an object"
);
}
// Validate adapter.users.get
if (!adapter.users.get || typeof adapter.users.get !== "function") {
throw new TypeError(
"PortraitFallbackHandler: adapter.users.get must be a function"
);
}
// Validate playerPrivacyManager
if (!playerPrivacyManager || typeof playerPrivacyManager !== "object") {
throw new TypeError(
"PortraitFallbackHandler: playerPrivacyManager argument is required and must be an object"
);
}
this._adapter = adapter;
this._playerPrivacyManager = playerPrivacyManager;
this._subscribers = new Set();
this._initBound = false;
}
/**
* Initializes change forwarding from PlayerPrivacyManager to this handler's subscribers.
* Must be called once during module initialization (side-effect-free constructor pattern).
*/
init() {
if (this._initBound) return;
this._initBound = true;
this._playerPrivacyManager.onChange((userId, key, newValue, previousValue) => {
if (key === "customPortraitFallback") {
this._notifyPortraitChange(userId, newValue, previousValue);
}
});
}
/**
* Resolves the fallback image URL for a participant.
*
* Priority order:
* 1. Custom Portrait Fallback (from user flag) - if set
* 2. FoundryVTT user avatar - if available
* 3. System placeholder - always available
*
* @param {string} userId - The user ID to resolve portrait for.
* @returns {string|null} The resolved image URL, or null if user doesn't exist.
*/
getFallbackImageURL(userId) {
// Check if user exists
const user = this._adapter.users.get(userId);
if (!user) {
return null;
}
// Check custom portrait fallback first
const custom = this._playerPrivacyManager.getPortraitFallbackDataURL(userId);
if (custom) {
return custom;
}
// Check FoundryVTT user avatar
if (user.avatar) {
return user.avatar;
}
// Return system placeholder
return DEFAULT_PLACEHOLDER_URL;
}
/**
* Creates an img element for the fallback portrait.
*
* @param {string} userId - The user ID to create portrait element for.
* @returns {HTMLImageElement} The img element ready for mounting.
*/
getFallbackImageElement(userId) {
const url = this.getFallbackImageURL(userId);
const user = this._adapter.users.get(userId);
const img = document.createElement("img");
img.src = url ?? DEFAULT_PLACEHOLDER_URL;
img.className = "sp-portrait-fallback";
img.dataset.spRole = "portrait-fallback";
// Set alt text based on user name
if (user && user.name) {
img.alt = `${user.name}'s portrait`;
} else {
img.alt = "Participant portrait";
}
// Set dimensions to match AV tile
img.style.width = "100%";
img.style.height = "100%";
img.style.objectFit = "cover";
return img;
}
/**
* Validates a file from the file picker for portrait upload.
*
* @param {File} file - The file object from the file picker.
* @returns {{ valid: boolean, error?: string, dataURL?: string }} Validation result.
*/
static validatePortraitFile(file) {
// Validate file object
if (!(file && typeof file === "object")) {
return { valid: false, error: "Invalid file object" };
}
if (!file.type || typeof file.type !== "string") {
return { valid: false, error: "File has no detectable MIME type" };
}
if (file.size === undefined || typeof file.size !== "number") {
return { valid: false, error: "File has no detectable size" };
}
// Validate MIME type
if (!VALID_PORTRAIT_FORMATS.includes(file.type.toLowerCase())) {
return {
valid: false,
error: `Unsupported format: ${file.type}. Supported: ${VALID_PORTRAIT_FORMATS.join(", ")}`,
};
}
// Validate file size
if (file.size > MAX_PORTRAIT_SIZE) {
return {
valid: false,
error: `File is too large. Maximum size: ${MAX_PORTRAIT_SIZE / (1024 * 1024)}MB`,
};
}
return { valid: true };
}
/**
* Converts a File object to a DataURL string.
*
* @param {File} file - The file to convert.
* @returns {Promise<string>} The DataURL string.
*/
static fileToDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
resolve(result);
} else {
reject(new TypeError("FileReader produced non-string result"));
}
};
reader.onerror = () => {
reject(new TypeError("FileReader error: failed to read file"));
};
reader.onabort = () => {
reject(new TypeError("FileReader error: read aborted"));
};
reader.readAsDataURL(file);
});
}
/**
* Subscribes to portrait change events.
*
* @param {function(userId: string, newValue: string|null, previousValue: string|null): void} callback
* Callback function invoked on portrait changes.
* @returns {function(): void} Unsubscribe function.
*/
onPortraitChange(callback) {
this._subscribers.add(callback);
return () => {
this._subscribers.delete(callback);
};
}
/**
* Notifies all subscribers of a portrait change.
*
* @private
* @param {string} userId - The user ID whose portrait changed.
* @param {string|null} newValue - The new portrait DataURL or null.
* @param {string|null} previousValue - The previous portrait DataURL or null.
*/
_notifyPortraitChange(userId, newValue, previousValue) {
for (const callback of this._subscribers) {
try {
callback(userId, newValue, previousValue);
} catch (err) {
console.error(
"[ScryingPool] PortraitFallbackHandler subscriber error:",
err
);
}
}
}
/**
* Cleans up internal state.
* Safe to call multiple times.
*/
teardown() {
this._subscribers.clear();
}
}
+24 -5
View File
@@ -12,12 +12,14 @@ export class RoleRenderer {
* @param {object} controller - ScryingPoolController instance
* @param {object} avTileAdapter - AVTileAdapter instance
* @param {object} adapter - FoundryAdapter instance
* @param {object} [portraitFallbackHandler] - PortraitFallbackHandler instance (Story 4.2)
*/
constructor(stateStore, controller, avTileAdapter, adapter) {
constructor(stateStore, controller, avTileAdapter, adapter, portraitFallbackHandler = null) {
this._stateStore = stateStore;
this._controller = controller;
this._avTileAdapter = avTileAdapter;
this._adapter = adapter;
this._portraitFallbackHandler = portraitFallbackHandler;
/** @type {ScryingPoolStrip|null} */
this._strip = null;
}
@@ -39,6 +41,16 @@ export class RoleRenderer {
this._strip.render(true);
}
});
// Story 4.2: Refresh AV tile portrait when custom portrait changes
if (this._portraitFallbackHandler) {
this._portraitFallbackHandler.onPortraitChange((userId) => {
const state = this._stateStore.getState(userId);
if (state === 'never-connected' || state === 'cam-lost') {
this._applyAVTileState(userId, state);
}
});
}
}
/**
@@ -59,10 +71,17 @@ export class RoleRenderer {
lockEl.title = 'Camera hidden by GM';
this._avTileAdapter.mount(userId, lockEl);
} else if (CAMERA_ABSENT) {
const fallbackEl = document.createElement('div');
fallbackEl.className = 'sp-portrait-fallback';
fallbackEl.dataset.spRole = 'portrait-fallback';
this._avTileAdapter.mount(userId, fallbackEl);
// Story 4.2: Use PortraitFallbackHandler if available, otherwise fall back to generic div
if (this._portraitFallbackHandler) {
const portraitElement = this._portraitFallbackHandler.getFallbackImageElement(userId);
this._avTileAdapter.mount(userId, portraitElement);
} else {
// Backward compatibility: generic fallback div
const fallbackEl = document.createElement('div');
fallbackEl.className = 'sp-portrait-fallback';
fallbackEl.dataset.spRole = 'portrait-fallback';
this._avTileAdapter.mount(userId, fallbackEl);
}
} else {
this._avTileAdapter.unmount(userId);
}
+6 -2
View File
@@ -12,6 +12,7 @@ import { PlayerPrivacyPanel } from '../player/PlayerPrivacyPanel.js';
*/
let _adapter = null;
let _playerPrivacyManager = null;
let _portraitFallbackHandler = null;
/**
* Flag to track if dependencies have been initialized.
@@ -23,8 +24,9 @@ let _isInitialized = false;
* Called once during module initialization.
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
* @param {import('../../core/PortraitFallbackHandler.js').PortraitFallbackHandler} [portraitFallbackHandler]
*/
export function initGMPlayerPrivacySelector(adapter, playerPrivacyManager) {
export function initGMPlayerPrivacySelector(adapter, playerPrivacyManager, portraitFallbackHandler = null) {
if (!adapter || typeof adapter !== 'object') {
throw new TypeError('initGMPlayerPrivacySelector: adapter is required');
}
@@ -34,6 +36,7 @@ export function initGMPlayerPrivacySelector(adapter, playerPrivacyManager) {
_adapter = adapter;
_playerPrivacyManager = playerPrivacyManager;
_portraitFallbackHandler = portraitFallbackHandler;
_isInitialized = true;
// Register the settings menu
@@ -247,7 +250,8 @@ export class GMPlayerPrivacySelectorMenu {
const panel = new PlayerPrivacyPanel(
this._adapter,
this._playerPrivacyManager,
userId
userId,
_portraitFallbackHandler
);
this._panels.push(panel);
panel.render(true);
+209 -1
View File
@@ -1,4 +1,5 @@
// @ts-nocheck
import { PortraitFallbackHandler } from '../../core/PortraitFallbackHandler.js';
// Conditional base class — test environment lacks foundry globals.
// At module load time in tests, foundry is undefined → fallback class is used.
@@ -65,11 +66,13 @@ export class PlayerPrivacyPanel extends _AppBase {
* Injected FoundryAdapter surface.
* @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
* Injected PlayerPrivacyManager for privacy settings operations.
* @param {import('../../core/PortraitFallbackHandler.js').PortraitFallbackHandler} [portraitFallbackHandler]
* Injected PortraitFallbackHandler for portrait operations (Story 4.2).
* @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 = {}) {
constructor(adapter, playerPrivacyManager, targetUserId, portraitFallbackHandler = null, options = {}) {
// Validate dependencies
if (!adapter || typeof adapter !== 'object') {
throw new TypeError(
@@ -91,6 +94,7 @@ export class PlayerPrivacyPanel extends _AppBase {
this._adapter = adapter;
this._playerPrivacyManager = playerPrivacyManager;
this._portraitFallbackHandler = portraitFallbackHandler;
this._targetUserId = targetUserId;
// Cache for DOM elements
@@ -98,6 +102,12 @@ export class PlayerPrivacyPanel extends _AppBase {
this._reactionCamToggle = null;
/** @type {HTMLElement|null} */
this._hpReactiveCamToggle = null;
/** @type {HTMLElement|null} */
this._fileInput = null;
/** @type {HTMLElement|null} */
this._portraitPreview = null;
/** @type {boolean} */
this._uploading = false;
// Current settings state
/** @type {import('../../contracts/privacy-settings.js').PrivacySettings|null} */
@@ -120,6 +130,17 @@ export class PlayerPrivacyPanel extends _AppBase {
const isOwnUser = this._targetUserId === currentUserId;
const isReadOnly = !isOwnUser;
// Story 4.2: Get portrait fallback data
let portraitPreviewURL = null;
let hasCustomPortrait = false;
if (this._portraitFallbackHandler) {
const custom = this._playerPrivacyManager.getPortraitFallback(this._targetUserId);
hasCustomPortrait = custom !== null && custom !== undefined && custom !== '';
portraitPreviewURL = hasCustomPortrait
? custom
: this._portraitFallbackHandler.getFallbackImageURL(this._targetUserId);
}
return {
// Panel metadata
title: i18n.localize('SCRYING_POOL.PrivacyPanel.title'),
@@ -148,6 +169,15 @@ export class PlayerPrivacyPanel extends _AppBase {
toggleOnLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOn'),
toggleOffLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOff'),
// Story 4.2: Portrait fallback section
hasPortraitSection: !!this._portraitFallbackHandler,
portraitLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.portraitFallbackLabel'),
portraitDescription: i18n.localize('SCRYING_POOL.PrivacyPanel.portraitFallbackDescription'),
chooseImageLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.chooseImageLabel'),
removeImageLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.removeImageLabel'),
hasCustomPortrait,
portraitPreviewURL,
// State
isReadOnly,
isOwnUser,
@@ -163,6 +193,9 @@ export class PlayerPrivacyPanel extends _AppBase {
this._reactionCamToggle = element.querySelector('[data-setting="reactionCamEnabled"]');
this._hpReactiveCamToggle = element.querySelector('[data-setting="hpReactiveCamStylingEnabled"]');
// Story 4.2: Set up portrait section event handlers
this._setupPortraitHandlers(element);
// Set up toggle change handlers
this._setupToggleHandlers(element);
}
@@ -179,6 +212,179 @@ export class PlayerPrivacyPanel extends _AppBase {
}
}
/**
* Sets up event handlers for portrait fallback section.
* @param {HTMLElement} element - The dialog element.
*/
_setupPortraitHandlers(element) {
if (!this._portraitFallbackHandler || this._isReadOnlyMode()) {
return;
}
// Cache portrait elements
this._fileInput = element.querySelector('.player-privacy-panel__portrait-input');
this._portraitPreview = element.querySelector('.player-privacy-panel__portrait-preview img');
// Find and set up the choose image button
const chooseButton = element.querySelector('.player-privacy-panel__portrait-choose');
if (chooseButton) {
chooseButton.addEventListener('click', () => this._onChooseImageClick());
}
// Find and set up the remove button (only shown when custom portrait exists)
const removeButton = element.querySelector('.player-privacy-panel__portrait-remove');
if (removeButton) {
removeButton.addEventListener('click', () => this._onRemovePortraitClick());
}
// Set up file input change handler
if (this._fileInput) {
this._fileInput.addEventListener('change', (event) => this._onFileSelected(event));
}
}
/**
* Handles click on Choose Image button.
* @private
*/
_onChooseImageClick() {
if (this._fileInput) {
this._fileInput.click();
}
}
/**
* Handles file selection from the file picker.
* @param {Event} event - The change event from the file input.
* @private
*/
async _onFileSelected(event) {
if (this._uploading) return;
const file = event.target?.files?.[0];
if (!file) return;
this._uploading = true;
// Validate the file
const validation = PortraitFallbackHandler.validatePortraitFile(file);
if (!validation.valid) {
this._adapter.notifications.error(validation.error);
// Reset the file input
if (this._fileInput) {
this._fileInput.value = '';
}
this._uploading = false;
return;
}
try {
// Convert file to DataURL
const dataURL = await PortraitFallbackHandler.fileToDataURL(file);
// Validate the DataURL
const result = this._playerPrivacyManager.setPortraitFallback(
this._targetUserId,
dataURL
);
// Wait for the async operation
await result;
// Update the preview and notification
if (this._portraitPreview) {
this._portraitPreview.src = dataURL;
}
// Reset the file input
if (this._fileInput) {
this._fileInput.value = '';
}
// Update our cached state
if (this._currentSettings) {
this._currentSettings.customPortraitFallback = dataURL;
}
// Show success notification
this._adapter.notifications.info(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.portraitSaved')
);
// Re-render to update the UI (show remove button, etc.)
// Guard against stale render after panel has been closed
if (this.rendered) {
this.render(true);
}
} catch (err) {
console.error('[ScryingPool] PlayerPrivacyPanel: failed to set portrait:', err);
// Reset the file input
if (this._fileInput) {
this._fileInput.value = '';
}
// 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.portraitErrorInvalid')
);
}
} finally {
this._uploading = false;
}
}
/**
* Handles click on Remove custom image button.
* @private
*/
async _onRemovePortraitClick() {
if (this._isReadOnlyMode()) return;
try {
// Show confirmation dialog
// Use FoundryVTT Dialog.confirm if available, fallback to browser confirm
let confirmed;
if (typeof Dialog !== 'undefined' && Dialog.confirm) {
confirmed = await Dialog.confirm({
title: this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.removeImageLabel'),
content: this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.removeImageConfirm'),
});
} else {
confirmed = window.confirm(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.removeImageConfirm')
);
}
if (!confirmed) return;
// Remove the custom portrait
await this._playerPrivacyManager.removePortraitFallback(this._targetUserId);
// Update our cached state
if (this._currentSettings) {
this._currentSettings.customPortraitFallback = null;
}
// Show success notification
this._adapter.notifications.info(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.portraitRemoved')
);
// Re-render to update the UI (guard against stale render)
if (this.rendered) {
this.render(true);
}
} catch (err) {
console.error('[ScryingPool] PlayerPrivacyPanel: failed to remove portrait:', err);
this._adapter.notifications.error(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.portraitErrorInvalid')
);
}
}
/**
* Handles toggle change events.
* @param {Event} event - The change event from the checkbox.
@@ -253,6 +459,8 @@ export class PlayerPrivacyPanel extends _AppBase {
// Clear cached elements
this._reactionCamToggle = null;
this._hpReactiveCamToggle = null;
this._fileInput = null;
this._portraitPreview = null;
this._currentSettings = null;
}
}
+5 -1
View File
@@ -13,6 +13,7 @@ import { PlayerPrivacyPanel } from './PlayerPrivacyPanel.js';
*/
let _adapter = null;
let _playerPrivacyManager = null;
let _portraitFallbackHandler = null;
/**
* Flag to track if dependencies have been initialized.
@@ -24,8 +25,9 @@ let _isInitialized = false;
* Called once during module initialization.
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
* @param {import('../../core/PortraitFallbackHandler.js').PortraitFallbackHandler} [portraitFallbackHandler]
*/
export function initPlayerPrivacyPanelMenu(adapter, playerPrivacyManager) {
export function initPlayerPrivacyPanelMenu(adapter, playerPrivacyManager, portraitFallbackHandler = null) {
if (!adapter || typeof adapter !== 'object') {
throw new TypeError('initPlayerPrivacyPanelMenu: adapter is required');
}
@@ -35,6 +37,7 @@ export function initPlayerPrivacyPanelMenu(adapter, playerPrivacyManager) {
_adapter = adapter;
_playerPrivacyManager = playerPrivacyManager;
_portraitFallbackHandler = portraitFallbackHandler;
_isInitialized = true;
}
@@ -75,6 +78,7 @@ export class PlayerPrivacyPanelMenu {
_adapter,
_playerPrivacyManager,
currentUser.id,
_portraitFallbackHandler,
options
);
}