Story 4.2 completed
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user