mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
The video modal's ⋯ trigger declares its context via `data-more-dropdown="modal"`, but attachMoreMenuHandlers read only `data-context`, so it defaulted to "card", skipped the modal video resolution, and opened the popover with a null video. The panel is built from that video, so every menu item got an empty data-event-id and both Event Details and Popularity reported "No video selected." - Read context from `data-more-dropdown` as well as `data-context`. - Modal branch falls back to the modal's `activeVideo` when getCurrentVideo() is null (deep-link opens don't set the playback service's currentVideo). - Defense-in-depth: handleEventDetailsAction resolves the video from app.videosMap.get(eventId); view-stats resolves via a new getVideoByEventId callback instead of a risky `|| currentVideo` that could show the wrong stats. - Regression tests in tests/more-menu-controller.test.mjs. Co-Authored-By: Claude Opus 4.8 <[email protected]>
362 lines
11 KiB
JavaScript
362 lines
11 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import MoreMenuController from '../js/ui/moreMenuController.js';
|
|
|
|
test('copy-link action writes to clipboard and shows success', async () => {
|
|
let copied = null;
|
|
const clipboard = {
|
|
writeText: async (text) => {
|
|
copied = text;
|
|
},
|
|
};
|
|
|
|
const messages = { error: [], success: [] };
|
|
|
|
const controller = new MoreMenuController({
|
|
clipboard,
|
|
callbacks: {
|
|
buildShareUrlFromEventId: (eventId) => `https://example.com/watch/${eventId}`,
|
|
showError: (message) => messages.error.push(message),
|
|
showSuccess: (message) => messages.success.push(message),
|
|
getCurrentVideo: () => ({ id: 'fallback-id' }),
|
|
},
|
|
});
|
|
|
|
await controller.handleMoreMenuAction('copy-link', { eventId: 'abc123' });
|
|
await Promise.resolve();
|
|
|
|
assert.equal(copied, 'https://example.com/watch/abc123');
|
|
assert.deepEqual(messages.error, []);
|
|
assert.deepEqual(messages.success, ['Video link copied to clipboard!']);
|
|
});
|
|
|
|
test('blacklist-author requires moderator access and refreshes subscriptions', async () => {
|
|
const calls = [];
|
|
const accessControl = {
|
|
ensureReady: async () => {
|
|
calls.push('ensureReady');
|
|
},
|
|
canEditAdminLists: (actorNpub) => {
|
|
calls.push(['canEdit', actorNpub]);
|
|
return true;
|
|
},
|
|
addToBlacklist: async (actorNpub, targetNpub) => {
|
|
calls.push(['add', actorNpub, targetNpub]);
|
|
return { ok: true };
|
|
},
|
|
};
|
|
|
|
let refreshedWith = null;
|
|
|
|
const controller = new MoreMenuController({
|
|
accessControl,
|
|
callbacks: {
|
|
getCurrentUserNpub: () => 'npub1actor',
|
|
getCurrentVideo: () => ({ pubkey: 'authorhex' }),
|
|
safeEncodeNpub: (pubkey) => `npub:${pubkey}`,
|
|
showError: (message) => {
|
|
throw new Error(`Unexpected error: ${message}`);
|
|
},
|
|
showSuccess: (message) => {
|
|
calls.push(['success', message]);
|
|
},
|
|
canCurrentUserManageBlacklist: () => true,
|
|
refreshAllVideoGrids: async (args) => {
|
|
refreshedWith = args;
|
|
},
|
|
},
|
|
});
|
|
|
|
await controller.handleMoreMenuAction('blacklist-author', {
|
|
author: 'authorhex',
|
|
});
|
|
|
|
assert.deepEqual(calls, [
|
|
'ensureReady',
|
|
['canEdit', 'npub1actor'],
|
|
['add', 'npub1actor', 'npub:authorhex'],
|
|
['success', 'Creator added to the blacklist.'],
|
|
]);
|
|
assert.deepEqual(refreshedWith, {
|
|
reason: 'admin-blacklist-update',
|
|
forceMainReload: true,
|
|
});
|
|
});
|
|
|
|
test('blacklist-author shows error when no moderator session is available', async () => {
|
|
const errors = [];
|
|
const controller = new MoreMenuController({
|
|
callbacks: {
|
|
getCurrentUserNpub: () => null,
|
|
showError: (message) => errors.push(message),
|
|
getCurrentVideo: () => ({ pubkey: 'authorhex' }),
|
|
},
|
|
});
|
|
|
|
await controller.handleMoreMenuAction('blacklist-author', {
|
|
author: 'authorhex',
|
|
});
|
|
|
|
assert.deepEqual(errors, [
|
|
'Please login as a moderator to manage the blacklist.',
|
|
]);
|
|
});
|
|
|
|
test('block-author updates user blocks, reloads videos, and refreshes feeds', async () => {
|
|
const events = [];
|
|
const userBlocks = {
|
|
ensureLoaded: async (pubkey) => {
|
|
events.push(['ensureLoaded', pubkey]);
|
|
},
|
|
isBlocked: (pubkey) => {
|
|
events.push(['isBlocked', pubkey]);
|
|
return false;
|
|
},
|
|
addBlock: async (target, actor) => {
|
|
events.push(['addBlock', target, actor]);
|
|
},
|
|
};
|
|
|
|
const moderationStub = {
|
|
awaitUserBlockRefresh: async () => {
|
|
events.push(['awaitUserBlockRefresh']);
|
|
},
|
|
};
|
|
|
|
const controller = new MoreMenuController({
|
|
userBlocks,
|
|
moderationService: moderationStub,
|
|
callbacks: {
|
|
getCurrentUserPubkey: () => 'actorhex',
|
|
getCurrentVideo: () => ({ pubkey: 'fallbackhex' }),
|
|
safeDecodeNpub: (npub) => (npub === 'npub1target' ? 'targethex' : ''),
|
|
showError: (message) => {
|
|
throw new Error(`Unexpected error: ${message}`);
|
|
},
|
|
showSuccess: (message) => {
|
|
events.push(['success', message]);
|
|
},
|
|
onUserBlocksUpdated: () => {
|
|
events.push(['refreshBlockedList']);
|
|
},
|
|
refreshAllVideoGrids: async (args) => {
|
|
events.push(['refreshAllVideoGrids', args]);
|
|
},
|
|
},
|
|
});
|
|
|
|
await controller.handleMoreMenuAction('block-author', {
|
|
author: 'npub1target',
|
|
});
|
|
|
|
assert.deepEqual(events, [
|
|
['ensureLoaded', 'actorhex'],
|
|
['isBlocked', 'targethex'],
|
|
['addBlock', 'targethex', 'actorhex'],
|
|
['success', "Creator blocked. You won't see their videos anymore."],
|
|
['refreshBlockedList'],
|
|
['awaitUserBlockRefresh'],
|
|
[
|
|
'refreshAllVideoGrids',
|
|
{ reason: 'user-block-update', forceMainReload: true },
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('mute-author updates viewer mute list and refreshes feeds', async () => {
|
|
const events = [];
|
|
const moderationStub = {
|
|
isAuthorMutedByViewer: () => false,
|
|
};
|
|
const userBlocksStub = {
|
|
addMute: async (target, viewer) => {
|
|
events.push(['addMute', target, viewer]);
|
|
return { ok: true };
|
|
},
|
|
};
|
|
|
|
const controller = new MoreMenuController({
|
|
moderationService: moderationStub,
|
|
userBlocks: userBlocksStub,
|
|
callbacks: {
|
|
getCurrentUserPubkey: () => 'viewerhex',
|
|
getCurrentVideo: () => ({ pubkey: 'fallbackhex' }),
|
|
showSuccess: (message) => {
|
|
events.push(['success', message]);
|
|
},
|
|
showError: (message) => {
|
|
events.push(['error', message]);
|
|
},
|
|
safeDecodeNpub: () => '',
|
|
refreshAllVideoGrids: async (args) => {
|
|
events.push(['refreshAllVideoGrids', args]);
|
|
},
|
|
},
|
|
});
|
|
|
|
const targetHex = 'd'.repeat(64);
|
|
|
|
await controller.handleMoreMenuAction('mute-author', { author: targetHex });
|
|
|
|
assert.deepEqual(events, [
|
|
['addMute', targetHex, 'viewerhex'],
|
|
['success', 'Creator muted. Their videos will be downranked in your feeds.'],
|
|
[
|
|
'refreshAllVideoGrids',
|
|
{ reason: 'viewer-mute-update', forceMainReload: true },
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('unmute-author removes creators from viewer mute list', async () => {
|
|
const events = [];
|
|
const moderationStub = {
|
|
isAuthorMutedByViewer: () => true,
|
|
};
|
|
const userBlocksStub = {
|
|
removeMute: async (target, viewer) => {
|
|
events.push(['removeMute', target, viewer]);
|
|
return { ok: true };
|
|
},
|
|
};
|
|
|
|
const controller = new MoreMenuController({
|
|
moderationService: moderationStub,
|
|
userBlocks: userBlocksStub,
|
|
callbacks: {
|
|
getCurrentUserPubkey: () => 'viewerhex',
|
|
getCurrentVideo: () => ({ pubkey: 'fallbackhex' }),
|
|
showSuccess: (message) => {
|
|
events.push(['success', message]);
|
|
},
|
|
showError: (message) => {
|
|
events.push(['error', message]);
|
|
},
|
|
safeDecodeNpub: () => '',
|
|
refreshAllVideoGrids: async (args) => {
|
|
events.push(['refreshAllVideoGrids', args]);
|
|
},
|
|
},
|
|
});
|
|
|
|
const targetHex = 'e'.repeat(64);
|
|
|
|
await controller.handleMoreMenuAction('unmute-author', { author: targetHex });
|
|
|
|
assert.deepEqual(events, [
|
|
['removeMute', targetHex, 'viewerhex'],
|
|
['success', 'Creator removed from your mute list.'],
|
|
[
|
|
'refreshAllVideoGrids',
|
|
{ reason: 'viewer-mute-update', forceMainReload: true },
|
|
],
|
|
]);
|
|
});
|
|
|
|
// Regression: card popovers don't always carry the video object on the menu
|
|
// context. The view-stats and event-details actions must resolve the video from
|
|
// the menu item's eventId so the buttons work for cards (not just the modal).
|
|
test('event-details forwards the eventId so the app can resolve a card video', async () => {
|
|
let received = null;
|
|
const controller = new MoreMenuController({
|
|
callbacks: {
|
|
getCurrentVideo: () => null,
|
|
handleEventDetailsAction: async (payload) => {
|
|
received = payload;
|
|
},
|
|
showError: (message) => {
|
|
throw new Error(`Unexpected error: ${message}`);
|
|
},
|
|
},
|
|
});
|
|
|
|
// video === null mimics entry.context.video being absent on a card popover.
|
|
await controller.handleMoreMenuAction(
|
|
'event-details',
|
|
{ eventId: 'card-event-id', context: 'card' },
|
|
null,
|
|
);
|
|
|
|
assert.equal(received?.eventId, 'card-event-id');
|
|
assert.equal(received?.video, null);
|
|
});
|
|
|
|
test('view-stats looks the card video up by eventId instead of the modal video', async () => {
|
|
const resolvedVideo = { id: 'card-event-id', pubkey: 'authorhex' };
|
|
const lookups = [];
|
|
const controller = new MoreMenuController({
|
|
callbacks: {
|
|
// The old code used `|| currentVideo`, which would show this video's stats
|
|
// for any card whose popover lacked the object — the bug we're guarding.
|
|
getCurrentVideo: () => ({ id: 'wrong-modal-video' }),
|
|
getVideoByEventId: (eventId) => {
|
|
lookups.push(eventId);
|
|
return eventId === 'card-event-id' ? resolvedVideo : null;
|
|
},
|
|
showError: (message) => {
|
|
throw new Error(`Unexpected error: ${message}`);
|
|
},
|
|
},
|
|
});
|
|
|
|
// Drive the real action with no video object (card popover) in a DOM-less
|
|
// harness — openPopularityModal no-ops without a document, so we assert the
|
|
// controller resolved the right video by eventId before opening.
|
|
await controller.handleMoreMenuAction(
|
|
'view-stats',
|
|
{ eventId: 'card-event-id', context: 'card' },
|
|
null,
|
|
);
|
|
|
|
assert.deepEqual(lookups, ['card-event-id']);
|
|
});
|
|
|
|
// Regression: the modal ⋯ trigger declares its context via `data-more-dropdown`
|
|
// ("modal"), not `data-context`. Reading only `data-context` made it default to
|
|
// "card" and skip modal video resolution, so the popover opened with a null
|
|
// video and every action reported "No video selected." attachMoreMenuHandlers
|
|
// must treat the trigger as modal context and resolve the modal's active video.
|
|
test('modal ⋯ trigger resolves its video via data-more-dropdown + activeVideo fallback', async () => {
|
|
const activeVideo = { id: 'modal-video-id', pubkey: 'authorhex' };
|
|
|
|
const controller = new MoreMenuController({
|
|
callbacks: {
|
|
// currentVideo is null (e.g. deep-link open without playback) — the bug.
|
|
getCurrentVideo: () => null,
|
|
canCurrentUserManageBlacklist: () => false,
|
|
},
|
|
});
|
|
// The modal is the authoritative on-screen video source.
|
|
controller.setVideoModal({ activeVideo });
|
|
// Avoid the real DOM/popover engine; capture what toggleMoreMenu receives.
|
|
let toggled = null;
|
|
controller.toggleMoreMenu = (detail) => {
|
|
toggled = detail;
|
|
};
|
|
controller.ensureGlobalMoreMenuHandlers = () => {};
|
|
controller.getHTMLElementConstructor = () => null;
|
|
controller.document = {};
|
|
|
|
// Minimal trigger button mimicking the modal ⋯ markup.
|
|
let clickHandler = null;
|
|
const button = {
|
|
dataset: {},
|
|
getAttribute: (name) =>
|
|
name === 'data-more-dropdown' ? 'modal' : null,
|
|
addEventListener: (type, handler) => {
|
|
if (type === 'click') clickHandler = handler;
|
|
},
|
|
};
|
|
const container = {
|
|
querySelectorAll: () => [button],
|
|
};
|
|
|
|
controller.attachMoreMenuHandlers(container);
|
|
assert.equal(typeof clickHandler, 'function', 'click handler bound');
|
|
|
|
await clickHandler({ preventDefault() {}, stopPropagation() {} });
|
|
|
|
assert.equal(toggled?.context, 'modal');
|
|
assert.equal(toggled?.video, activeVideo);
|
|
});
|