mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Prefer the best available YouTube thumbnail, fall back cleanly when high-resolution images are unavailable, and keep the React Doctor PR gate scoped to newly introduced issues.
1291 lines
50 KiB
TypeScript
1291 lines
50 KiB
TypeScript
import * as React from 'react';
|
||
import { createElement } from 'react';
|
||
import { createRoot, type Root } from 'react-dom/client';
|
||
import { MemoryRouter } from 'react-router-dom';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import ReplyModal from '../reply-modal';
|
||
import { OEKAKI_WEB_WARNING_TEXT } from '../../../lib/oekaki/oekaki-copy';
|
||
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
|
||
|
||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||
|
||
const testState = vi.hoisted(() => ({
|
||
account: { author: { address: 'alice.eth', displayName: 'Alice' } } as { author?: { address?: string; displayName?: string } },
|
||
closeModalMock: vi.fn(),
|
||
directoryByAddress: {
|
||
'music-posting.eth': {
|
||
address: 'music-posting.eth',
|
||
features: {},
|
||
title: '/mu/ - Music',
|
||
},
|
||
} as Record<string, { address: string; directoryCode?: string; features?: Record<string, unknown>; title?: string }>,
|
||
directoryListsByCode: {} as Record<
|
||
string,
|
||
{
|
||
boards: Array<{ address: string; features?: Record<string, unknown>; publicKey?: string }>;
|
||
directoryCode: string;
|
||
features?: Record<string, unknown>;
|
||
title?: string;
|
||
}
|
||
>,
|
||
handleUploadMock: vi.fn(),
|
||
uploadFileMock: vi.fn(),
|
||
isMobile: false,
|
||
mediaHostingRuntime: 'web' as 'web' | 'android' | 'electron',
|
||
isResolvingExternalQuotes: false,
|
||
isUploading: false,
|
||
navigateMock: vi.fn(),
|
||
offlineTitle: '' as string | false,
|
||
offlineStates: {} as Record<string, { isOffline: boolean; isOnlineStatusLoading: boolean; offlineTitle: string | false }>,
|
||
offlineStatusLoading: false,
|
||
offlineWarningVisible: false,
|
||
openEmpty: false,
|
||
publishReplyMock: vi.fn(),
|
||
publishReplyError: null as string | null,
|
||
publishReplyStateMessage: null as string | null,
|
||
quoteInsertNumber: undefined as number | undefined,
|
||
quoteInsertRequestId: 0,
|
||
quoteInsertSelectedText: '',
|
||
dragHandler: undefined as ((state: { active: boolean; event: Pick<Event, 'preventDefault'>; offset: [number, number] }) => void) | undefined,
|
||
replyIndex: undefined as number | undefined,
|
||
resetPublishReplyOptionsMock: vi.fn(),
|
||
resolvedCommunityAddress: undefined as string | undefined,
|
||
rolesByCommunity: {} as Record<string, Record<string, { role?: string }>>,
|
||
selectedText: 'selected text',
|
||
setAccountMock: vi.fn(),
|
||
setPublishReplyOptionsMock: vi.fn(),
|
||
springStartMock: vi.fn(),
|
||
useSpringMock: vi.fn(),
|
||
communities: {
|
||
'music-posting.eth': {
|
||
address: 'music-posting.eth',
|
||
},
|
||
} as Record<string, { address: string }>,
|
||
fetchMock: vi.fn(),
|
||
showUploadControls: true,
|
||
uploadComplete: undefined as ((url: string) => void) | undefined,
|
||
uploadedFileName: null as string | null,
|
||
uploadMode: 'always',
|
||
}));
|
||
|
||
vi.mock('react-i18next', () => ({
|
||
Trans: ({ i18nKey, values }: { i18nKey: string; values?: Record<string, unknown> }) =>
|
||
createElement('span', { 'data-testid': `trans-${i18nKey}` }, `${i18nKey}:${JSON.stringify(values || {})}`),
|
||
useTranslation: () => ({
|
||
t: (key: string, options?: Record<string, unknown>) => {
|
||
if (!options) {
|
||
return key;
|
||
}
|
||
if (typeof options.no !== 'undefined') {
|
||
return `${key}:${options.no}`;
|
||
}
|
||
if (typeof options.length !== 'undefined') {
|
||
return `${key}:${options.length}`;
|
||
}
|
||
return `${key}:${JSON.stringify(options)}`;
|
||
},
|
||
}),
|
||
}));
|
||
|
||
vi.mock('react-router-dom', async () => {
|
||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||
return {
|
||
...actual,
|
||
useNavigate: () => testState.navigateMock,
|
||
};
|
||
});
|
||
|
||
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
||
setAccount: (account: unknown) => testState.setAccountMock(account),
|
||
useAccount: () => testState.account,
|
||
useCommunity: (options?: { community?: { name?: string; publicKey?: string } }) => {
|
||
const communityKey = options?.community?.name ?? options?.community?.publicKey;
|
||
return communityKey ? testState.communities[communityKey] : undefined;
|
||
},
|
||
}));
|
||
|
||
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/communities', () => ({
|
||
default: <T,>(selector: (state: { communities: typeof testState.communities }) => T) =>
|
||
selector({
|
||
communities: testState.communities,
|
||
}),
|
||
}));
|
||
|
||
vi.mock('../../../hooks/use-is-community-offline', () => ({
|
||
default: (community?: { address?: string }) =>
|
||
(community?.address ? testState.offlineStates[community.address] : undefined) || {
|
||
isOffline: testState.offlineWarningVisible,
|
||
isOnlineStatusLoading: testState.offlineStatusLoading,
|
||
offlineTitle: testState.offlineTitle,
|
||
},
|
||
}));
|
||
|
||
vi.mock('../../../stores/use-selected-text-store', () => ({
|
||
default: () => ({
|
||
selectedText: testState.selectedText,
|
||
}),
|
||
}));
|
||
|
||
vi.mock('../../../stores/use-reply-modal-store', () => ({
|
||
default: <T,>(selector?: (state: { openEmpty: boolean; quoteInsertNumber?: number; quoteInsertRequestId: number; quoteInsertSelectedText: string }) => T) => {
|
||
const state = {
|
||
openEmpty: testState.openEmpty,
|
||
quoteInsertNumber: testState.quoteInsertNumber,
|
||
quoteInsertRequestId: testState.quoteInsertRequestId,
|
||
quoteInsertSelectedText: testState.quoteInsertSelectedText,
|
||
};
|
||
return selector ? selector(state) : (state as T);
|
||
},
|
||
}));
|
||
|
||
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
||
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
|
||
getShowUploadControls: () => testState.showUploadControls,
|
||
isWebRuntime: () => testState.mediaHostingRuntime === 'web',
|
||
}));
|
||
|
||
vi.mock('../../../stores/use-media-hosting-store', () => ({
|
||
default: (selector: (state: { uploadMode: string }) => unknown) =>
|
||
selector({
|
||
uploadMode: testState.uploadMode,
|
||
}),
|
||
}));
|
||
|
||
vi.mock('../../../hooks/use-directories', () => ({
|
||
findDirectoryByAddress: (directories: Array<{ address: string; features?: Record<string, unknown>; title?: string }>, address: string | undefined) =>
|
||
directories.find((entry) => entry.address === address),
|
||
useDirectories: () => Object.values(testState.directoryByAddress),
|
||
useDirectoryByAddress: (address: string) => testState.directoryByAddress[address],
|
||
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
||
}));
|
||
|
||
vi.mock('../../../hooks/use-directory-entry', async () => {
|
||
const actual = await vi.importActual<typeof import('../../../hooks/use-directory-entry')>('../../../hooks/use-directory-entry');
|
||
return {
|
||
...actual,
|
||
useDirectoryEntry: (address: string | undefined, directoryCodeHint?: string) => {
|
||
const list =
|
||
testState.directoryListsByCode[directoryCodeHint ?? ''] ??
|
||
Object.values(testState.directoryListsByCode).find((candidate) => candidate.boards.some((board) => board.address === address));
|
||
return actual.getDirectoryEntryForAddress({
|
||
address,
|
||
directories: Object.values(testState.directoryByAddress),
|
||
directoryCodeHint,
|
||
list,
|
||
});
|
||
},
|
||
};
|
||
});
|
||
|
||
vi.mock('../../../hooks/use-community-identifiers', () => ({
|
||
useCommunityIdentifier: (address?: string) => (address ? { name: address } : undefined),
|
||
}));
|
||
|
||
vi.mock('../../../hooks/use-resolved-community-address', () => ({
|
||
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
|
||
}));
|
||
|
||
vi.mock('../../../hooks/use-stable-community', () => ({
|
||
useCommunityField: <T,>(communityAddress: string | undefined, selector: (community?: { roles?: Record<string, { role?: string }> }) => T) =>
|
||
selector(communityAddress ? { roles: testState.rolesByCommunity[communityAddress] } : undefined),
|
||
}));
|
||
|
||
vi.mock('../../../hooks/use-publish-reply', async () => {
|
||
const React = await vi.importActual<typeof import('react')>('react');
|
||
|
||
return {
|
||
default: () => {
|
||
const [, forceUpdate] = React.useReducer((value: number) => value + 1, 0);
|
||
|
||
return {
|
||
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
||
publishReply: (options?: Record<string, unknown>) => {
|
||
if (options) {
|
||
testState.setPublishReplyOptionsMock(options);
|
||
}
|
||
const result = testState.publishReplyMock(options);
|
||
forceUpdate();
|
||
return result;
|
||
},
|
||
publishReplyError: testState.publishReplyError,
|
||
publishReplyStateMessage: testState.publishReplyStateMessage,
|
||
replyIndex: testState.replyIndex,
|
||
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
||
setPublishReplyOptions: (options: Record<string, unknown>) => testState.setPublishReplyOptionsMock(options),
|
||
};
|
||
},
|
||
};
|
||
});
|
||
|
||
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||
default: () => testState.isMobile,
|
||
}));
|
||
|
||
vi.mock('../../../hooks/use-file-upload', () => ({
|
||
useFileUpload: ({ onUploadComplete }: { onUploadComplete: (url: string) => void }) => {
|
||
testState.uploadComplete = onUploadComplete;
|
||
return {
|
||
handleUpload: testState.handleUploadMock,
|
||
uploadFile: testState.uploadFileMock,
|
||
isUploading: testState.isUploading,
|
||
uploadedFileName: testState.uploadedFileName,
|
||
};
|
||
},
|
||
}));
|
||
|
||
vi.mock('../../loading-ellipsis/loading-ellipsis', () => ({
|
||
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
|
||
}));
|
||
|
||
vi.mock('../../../lib/mathjax/mathjax-typeset', () => ({
|
||
clearMathElement: () => undefined,
|
||
preloadMathJax: () => undefined,
|
||
typesetMathElement: () => Promise.resolve(),
|
||
}));
|
||
|
||
vi.mock('lodash/debounce', () => ({
|
||
default: <T extends (...args: any[]) => void>(fn: T, wait = 0) => {
|
||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||
const wrapped = ((...args: Parameters<T>) => {
|
||
if (timeout) {
|
||
clearTimeout(timeout);
|
||
}
|
||
timeout = setTimeout(() => fn(...args), wait);
|
||
}) as T & { cancel: () => void };
|
||
wrapped.cancel = () => {
|
||
if (timeout) {
|
||
clearTimeout(timeout);
|
||
}
|
||
timeout = undefined;
|
||
};
|
||
return wrapped;
|
||
},
|
||
}));
|
||
|
||
vi.mock('@react-spring/web', async () => {
|
||
const React = await vi.importActual<typeof import('react')>('react');
|
||
const normalizeStyle = (style: Record<string, unknown> | undefined) =>
|
||
style
|
||
? Object.fromEntries(
|
||
Object.entries(style).map(([key, value]) => [
|
||
key,
|
||
typeof value === 'object' && value !== null && 'get' in value && typeof (value as { get: unknown }).get === 'function'
|
||
? (value as { get: () => unknown }).get()
|
||
: value,
|
||
]),
|
||
)
|
||
: undefined;
|
||
|
||
return {
|
||
animated: {
|
||
div: React.forwardRef(({ style, ...props }: any, ref) => React.createElement('div', { ...props, ref, style: normalizeStyle(style) })),
|
||
},
|
||
useSpring: testState.useSpringMock.mockImplementation(() => [
|
||
{
|
||
left: { get: () => 120 },
|
||
top: { get: () => 80 },
|
||
},
|
||
{
|
||
start: testState.springStartMock,
|
||
},
|
||
]),
|
||
};
|
||
});
|
||
|
||
vi.mock('@use-gesture/react', () => ({
|
||
useDrag: (handler: (state: { active: boolean; event: Pick<Event, 'preventDefault'>; offset: [number, number] }) => void) => {
|
||
testState.dragHandler = handler;
|
||
return () => ({});
|
||
},
|
||
}));
|
||
|
||
let container: HTMLDivElement;
|
||
let root: Root;
|
||
|
||
const flushEffects = async (count = 4) => {
|
||
for (let i = 0; i < count; i += 1) {
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
});
|
||
}
|
||
};
|
||
|
||
const renderReplyModal = async (initialEntry = '/mu/thread/post-1', communityAddress = 'music-posting.eth') => {
|
||
await act(async () => {
|
||
root.render(
|
||
createElement(
|
||
MemoryRouter,
|
||
{ initialEntries: [initialEntry] },
|
||
createElement(ReplyModal, {
|
||
closeModal: testState.closeModalMock,
|
||
parentCid: 'parent-cid',
|
||
parentNumber: 42,
|
||
postCid: 'post-cid',
|
||
scrollY: 120,
|
||
showReplyModal: true,
|
||
communityAddress,
|
||
threadNumber: 42,
|
||
}),
|
||
),
|
||
);
|
||
});
|
||
await flushEffects();
|
||
};
|
||
|
||
const rerenderReplyModal = async (initialEntry = '/mu/thread/post-1') => {
|
||
await renderReplyModal(initialEntry);
|
||
};
|
||
|
||
const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||
await act(async () => {
|
||
const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
||
descriptor?.set?.call(element, value);
|
||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||
});
|
||
};
|
||
|
||
const waitForOptionsValidation = async () => {
|
||
await act(async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, POST_OPTIONS_VALIDATION_DELAY_MS + 20));
|
||
});
|
||
};
|
||
|
||
const waitForContentLengthValidation = async () => {
|
||
await act(async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, 1020));
|
||
});
|
||
};
|
||
|
||
const clickButtonByText = async (text: string) => {
|
||
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
|
||
await act(async () => {
|
||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
});
|
||
};
|
||
|
||
describe('ReplyModal', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
testState.account = { author: { address: 'alice.eth', displayName: 'Alice' } };
|
||
testState.closeModalMock.mockReset();
|
||
testState.directoryByAddress = {
|
||
'music-posting.eth': {
|
||
address: 'music-posting.eth',
|
||
features: {},
|
||
title: '/mu/ - Music',
|
||
},
|
||
'politically-incorrect.bso': {
|
||
address: 'politically-incorrect.bso',
|
||
directoryCode: 'pol',
|
||
features: { hasFlags: true },
|
||
title: '/pol/ - Politically Incorrect',
|
||
},
|
||
'international-nsfw.bso': {
|
||
address: 'international-nsfw.bso',
|
||
directoryCode: 'bant',
|
||
features: { hasFlags: true },
|
||
title: '/bant/ - International/Random',
|
||
},
|
||
'international-sfw.bso': {
|
||
address: 'international-sfw.bso',
|
||
directoryCode: 'int',
|
||
features: { hasFlags: true },
|
||
title: '/int/ - International',
|
||
},
|
||
'sports-posting.bso': {
|
||
address: 'sports-posting.bso',
|
||
directoryCode: 'sp',
|
||
features: { hasFlags: true },
|
||
title: '/sp/ - Sports',
|
||
},
|
||
'random-nsfw.bso': {
|
||
address: 'random-nsfw.bso',
|
||
features: {},
|
||
title: '/b/ - Random',
|
||
},
|
||
'silly-stuff.bso': {
|
||
address: 'silly-stuff.bso',
|
||
features: {},
|
||
title: '/s5s/ - Silly Stuff',
|
||
},
|
||
'traditional-games.bso': {
|
||
address: 'traditional-games.bso',
|
||
features: {},
|
||
title: '/tg/ - Traditional Games',
|
||
},
|
||
};
|
||
testState.directoryListsByCode = {};
|
||
testState.handleUploadMock.mockReset();
|
||
testState.uploadFileMock.mockReset();
|
||
testState.isMobile = false;
|
||
testState.isResolvingExternalQuotes = false;
|
||
testState.isUploading = false;
|
||
testState.navigateMock.mockReset();
|
||
testState.offlineTitle = '';
|
||
testState.offlineStates = {};
|
||
testState.offlineStatusLoading = false;
|
||
testState.offlineWarningVisible = false;
|
||
testState.openEmpty = false;
|
||
testState.publishReplyMock.mockReset();
|
||
testState.publishReplyError = null;
|
||
testState.publishReplyStateMessage = null;
|
||
testState.quoteInsertNumber = undefined;
|
||
testState.quoteInsertRequestId = 0;
|
||
testState.quoteInsertSelectedText = '';
|
||
testState.dragHandler = undefined;
|
||
testState.replyIndex = undefined;
|
||
testState.resetPublishReplyOptionsMock.mockReset();
|
||
testState.resolvedCommunityAddress = undefined;
|
||
testState.rolesByCommunity = {};
|
||
testState.selectedText = 'selected text';
|
||
testState.setAccountMock.mockReset();
|
||
testState.setPublishReplyOptionsMock.mockReset();
|
||
testState.springStartMock.mockReset();
|
||
testState.useSpringMock.mockReset();
|
||
testState.useSpringMock.mockImplementation(() => [
|
||
{
|
||
left: { get: () => 120 },
|
||
top: { get: () => 80 },
|
||
},
|
||
{
|
||
start: testState.springStartMock,
|
||
},
|
||
]);
|
||
testState.communities = {
|
||
'music-posting.eth': {
|
||
address: 'music-posting.eth',
|
||
},
|
||
'traditional-games.bso': {
|
||
address: 'traditional-games.bso',
|
||
},
|
||
};
|
||
testState.fetchMock.mockReset();
|
||
testState.fetchMock.mockResolvedValue({
|
||
headers: {
|
||
get: (name: string) => (name.toLowerCase() === 'content-length' ? '12345' : null),
|
||
},
|
||
ok: true,
|
||
});
|
||
vi.stubGlobal('fetch', testState.fetchMock);
|
||
testState.showUploadControls = true;
|
||
testState.uploadComplete = undefined;
|
||
testState.uploadedFileName = null;
|
||
testState.uploadMode = 'always';
|
||
testState.mediaHostingRuntime = 'web';
|
||
container = document.createElement('div');
|
||
document.body.appendChild(container);
|
||
root = createRoot(container);
|
||
});
|
||
|
||
afterEach(() => {
|
||
act(() => root.unmount());
|
||
container.remove();
|
||
document.body.style.userSelect = '';
|
||
document.body.style.webkitUserSelect = '';
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('initializes quoted content, display name, upload controls, and shared offline warning on board routes', async () => {
|
||
testState.offlineTitle = 'posts_last_synced_info:{"time":"ago:1000"}';
|
||
testState.offlineWarningVisible = true;
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
const nameInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[0];
|
||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||
|
||
expect(optionsInput).toBeTruthy();
|
||
expect(linkInput).toBeTruthy();
|
||
expect(textarea).toBeTruthy();
|
||
expect(nameInput?.value).toBe('Alice');
|
||
expect(optionsInput?.getAttribute('placeholder')).toBe('Options');
|
||
expect(linkInput?.getAttribute('placeholder')).toContain('Link');
|
||
expect(textarea?.value).toBe('>>42\nselected text');
|
||
expect(Boolean(optionsInput!.compareDocumentPosition(textarea!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
|
||
expect(Boolean(textarea!.compareDocumentPosition(linkInput!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
|
||
expect(container.textContent).toContain('choose_file');
|
||
expect(container.textContent).toContain('Spoiler?');
|
||
expect(container.textContent).toContain('posts_last_synced_info:{"time":"ago:1000"}');
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: '>>42\nselected text' });
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alice' });
|
||
});
|
||
|
||
it('shows Oekaki draw controls on /i/ replies', async () => {
|
||
testState.directoryByAddress['oekaki-posting.bso'] = {
|
||
address: 'oekaki-posting.bso',
|
||
directoryCode: 'i',
|
||
features: { requirePostLink: true, requirePostLinkIsMedia: true },
|
||
title: '/i/ - Oekaki',
|
||
};
|
||
testState.communities['oekaki-posting.bso'] = { address: 'oekaki-posting.bso' };
|
||
|
||
await renderReplyModal('/i/thread/post-1', 'oekaki-posting.bso');
|
||
|
||
expect(container.textContent).toContain('Size');
|
||
expect(container.textContent).toContain('Replay');
|
||
expect(Array.from(container.querySelectorAll('span')).some((span) => span.textContent === '×')).toBe(true);
|
||
expect(container.textContent).toContain(OEKAKI_WEB_WARNING_TEXT);
|
||
expect(Array.from(container.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(true);
|
||
expect((Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Clear') as HTMLButtonElement | undefined)?.disabled).toBe(true);
|
||
|
||
testState.mediaHostingRuntime = 'android';
|
||
await renderReplyModal('/i/thread/post-1', 'oekaki-posting.bso');
|
||
|
||
expect(container.textContent).not.toContain(OEKAKI_WEB_WARNING_TEXT);
|
||
});
|
||
|
||
it('opens the TeX preview modal from the /sci/ reply modal button', async () => {
|
||
testState.directoryByAddress['science-and-math.bso'] = {
|
||
address: 'science-and-math.bso',
|
||
directoryCode: 'sci',
|
||
features: {},
|
||
title: '/sci/ - Science & Math',
|
||
};
|
||
testState.communities['science-and-math.bso'] = { address: 'science-and-math.bso' };
|
||
|
||
await renderReplyModal('/sci/thread/post-1', 'science-and-math.bso');
|
||
|
||
const texButton = container.querySelector<HTMLButtonElement>('button[aria-label="preview_tex_equations"]');
|
||
expect(texButton?.textContent).toBe('TEX');
|
||
expect(texButton?.querySelector('sub')?.textContent).toBe('E');
|
||
|
||
await act(async () => {
|
||
texButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
});
|
||
|
||
const texPreview = container.querySelector('dialog[aria-labelledby="tex-preview-title"]');
|
||
expect(texPreview).toBeTruthy();
|
||
expect(texPreview?.textContent).toContain('tex_preview_title');
|
||
});
|
||
|
||
it('clears TeX button focus after closing the TeX preview with Escape', async () => {
|
||
testState.directoryByAddress['science-and-math.bso'] = {
|
||
address: 'science-and-math.bso',
|
||
directoryCode: 'sci',
|
||
features: {},
|
||
title: '/sci/ - Science & Math',
|
||
};
|
||
testState.communities['science-and-math.bso'] = { address: 'science-and-math.bso' };
|
||
|
||
await renderReplyModal('/sci/thread/post-1', 'science-and-math.bso');
|
||
|
||
const texButton = container.querySelector<HTMLButtonElement>('button[aria-label="preview_tex_equations"]');
|
||
expect(texButton).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
texButton?.focus();
|
||
texButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
});
|
||
expect(document.activeElement).toBe(texButton);
|
||
|
||
await act(async () => {
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' }));
|
||
});
|
||
|
||
expect(document.activeElement).not.toBe(texButton);
|
||
});
|
||
|
||
it('shows a flag selector on flag boards and publishes the default geographic request', async () => {
|
||
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
|
||
|
||
const flagSelect = container.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
|
||
|
||
expect(flagSelect).toBeTruthy();
|
||
expect(flagSelect?.value).toBe('country:auto');
|
||
expect(
|
||
Array.from(flagSelect?.options || [])
|
||
.slice(0, 3)
|
||
.map((option) => option.textContent),
|
||
).toEqual(['Geographic Location', 'Anarcho-Capitalist', 'Anarchist']);
|
||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||
expect(Boolean(linkInput!.compareDocumentPosition(flagSelect!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
|
||
|
||
await clickButtonByText('post');
|
||
|
||
expect(testState.publishReplyMock).toHaveBeenCalledWith({
|
||
content: '>>42\nselected text',
|
||
challengeRequest: {
|
||
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
|
||
},
|
||
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
|
||
});
|
||
});
|
||
|
||
it('shows the /pol/ flag selector for replies on a non-primary directory candidate board', async () => {
|
||
testState.directoryListsByCode.pol = {
|
||
directoryCode: 'pol',
|
||
features: { hasFlags: true },
|
||
title: '/pol/ - Politically Incorrect',
|
||
boards: [{ address: 'politically-incorrect.bso' }, { address: 'nothing-is-beyond-our-reach.bso' }],
|
||
};
|
||
|
||
await renderReplyModal('/pol/thread/post-1', 'nothing-is-beyond-our-reach.bso');
|
||
|
||
const flagSelect = container.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
|
||
|
||
expect(flagSelect).toBeTruthy();
|
||
expect(flagSelect?.value).toBe('country:auto');
|
||
|
||
await clickButtonByText('post');
|
||
|
||
expect(testState.publishReplyMock).toHaveBeenCalledWith({
|
||
content: '>>42\nselected text',
|
||
challengeRequest: {
|
||
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
|
||
},
|
||
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
{ boardPath: '/bant/thread/post-1', communityAddress: 'international-nsfw.bso' },
|
||
{ boardPath: '/int/thread/post-1', communityAddress: 'international-sfw.bso' },
|
||
{ boardPath: '/sp/thread/post-1', communityAddress: 'sports-posting.bso' },
|
||
])('publishes geographic location on country-only boards without showing a flag selector', async ({ boardPath, communityAddress }) => {
|
||
await renderReplyModal(boardPath, communityAddress);
|
||
|
||
expect(container.querySelector<HTMLSelectElement>('select[aria-label="flag"]')).toBeNull();
|
||
|
||
await clickButtonByText('post');
|
||
|
||
expect(testState.publishReplyMock).toHaveBeenCalledWith({
|
||
content: '>>42\nselected text',
|
||
challengeRequest: {
|
||
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
|
||
},
|
||
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
|
||
});
|
||
});
|
||
|
||
it('publishes selected political flags from the reply modal', async () => {
|
||
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
|
||
|
||
const flagSelect = container.querySelector<HTMLSelectElement>('select[aria-label="flag"]');
|
||
await dispatchInput(container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement, 'reply body');
|
||
await act(async () => {
|
||
if (flagSelect) {
|
||
flagSelect.value = 'pol:AC';
|
||
flagSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
});
|
||
await clickButtonByText('post');
|
||
|
||
expect(testState.publishReplyMock).toHaveBeenCalledWith({
|
||
content: 'reply body',
|
||
flairs: [{ type: 'pol', code: 'AC', text: 'flag:pol:AC' }],
|
||
});
|
||
});
|
||
|
||
it('uses the shared loading ellipsis while a reply upload is running', async () => {
|
||
testState.isUploading = true;
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('uploading');
|
||
});
|
||
|
||
it('does not render an offline warning when the shared offline hook reports the board as online', async () => {
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
expect(container.querySelector('[class*="offlineBoard"]')).toBeNull();
|
||
expect(container.textContent).not.toContain('community_offline_info');
|
||
});
|
||
|
||
it('prefers the resolved board entry when the modal prop address uses a different alias', async () => {
|
||
testState.offlineTitle = 'community_offline_info';
|
||
testState.offlineWarningVisible = true;
|
||
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||
testState.communities = {
|
||
'music-posting.eth': {
|
||
address: 'music-posting.eth',
|
||
},
|
||
};
|
||
testState.offlineStates = {
|
||
'music-posting.eth': {
|
||
isOffline: false,
|
||
isOnlineStatusLoading: false,
|
||
offlineTitle: '',
|
||
},
|
||
};
|
||
|
||
await renderReplyModal('/mu/thread/post-1', 'music-posting.bso');
|
||
|
||
expect(container.querySelector('[class*="offlineBoard"]')).toBeNull();
|
||
expect(container.textContent).not.toContain('community_offline_info');
|
||
});
|
||
|
||
it('validates empty and invalid replies, then publishes once the payload is valid', async () => {
|
||
testState.openEmpty = true;
|
||
testState.selectedText = '';
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
await clickButtonByText('post');
|
||
expect(container.textContent).toContain('error: empty_comment_alert');
|
||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||
|
||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||
const spoilerCheckbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||
await dispatchInput(linkInput, 'not-a-url');
|
||
await act(async () => {
|
||
spoilerCheckbox?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
});
|
||
await clickButtonByText('post');
|
||
|
||
expect(container.textContent).toContain('error: invalid_url_alert');
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ spoiler: true });
|
||
|
||
await dispatchInput(linkInput, 'https://temp.sh/example.png');
|
||
await clickButtonByText('post');
|
||
|
||
expect(container.textContent).toContain('error: expiring_media_link_alert:{"domain":"temp.sh"}');
|
||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||
|
||
await dispatchInput(linkInput, 'https://example.com/file.png');
|
||
await clickButtonByText('post');
|
||
|
||
expect(container.textContent).toContain('file.png');
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://example.com/file.png' });
|
||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('publishes twimg query-format reply links with a path extension', async () => {
|
||
testState.openEmpty = true;
|
||
testState.selectedText = 'reply body';
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||
|
||
// The publish path normalizes the raw `?format=` link even without blurring the field.
|
||
await dispatchInput(linkInput, 'https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
|
||
expect(linkInput.value).toBe('https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
|
||
await clickButtonByText('post');
|
||
|
||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||
expect(testState.publishReplyMock).toHaveBeenCalledWith(expect.objectContaining({ link: 'https://pbs.twimg.com/media/HKUAehoXUAAXkMb.jpg' }));
|
||
});
|
||
|
||
it('rewrites twimg query-format reply links to their path-extension form on blur', async () => {
|
||
testState.openEmpty = true;
|
||
testState.selectedText = 'reply body';
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||
|
||
await dispatchInput(linkInput, 'https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
|
||
expect(linkInput.value).toBe('https://pbs.twimg.com/media/HKUAehoXUAAXkMb?format=jpg&name=4096x4096');
|
||
|
||
await act(async () => {
|
||
linkInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||
});
|
||
|
||
expect(linkInput.value).toBe('https://pbs.twimg.com/media/HKUAehoXUAAXkMb.jpg');
|
||
});
|
||
|
||
it('moves YouTube links into reply content and publishes the thumbnail link on media-only boards', async () => {
|
||
const youtubeLink = 'https://youtu.be/reply123';
|
||
const thumbnailLink = 'https://img.youtube.com/vi/reply123/maxresdefault.jpg';
|
||
testState.openEmpty = true;
|
||
testState.selectedText = 'reply body';
|
||
testState.directoryByAddress['music-posting.eth'] = {
|
||
address: 'music-posting.eth',
|
||
features: { requirePostLinkIsMedia: true },
|
||
title: '/mu/ - Music',
|
||
};
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement;
|
||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||
const linkContainer = linkInput.parentElement as HTMLElement;
|
||
const getConversionNotice = () =>
|
||
Array.from(container.querySelectorAll<HTMLDivElement>('div'))
|
||
.reverse()
|
||
.find((element) => element.textContent?.includes('youtube_thumbnail_link_conversion_notice'));
|
||
|
||
await dispatchInput(linkInput, youtubeLink);
|
||
|
||
expect(linkInput.value).toBe(youtubeLink);
|
||
expect(container.textContent).toContain('youtube_thumbnail_link_conversion_notice:{"count":3}');
|
||
expect(linkContainer.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
|
||
expect(getConversionNotice()?.className).toContain('error');
|
||
|
||
await clickButtonByText('post');
|
||
|
||
expect(linkInput.value).toBe(thumbnailLink);
|
||
expect(textarea.value).toBe(`${youtubeLink}\nreply body`);
|
||
expect(testState.publishReplyMock).toHaveBeenCalledWith({
|
||
content: `${youtubeLink}\nreply body`,
|
||
link: thumbnailLink,
|
||
});
|
||
});
|
||
|
||
it('rejects unresolved YouTube links on media-only reply modal boards', async () => {
|
||
const youtubeLink = 'https://youtu.be/replymissing';
|
||
testState.fetchMock.mockResolvedValue({
|
||
headers: {
|
||
get: () => null,
|
||
},
|
||
ok: false,
|
||
});
|
||
testState.openEmpty = true;
|
||
testState.selectedText = 'reply body';
|
||
testState.directoryByAddress['music-posting.eth'] = {
|
||
address: 'music-posting.eth',
|
||
features: { requirePostLinkIsMedia: true },
|
||
title: '/mu/ - Music',
|
||
};
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||
|
||
await dispatchInput(linkInput, youtubeLink);
|
||
await clickButtonByText('post');
|
||
await flushEffects(8);
|
||
|
||
expect(testState.fetchMock).toHaveBeenCalled();
|
||
expect(container.textContent).toContain('error: link_not_image_or_video_alert');
|
||
expect(linkInput.value).toBe(youtubeLink);
|
||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('ignores duplicate reply clicks while youtube thumbnail resolution is pending', async () => {
|
||
const youtubeLink = 'https://youtu.be/replyslow';
|
||
const thumbnailLink = 'https://img.youtube.com/vi/replyslow/maxresdefault.jpg';
|
||
let resolveFetch: (response: unknown) => void = () => {};
|
||
const fetchPromise = new Promise((resolve) => {
|
||
resolveFetch = resolve;
|
||
});
|
||
testState.fetchMock.mockReturnValue(fetchPromise);
|
||
testState.openEmpty = true;
|
||
testState.selectedText = 'reply body';
|
||
testState.directoryByAddress['music-posting.eth'] = {
|
||
address: 'music-posting.eth',
|
||
features: { requirePostLinkIsMedia: true },
|
||
title: '/mu/ - Music',
|
||
};
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||
const postButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'post') as HTMLButtonElement;
|
||
|
||
await dispatchInput(linkInput, youtubeLink);
|
||
|
||
await clickButtonByText('post');
|
||
expect(postButton.disabled).toBe(true);
|
||
|
||
await clickButtonByText('post');
|
||
expect(testState.fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||
|
||
await act(async () => {
|
||
resolveFetch({
|
||
headers: {
|
||
get: (name: string) => (name.toLowerCase() === 'content-length' ? '12345' : null),
|
||
},
|
||
ok: true,
|
||
});
|
||
await fetchPromise;
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||
expect(testState.publishReplyMock).toHaveBeenCalledWith({
|
||
content: `${youtubeLink}\nreply body`,
|
||
link: thumbnailLink,
|
||
});
|
||
});
|
||
|
||
it('validates unsupported options and keeps fortune output out of preview state until reply publish', async () => {
|
||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
||
testState.openEmpty = true;
|
||
testState.selectedText = '';
|
||
|
||
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
|
||
|
||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||
|
||
await dispatchInput(optionsInput, 'x y z');
|
||
expect(container.textContent).not.toContain('Unsupported options');
|
||
|
||
await waitForOptionsValidation();
|
||
expect(container.textContent).toContain('Unsupported options: x, y, z.');
|
||
const delayedOptionsError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'Unsupported options: x, y, z.');
|
||
expect(delayedOptionsError?.className).toContain('error');
|
||
|
||
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
|
||
await clickButtonByText('post');
|
||
|
||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||
|
||
await dispatchInput(optionsInput, 'fortune');
|
||
|
||
expect(container.textContent).not.toContain('Unsupported options');
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
|
||
content: 'reply body',
|
||
});
|
||
|
||
await clickButtonByText('post');
|
||
|
||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||
expect(testState.publishReplyMock).toHaveBeenCalledWith({
|
||
content: 'reply body[fortune color=#fd4d32]Excellent Luck[/fortune]',
|
||
});
|
||
randomSpy.mockRestore();
|
||
});
|
||
|
||
it('counts hidden fortune output in reply length validation without revealing it', async () => {
|
||
testState.openEmpty = true;
|
||
testState.selectedText = '';
|
||
|
||
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
|
||
|
||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement;
|
||
const longContent = 'x'.repeat(1930);
|
||
|
||
await dispatchInput(optionsInput, 'fortune');
|
||
await dispatchInput(textarea, longContent);
|
||
await waitForContentLengthValidation();
|
||
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: longContent });
|
||
expect(container.textContent).toContain('comment_field_too_long:2001');
|
||
expect(container.textContent).not.toContain('[fortune color=');
|
||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('links the unsupported sage option to its FAQ entry in reply modal', async () => {
|
||
testState.openEmpty = true;
|
||
testState.selectedText = '';
|
||
|
||
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
|
||
|
||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||
|
||
await dispatchInput(optionsInput, 'sage');
|
||
await waitForOptionsValidation();
|
||
|
||
expect(container.textContent).toContain('Unsupported options: sage [learn why].');
|
||
expect(container.querySelector<HTMLAnchorElement>('a[href="/faq#sage"]')?.textContent).toBe('learn why');
|
||
});
|
||
|
||
it('supports fortune on the /s5s/ route when directory metadata is not loaded', async () => {
|
||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
||
testState.openEmpty = true;
|
||
testState.selectedText = '';
|
||
delete testState.directoryByAddress['silly-stuff.bso'];
|
||
|
||
await renderReplyModal('/s5s/thread/post-1', 'silly-stuff.bso');
|
||
|
||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||
|
||
await dispatchInput(optionsInput, 'fortune');
|
||
await waitForOptionsValidation();
|
||
|
||
expect(container.textContent).not.toContain('Unsupported options');
|
||
|
||
await dispatchInput(textarea as HTMLTextAreaElement, 'silly reply');
|
||
await clickButtonByText('post');
|
||
|
||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||
expect(testState.publishReplyMock).toHaveBeenCalledWith({
|
||
content: 'silly reply[fortune color=#fd4d32]Excellent Luck[/fortune]',
|
||
});
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'silly reply' });
|
||
randomSpy.mockRestore();
|
||
});
|
||
|
||
it('stores dice rolls in reply content on dice-enabled boards', async () => {
|
||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
||
testState.openEmpty = true;
|
||
testState.selectedText = '';
|
||
|
||
await renderReplyModal('/tg/thread/post-1', 'traditional-games.bso');
|
||
|
||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||
|
||
await dispatchInput(optionsInput, 'dice+2d6');
|
||
await waitForOptionsValidation();
|
||
|
||
expect(container.textContent).not.toContain('Unsupported options');
|
||
|
||
await dispatchInput(textarea as HTMLTextAreaElement, 'dice reply');
|
||
await clickButtonByText('post');
|
||
|
||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
|
||
content: '<b>Rolled 2, 2 = 4 (2d6)<br><br></b>dice reply',
|
||
});
|
||
randomSpy.mockRestore();
|
||
});
|
||
|
||
it('treats dice rolls as unsupported outside /tg/ and /qst/ in reply modal', async () => {
|
||
testState.openEmpty = true;
|
||
testState.selectedText = '';
|
||
|
||
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
|
||
|
||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||
|
||
await dispatchInput(optionsInput, 'dice+1d6');
|
||
expect(container.textContent).not.toContain('Unsupported options');
|
||
|
||
await waitForOptionsValidation();
|
||
expect(container.textContent).toContain('Unsupported options: dice+1d6. Option "dice+1d6" is supported on: /qst/, /tg/.');
|
||
expect(container.querySelector<HTMLAnchorElement>('a[href="/qst"]')?.textContent).toBe('/qst/');
|
||
expect(container.querySelector<HTMLAnchorElement>('a[href="/tg"]')?.textContent).toBe('/tg/');
|
||
|
||
await dispatchInput(textarea as HTMLTextAreaElement, 'plain dice reply');
|
||
await clickButtonByText('post');
|
||
|
||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'plain dice reply' });
|
||
});
|
||
|
||
it('treats fortune as unsupported outside /b/ and /s5s/ in reply modal', async () => {
|
||
testState.openEmpty = true;
|
||
testState.selectedText = '';
|
||
|
||
await renderReplyModal('/mu/thread/post-1', 'music-posting.eth');
|
||
|
||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||
|
||
await dispatchInput(optionsInput, 'fortune');
|
||
expect(container.textContent).not.toContain('Unsupported options');
|
||
|
||
await waitForOptionsValidation();
|
||
expect(container.textContent).toContain('Unsupported options: fortune. Option "fortune" is supported on: /b/, /s5s/.');
|
||
expect(container.querySelector<HTMLAnchorElement>('a[href="/b"]')?.textContent).toBe('/b/');
|
||
expect(container.querySelector<HTMLAnchorElement>('a[href="/s5s"]')?.textContent).toBe('/s5s/');
|
||
|
||
await dispatchInput(textarea as HTMLTextAreaElement, 'plain reply');
|
||
await clickButtonByText('post');
|
||
|
||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: 'plain reply' });
|
||
});
|
||
|
||
it('shows BBCode controls only for board mods and inserts tags into the reply textarea', async () => {
|
||
testState.account = { author: { address: 'mod.eth', displayName: 'Alice' } };
|
||
testState.rolesByCommunity = {
|
||
'music-posting.eth': {
|
||
'mod.eth': { role: 'admin' },
|
||
},
|
||
};
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||
const redButton = container.querySelector<HTMLButtonElement>('button[aria-label="Red text"]');
|
||
const linkButton = container.querySelector<HTMLButtonElement>('button[aria-label="Link"]');
|
||
expect(textarea).toBeTruthy();
|
||
expect(redButton).toBeTruthy();
|
||
expect(linkButton).toBeTruthy();
|
||
expect(container.querySelector('select[aria-label="Text color"]')).toBeNull();
|
||
expect(container.textContent).not.toContain('mods only');
|
||
expect(container.textContent).not.toContain('Mod editor');
|
||
expect(container.textContent).toContain('warning: posting as admin');
|
||
const moderatorWarning = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'warning: posting as admin');
|
||
expect(moderatorWarning?.className).toContain('error');
|
||
expect(container.querySelector('button[aria-label="Quote"]')).toBeNull();
|
||
|
||
const selectionStart = textarea?.value.indexOf('selected text') ?? 0;
|
||
textarea?.setSelectionRange(selectionStart, selectionStart + 'selected text'.length);
|
||
await act(async () => {
|
||
redButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
});
|
||
|
||
expect(textarea?.value).toBe('>>42\n[color=red]selected text[/color]');
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: '>>42\n[color=red]selected text[/color]' });
|
||
|
||
testState.rolesByCommunity = {};
|
||
await rerenderReplyModal('/mu/thread/post-1');
|
||
|
||
expect(container.querySelector('button[aria-label="Red text"]')).toBeNull();
|
||
expect(container.textContent).not.toContain('mods only');
|
||
expect(container.textContent).not.toContain('warning: posting as admin');
|
||
});
|
||
|
||
it('updates account state, applies upload completions, and closes once publishing succeeds', async () => {
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
const nameInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[0];
|
||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||
|
||
await dispatchInput(nameInput, 'Alicia');
|
||
expect(testState.setAccountMock).toHaveBeenCalledWith({
|
||
author: { address: 'alice.eth', displayName: 'Alicia' },
|
||
});
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alicia' });
|
||
|
||
await act(async () => {
|
||
testState.uploadComplete?.('https://cdn.example/uploaded.png');
|
||
});
|
||
|
||
expect(linkInput?.value).toBe('https://cdn.example/uploaded.png');
|
||
expect(container.textContent).toContain('uploaded.png');
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: 'https://cdn.example/uploaded.png' });
|
||
|
||
testState.replyIndex = 3;
|
||
await rerenderReplyModal('/mu/thread/post-1');
|
||
|
||
expect(testState.resetPublishReplyOptionsMock).toHaveBeenCalledTimes(1);
|
||
expect(testState.closeModalMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('redirects to the board index after a reply when nonoko is used', async () => {
|
||
testState.openEmpty = true;
|
||
testState.selectedText = '';
|
||
testState.publishReplyMock.mockImplementation(() => {
|
||
testState.replyIndex = 3;
|
||
});
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
|
||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||
|
||
await dispatchInput(optionsInput, 'nonoko');
|
||
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
|
||
await clickButtonByText('post');
|
||
await flushEffects();
|
||
await rerenderReplyModal('/mu/thread/post-1');
|
||
|
||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||
expect(testState.resetPublishReplyOptionsMock).toHaveBeenCalledTimes(1);
|
||
expect(testState.closeModalMock).toHaveBeenCalledTimes(1);
|
||
expect(testState.navigateMock).toHaveBeenCalledWith('/mu');
|
||
});
|
||
|
||
it('inserts quote requests only once and keeps the textarea content stable across rerenders', async () => {
|
||
testState.isMobile = true;
|
||
testState.openEmpty = true;
|
||
testState.selectedText = 'Existing line';
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
|
||
expect(textarea?.value).toBe('Existing line');
|
||
|
||
testState.quoteInsertNumber = 77;
|
||
testState.quoteInsertRequestId = 1;
|
||
testState.quoteInsertSelectedText = 'Quoted line';
|
||
await rerenderReplyModal('/mu/thread/post-1');
|
||
|
||
expect(textarea?.value).toBe('Existing line\n>>77\nQuoted line\n');
|
||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
|
||
content: 'Existing line\n>>77\nQuoted line\n',
|
||
});
|
||
|
||
await rerenderReplyModal('/mu/thread/post-1');
|
||
expect(textarea?.value).toBe('Existing line\n>>77\nQuoted line\n');
|
||
});
|
||
|
||
it('uses file-link placeholder defaults in all view and hides board-specific warnings or spoiler controls when disabled', async () => {
|
||
testState.directoryByAddress = {
|
||
'music-posting.eth': {
|
||
address: 'music-posting.eth',
|
||
features: { noSpoilerReplies: true },
|
||
},
|
||
};
|
||
|
||
await renderReplyModal('/all/thread/post-1');
|
||
|
||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||
expect(linkInput?.getAttribute('placeholder')).toBe('https://website.com/image.jpg');
|
||
expect(container.textContent).not.toContain('warning');
|
||
expect(container.textContent).not.toContain('Spoiler?');
|
||
});
|
||
|
||
it('only shows pasted link filenames after media-only links are confirmed as files', async () => {
|
||
await renderReplyModal('/all/thread/post-1');
|
||
|
||
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||
|
||
await dispatchInput(linkInput, 'https://imgur.com/8EJ2T76');
|
||
|
||
expect(container.textContent).toContain('no_file_chosen');
|
||
expect(container.textContent).not.toContain('8EJ2T76');
|
||
|
||
await dispatchInput(linkInput, 'https://imgur.com/8EJ2T76.jpg');
|
||
|
||
expect(container.textContent).toContain('8EJ2T76.jpg');
|
||
});
|
||
|
||
it('positions the draggable modal with left/top styles instead of a transform layer', async () => {
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
const modal = container.querySelector<HTMLDivElement>('[class*="container"]');
|
||
|
||
expect(modal?.style.left).toBe('120px');
|
||
expect(modal?.style.top).toBe('80px');
|
||
expect(modal?.style.transform).toBe('');
|
||
expect(modal?.style.touchAction).toBe('none');
|
||
});
|
||
|
||
it('closes with Escape from the document on desktop', async () => {
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
await act(async () => {
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
|
||
});
|
||
|
||
expect(testState.closeModalMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('restores body selection styles if unmounted during a drag', async () => {
|
||
document.body.style.userSelect = 'text';
|
||
document.body.style.webkitUserSelect = 'auto';
|
||
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
await act(async () => {
|
||
testState.dragHandler?.({
|
||
active: true,
|
||
event: { preventDefault: vi.fn() },
|
||
offset: [140, 100],
|
||
});
|
||
});
|
||
|
||
expect(document.body.style.userSelect).toBe('none');
|
||
expect(document.body.style.webkitUserSelect).toBe('none');
|
||
|
||
await act(async () => {
|
||
root.render(createElement(React.Fragment));
|
||
});
|
||
|
||
expect(document.body.style.userSelect).toBe('text');
|
||
expect(document.body.style.webkitUserSelect).toBe('auto');
|
||
});
|
||
|
||
it('initializes the drag spring once so typing rerenders do not recenter the modal', async () => {
|
||
await renderReplyModal('/mu/thread/post-1');
|
||
|
||
expect(testState.useSpringMock).toHaveBeenCalledWith(expect.any(Function), []);
|
||
|
||
const [configFactory, deps] = testState.useSpringMock.mock.calls[0] as [() => Record<string, unknown>, unknown[]];
|
||
expect(deps).toEqual([]);
|
||
expect(configFactory()).toEqual({
|
||
from: {
|
||
left: expect.any(Number),
|
||
top: expect.any(Number),
|
||
},
|
||
});
|
||
});
|
||
});
|