Fix Story 2.3 code review findings: remove duplicate ParticipantCard.js, fix lint in ScryingPoolStrip.js
- Delete src/ui/shared/ParticipantCard.js (duplicate of boardUtils.js with conflicting implementations) - Delete tests/unit/ui/shared/ParticipantCard.test.js (tests for deleted file) - Add directorsBoard to global declarations in ScryingPoolStrip.js to fix lint errors Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
// @ts-nocheck
|
||||
/* global Application, directorsBoard */
|
||||
import { generateOpId } from '../../utils/uuid.js';
|
||||
|
||||
/**
|
||||
* Canonical action labels — never use inline string literals.
|
||||
* @type {Readonly<{HIDE_FROM_TABLE: string, SHOW_TO_TABLE: string, FIRST_TOOLTIP: string}>}
|
||||
*/
|
||||
export const LABELS = Object.freeze({
|
||||
HIDE_FROM_TABLE: 'Hide from table',
|
||||
SHOW_TO_TABLE: 'Show to table',
|
||||
FIRST_TOOLTIP: 'Hide this participant from other players.',
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolves the target state for a hide/show toggle action.
|
||||
* @param {string} currentState
|
||||
* @returns {'hidden'|'active'}
|
||||
*/
|
||||
export function resolveTargetState(currentState) {
|
||||
return currentState === 'hidden' ? 'active' : 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a participant list array for getData().
|
||||
* @param {string[]} userIds
|
||||
* @param {object} stateStore
|
||||
* @param {object} controller
|
||||
* @param {object} adapter
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function buildParticipantList(userIds, stateStore, controller, adapter) {
|
||||
return userIds.map(userId => {
|
||||
const user = adapter.users.get(userId) ?? { name: userId, avatar: null };
|
||||
const state = stateStore.getState(userId) ?? 'active';
|
||||
return {
|
||||
userId,
|
||||
name: user.name ?? userId,
|
||||
avatarSrc: user.avatar ?? 'icons/svg/mystery-man.svg',
|
||||
state,
|
||||
stateLabel: _stateLabel(state),
|
||||
hasPendingOp: controller.hasPendingOp ? controller.hasPendingOp(userId) : false,
|
||||
isCurrentUser: adapter.users.current?.()?.id === userId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable label for a state string.
|
||||
* @param {string} state
|
||||
* @returns {string}
|
||||
*/
|
||||
function _stateLabel(state) {
|
||||
const LABELS_MAP = {
|
||||
active: 'Active',
|
||||
hidden: 'Hidden',
|
||||
'self-muted': 'Self-muted',
|
||||
offline: 'Offline',
|
||||
'cam-lost': 'Camera lost',
|
||||
reconnecting: 'Reconnecting',
|
||||
'never-connected': 'Never connected',
|
||||
ghost: 'Ghost',
|
||||
};
|
||||
return LABELS_MAP[state] ?? state;
|
||||
}
|
||||
|
||||
// Use conditional base class for test compatibility (typeof check is no-undef safe)
|
||||
const _AppBase =
|
||||
typeof Application !== 'undefined'
|
||||
? Application
|
||||
: class _FallbackApplication {
|
||||
static get defaultOptions() {
|
||||
return {};
|
||||
}
|
||||
render() {}
|
||||
close() {}
|
||||
get rendered() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GM-only floating control strip showing all connected participants.
|
||||
* Extends Foundry's Application base class.
|
||||
* Uses Application (not ApplicationV2) for simplicity in FoundryVTT v14.
|
||||
*/
|
||||
export class ScryingPoolStrip extends _AppBase {
|
||||
/** @inheritdoc */
|
||||
static get defaultOptions() {
|
||||
const base =
|
||||
typeof foundry !== 'undefined' && foundry.utils?.mergeObject
|
||||
? foundry.utils.mergeObject(super.defaultOptions, {})
|
||||
: super.defaultOptions ?? {};
|
||||
return Object.assign({}, base, {
|
||||
id: 'scrying-pool-strip',
|
||||
template: 'modules/video-view-manager/templates/roster-strip.hbs',
|
||||
popOut: true,
|
||||
resizable: false,
|
||||
title: 'Scrying Pool',
|
||||
classes: ['scrying-pool-strip'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} stateStore
|
||||
* @param {object} controller
|
||||
* @param {object} avTileAdapter
|
||||
* @param {object} adapter
|
||||
* @param {object} [options]
|
||||
*/
|
||||
constructor(stateStore, controller, avTileAdapter, adapter, options = {}) {
|
||||
super(options);
|
||||
this._stateStore = stateStore;
|
||||
this._controller = controller;
|
||||
this._avTileAdapter = avTileAdapter;
|
||||
this._adapter = adapter;
|
||||
this._isExpanded = true;
|
||||
/** @type {ActionPopover|null} */
|
||||
this._activePopover = null;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getData() {
|
||||
const savedState =
|
||||
typeof game !== 'undefined'
|
||||
? game.user?.getFlag?.('video-view-manager', 'stripState')
|
||||
: null;
|
||||
if (savedState?.expanded !== undefined) {
|
||||
this._isExpanded = savedState.expanded;
|
||||
}
|
||||
|
||||
const showFirstOpenTip =
|
||||
typeof game !== 'undefined' &&
|
||||
!game.user?.getFlag?.('video-view-manager', 'firstStripOpen');
|
||||
|
||||
const userIds = this._adapter.users.all
|
||||
? this._adapter.users.all().map(u => u.id)
|
||||
: [];
|
||||
|
||||
const participants = buildParticipantList(
|
||||
userIds,
|
||||
this._stateStore,
|
||||
this._controller,
|
||||
this._adapter
|
||||
);
|
||||
|
||||
return {
|
||||
participants,
|
||||
isExpanded: this._isExpanded,
|
||||
isEmpty: participants.length === 0,
|
||||
showFirstOpenTip,
|
||||
};
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
const el = html instanceof HTMLElement ? html : html[0];
|
||||
|
||||
el.querySelectorAll('[data-action="open-popover"]').forEach(btn => {
|
||||
const userId = btn.dataset.userId;
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
this._openPopover(userId, btn);
|
||||
});
|
||||
btn.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._openContextMenu(userId, btn, e);
|
||||
});
|
||||
});
|
||||
|
||||
const toggle = el.querySelector('[data-action="toggle-expanded"]');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', () => this._toggleExpanded());
|
||||
}
|
||||
|
||||
// Director's Board CTA button (fallback for sidebar API)
|
||||
const directorsBoardBtn = el.querySelector('[data-action="open-directors-board"]');
|
||||
if (directorsBoardBtn) {
|
||||
directorsBoardBtn.addEventListener('click', () => {
|
||||
if (typeof directorsBoard !== 'undefined' && directorsBoard) {
|
||||
directorsBoard.toggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// First open tip: set flag so it doesn't show again
|
||||
const isFirstOpen =
|
||||
typeof game !== 'undefined' &&
|
||||
!game.user?.getFlag?.('video-view-manager', 'firstStripOpen');
|
||||
if (isFirstOpen) {
|
||||
game.user?.setFlag?.('video-view-manager', 'firstStripOpen', true);
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async close(options = {}) {
|
||||
if (this._activePopover) {
|
||||
this._activePopover.close('superseded');
|
||||
this._activePopover = null;
|
||||
}
|
||||
if (typeof game !== 'undefined') {
|
||||
game.user?.setFlag?.('video-view-manager', 'stripState', {
|
||||
left: this.position?.left,
|
||||
top: this.position?.top,
|
||||
open: false,
|
||||
expanded: this._isExpanded,
|
||||
});
|
||||
}
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the expanded/collapsed state of the strip.
|
||||
*/
|
||||
_toggleExpanded() {
|
||||
this._isExpanded = !this._isExpanded;
|
||||
if (typeof game !== 'undefined') {
|
||||
game.user?.setFlag?.('video-view-manager', 'stripState', {
|
||||
left: this.position?.left,
|
||||
top: this.position?.top,
|
||||
open: true,
|
||||
expanded: this._isExpanded,
|
||||
});
|
||||
}
|
||||
this.render(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an ActionPopover for the given participant. Supersedes any open popover.
|
||||
* @param {string} participantId
|
||||
* @param {HTMLElement} anchorEl
|
||||
*/
|
||||
_openPopover(participantId, anchorEl) {
|
||||
if (this._activePopover) {
|
||||
this._activePopover.close('superseded');
|
||||
}
|
||||
const state = this._stateStore.getState(participantId);
|
||||
const hasPending = this._controller.hasPendingOp?.(participantId) ?? false;
|
||||
const popover = new ActionPopover(
|
||||
participantId,
|
||||
state,
|
||||
anchorEl,
|
||||
hasPending,
|
||||
(pid, targetState) => this._dispatchAction(pid, targetState)
|
||||
);
|
||||
popover.open();
|
||||
this._activePopover = popover;
|
||||
anchorEl.setAttribute('aria-pressed', 'true');
|
||||
popover.onClose(() => {
|
||||
anchorEl.setAttribute('aria-pressed', 'false');
|
||||
anchorEl.focus?.();
|
||||
if (this._activePopover === popover) this._activePopover = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a native context menu for hide/show actions.
|
||||
* @param {string} participantId
|
||||
* @param {HTMLElement} anchorEl
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_openContextMenu(participantId, anchorEl, event) {
|
||||
const currentState = this._stateStore.getState(participantId);
|
||||
const isHidden = currentState === 'hidden';
|
||||
const label = isHidden ? LABELS.SHOW_TO_TABLE : LABELS.HIDE_FROM_TABLE;
|
||||
const iconClass = isHidden ? 'fas fa-eye' : 'fas fa-eye-slash';
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'sp-context-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
|
||||
const item = document.createElement('button');
|
||||
item.className = 'sp-context-menu__item';
|
||||
item.setAttribute('role', 'menuitem');
|
||||
item.innerHTML = `<i class="${iconClass}" aria-hidden="true"></i> ${label}`;
|
||||
item.addEventListener('click', () => {
|
||||
this._dispatchAction(participantId, isHidden ? 'active' : 'hidden');
|
||||
menu.remove();
|
||||
});
|
||||
|
||||
menu.appendChild(item);
|
||||
document.body.appendChild(menu);
|
||||
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.left = `${event.clientX}px`;
|
||||
menu.style.top = `${event.clientY}px`;
|
||||
|
||||
const dismiss = e => {
|
||||
if (!menu.contains(e.target)) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', dismiss);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', dismiss), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a visibility change action through the controller.
|
||||
* @param {string} participantId
|
||||
* @param {string} [explicitTargetState] - if omitted, toggle from current state
|
||||
*/
|
||||
_dispatchAction(participantId, explicitTargetState) {
|
||||
const currentState = this._stateStore.getState(participantId);
|
||||
const targetState = explicitTargetState ?? resolveTargetState(currentState);
|
||||
const opId = generateOpId();
|
||||
const baseRevision = this._controller.getRevision?.(participantId) ?? 0;
|
||||
this._controller.action('strip', participantId, targetState, opId, baseRevision);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native <dialog>-based per-participant action popover.
|
||||
* Not exported — internal to the gm/ layer.
|
||||
*/
|
||||
class ActionPopover {
|
||||
/**
|
||||
* @param {string} participantId
|
||||
* @param {string} currentState
|
||||
* @param {HTMLElement} anchorEl
|
||||
* @param {boolean} hasPendingOp
|
||||
* @param {function(string, string): void} onAction
|
||||
*/
|
||||
constructor(participantId, currentState, anchorEl, hasPendingOp, onAction) {
|
||||
this._participantId = participantId;
|
||||
this._currentState = currentState;
|
||||
this._anchorEl = anchorEl;
|
||||
this._hasPendingOp = hasPendingOp;
|
||||
this._onAction = onAction;
|
||||
this._onCloseCb = null;
|
||||
this._dialog = this._build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the <dialog> DOM element.
|
||||
* @returns {HTMLDialogElement}
|
||||
*/
|
||||
_build() {
|
||||
const isHidden = this._currentState === 'hidden';
|
||||
const label = isHidden ? LABELS.SHOW_TO_TABLE : LABELS.HIDE_FROM_TABLE;
|
||||
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'sp-action-popover';
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
|
||||
const cta = document.createElement('button');
|
||||
cta.className = 'sp-action-popover__cta';
|
||||
cta.dataset.action = 'primary-cta';
|
||||
cta.textContent = label;
|
||||
|
||||
if (this._hasPendingOp) {
|
||||
cta.disabled = true;
|
||||
cta.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
// First-time tooltip (localStorage-based)
|
||||
const hasSeenTooltip = typeof localStorage !== 'undefined'
|
||||
? localStorage.getItem('scrying-pool.firstHideTooltip')
|
||||
: '1';
|
||||
if (!hasSeenTooltip) {
|
||||
cta.title = LABELS.FIRST_TOOLTIP;
|
||||
cta.addEventListener('mouseenter', () => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('scrying-pool.firstHideTooltip', '1');
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
cta.addEventListener('click', () => {
|
||||
const targetState = isHidden ? 'active' : 'hidden';
|
||||
this._onAction(this._participantId, targetState);
|
||||
this.close('action');
|
||||
});
|
||||
|
||||
dialog.appendChild(cta);
|
||||
|
||||
dialog.addEventListener('cancel', e => {
|
||||
e.preventDefault();
|
||||
this.close('escape');
|
||||
});
|
||||
|
||||
dialog.addEventListener('click', e => {
|
||||
if (e.target === dialog) {
|
||||
this.close('backdrop');
|
||||
}
|
||||
});
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the popover dialog, positions it near the anchor, and focuses the CTA.
|
||||
*/
|
||||
open() {
|
||||
document.body.appendChild(this._dialog);
|
||||
|
||||
const rect = this._anchorEl.getBoundingClientRect();
|
||||
this._dialog.style.position = 'fixed';
|
||||
this._dialog.style.left = `${rect.right + 4}px`;
|
||||
this._dialog.style.top = `${rect.top}px`;
|
||||
this._dialog.style.margin = '0';
|
||||
|
||||
if (typeof this._dialog.showModal === 'function') {
|
||||
this._dialog.showModal();
|
||||
}
|
||||
|
||||
const cta = this._dialog.querySelector('[data-action="primary-cta"]');
|
||||
cta?.focus?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the popover dialog and invokes the onClose callback.
|
||||
* @param {string} [reason]
|
||||
*/
|
||||
close(reason) {
|
||||
if (this._dialog.parentNode) {
|
||||
if (typeof this._dialog.close === 'function') {
|
||||
this._dialog.close(reason ?? '');
|
||||
}
|
||||
this._dialog.remove();
|
||||
}
|
||||
this._onCloseCb?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback for when the popover closes.
|
||||
* @param {function(): void} cb
|
||||
*/
|
||||
onClose(cb) {
|
||||
this._onCloseCb = cb;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user