feat(challenge modal): remember trusted board websites

This commit is contained in:
Tommaso Casaburi
2026-05-06 19:05:26 +07:00
parent 2ebd9ecc30
commit cb05a8b905
46 changed files with 733 additions and 366 deletions
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ChallengeModal from '../challenge-modal';
import getShortAddress from '../../../lib/get-short-address';
import useTrustedBoardUrlPermissionsStore from '../../../stores/use-trusted-board-url-permissions-store';
(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>;
@@ -15,7 +16,6 @@ const testState = vi.hoisted(() => ({
address: '0xabc123',
},
} as Record<string, any>,
capacitorPlatform: 'web',
challenges: [] as Array<{ challenge: any; id: number }>,
commentsByCid: {} as Record<string, { author?: { shortAddress?: string } }>,
publicationPreview: 'preview body',
@@ -33,7 +33,10 @@ vi.mock('react-i18next', () => ({
return `${options?.index}/${options?.total}`;
}
if (key === 'iframe_challenge_confirm') {
return `${options?.board} wants to open ${options?.site}.\n\nFor ${options?.publicationType}: ${options?.excerpt}`;
return `${options?.board} wants to open ${options?.site}.\n\nFor {{publicationType}}: {{excerpt}}`;
}
if (key === 'trusted_board_link_checkbox') {
return 'Always allow boards to open this website';
}
return key;
},
@@ -45,12 +48,6 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
}));
vi.mock('@capacitor/core', () => ({
Capacitor: {
getPlatform: () => testState.capacitorPlatform,
},
}));
vi.mock('../../../lib/utils/challenge-utils', () => ({
getPublicationPreview: () => testState.publicationPreview,
getPublicationType: () => testState.publicationType,
@@ -146,23 +143,17 @@ const clickButton = async (text: string) => {
});
};
const setNavigatorValue = (key: keyof Navigator, value: unknown) => {
Object.defineProperty(window.navigator, key, {
configurable: true,
value,
});
};
describe('ChallengeModal', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
useTrustedBoardUrlPermissionsStore.setState({ trustedOrigins: {} });
testState.abandonCurrentChallengeMock.mockReset().mockResolvedValue(undefined);
testState.account = {
author: {
address: '0xabc123',
},
};
testState.capacitorPlatform = 'web';
testState.challenges = [];
testState.commentsByCid = {
'parent-1': {
@@ -177,10 +168,6 @@ describe('ChallengeModal', () => {
testState.springStartMock.mockReset();
testState.theme = 'dark';
testState.votePreview = 'upvote';
setNavigatorValue('maxTouchPoints', 0);
setNavigatorValue('platform', 'MacIntel');
setNavigatorValue('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36');
setNavigatorValue('vendor', 'Google Inc.');
alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
@@ -280,7 +267,7 @@ describe('ChallengeModal', () => {
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
});
it('opens iframe challenges, injects the theme, and completes them', async () => {
it('opens iframe challenges through the custom modal, injects the theme, and completes them', async () => {
const publication = createPublication();
testState.challenges = [
createStoredChallenge(
@@ -293,16 +280,25 @@ describe('ChallengeModal', () => {
];
await renderModal();
expect(confirmSpy).toHaveBeenCalledWith('mu wants to open mintpass.org.\n\nFor post: Subject');
expect(confirmSpy).not.toHaveBeenCalled();
expect(container.querySelector('#challenge-modal-title')?.textContent).toContain('Challenge for post');
expect(container.textContent).toContain('mintpass.org');
expect(container.textContent).toContain('Done');
expect(container.textContent).not.toContain('Challenge for post');
expect(container.textContent).not.toContain('Open');
expect(container.textContent).toContain('mu wants to open mintpass.org.');
expect(container.textContent).not.toContain('For post:');
expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}');
expect(container.textContent).toContain('Open');
expect(container.textContent).toContain('Always allow boards to open this website');
expect(container.textContent).not.toContain('Always allow boards to open mintpass.org');
expect(container.textContent).not.toContain('Cancel');
expect(container.textContent).not.toContain('Alice');
expect(container.textContent).not.toContain('Subject');
expect(container.textContent).not.toContain('Publication content');
expect(container.textContent).not.toContain('mu wants to open');
const publicationFields = Array.from(container.querySelectorAll<HTMLInputElement>('input[type="text"]'));
expect(publicationFields.map((input) => input.value)).toEqual(['Alice', 'Subject', 'https://example.com/link']);
expect(publicationFields.every((input) => input.disabled)).toBe(true);
const publicationTextarea = container.querySelector<HTMLTextAreaElement>('textarea');
expect(publicationTextarea?.value).toBe('Publication content');
expect(publicationTextarea?.disabled).toBe(true);
expect(container.querySelector('iframe')).toBeNull();
await clickButton('Open');
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeNull();
@@ -327,6 +323,47 @@ describe('ChallengeModal', () => {
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
});
it('remembers a trusted iframe origin when the checkbox is selected', async () => {
testState.challenges = [
createStoredChallenge({
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
}),
];
await renderModal();
const checkbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
await act(async () => {
checkbox?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await clickButton('Open');
expect(useTrustedBoardUrlPermissionsStore.getState().isOriginTrusted('https://spamblocker.bitsocial.net')).toBe(true);
expect(useTrustedBoardUrlPermissionsStore.getState().getTrustedOrigins()).toEqual([
expect.objectContaining({
origin: 'https://spamblocker.bitsocial.net',
site: 'spamblocker.bitsocial.net',
}),
]);
});
it('opens trusted iframe origins without asking again', async () => {
useTrustedBoardUrlPermissionsStore.getState().trustOrigin('https://spamblocker.bitsocial.net', 'spamblocker.bitsocial.net');
testState.challenges = [
createStoredChallenge({
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
}),
];
await renderModal();
expect(confirmSpy).not.toHaveBeenCalled();
expect(container.textContent).not.toContain('wants to open');
expect(container.querySelector('iframe')?.getAttribute('src')).toContain('https://spamblocker.bitsocial.net/api/v1/iframe/session-123?theme=dark');
});
it('allows localhost http iframe challenges for local spam blocker testing', async () => {
const publication = createPublication();
testState.challenges = [
@@ -341,7 +378,12 @@ describe('ChallengeModal', () => {
await renderModal();
expect(confirmSpy).toHaveBeenCalledWith('mu wants to open localhost:3000.\n\nFor post: Subject');
expect(confirmSpy).not.toHaveBeenCalled();
expect(container.textContent).toContain('mu wants to open localhost:3000.');
expect(container.textContent).not.toContain('For post:');
expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}');
await clickButton('Open');
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeNull();
@@ -374,6 +416,7 @@ describe('ChallengeModal', () => {
];
await renderModal();
await clickButton('Open');
await act(async () => {
window.dispatchEvent(
@@ -405,6 +448,7 @@ describe('ChallengeModal', () => {
];
await renderModal();
await clickButton('Open');
await act(async () => {
window.dispatchEvent(
@@ -442,11 +486,14 @@ describe('ChallengeModal', () => {
await renderModal();
expect(confirmSpy).toHaveBeenCalledWith(`${getShortAddress(longCommunityAddress)} wants to open localhost:3000.\n\nFor post: Subject`);
expect(confirmSpy.mock.calls[0]?.[0]).not.toContain(longCommunityAddress);
expect(confirmSpy).not.toHaveBeenCalled();
expect(container.textContent).toContain(`${getShortAddress(longCommunityAddress)} wants to open localhost:3000.`);
expect(container.textContent).not.toContain('For post:');
expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}');
expect(container.textContent).not.toContain(longCommunityAddress);
});
it('uses reply content in the iframe confirmation excerpt', async () => {
it('shows reply content in disabled fields instead of the iframe confirmation message', async () => {
const publication = {
...createPublication(),
content: '>>17\nA reply body with enough context',
@@ -465,97 +512,15 @@ describe('ChallengeModal', () => {
await renderModal();
expect(confirmSpy).toHaveBeenCalledWith('mu wants to open spamblocker.bitsocial.net.\n\nFor reply: >>17 A reply body with enough context');
});
it('abandons iframe challenges when the external confirmation is canceled', async () => {
confirmSpy.mockReturnValueOnce(false);
testState.challenges = [
createStoredChallenge({
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
}),
];
await renderModal();
expect(confirmSpy).toHaveBeenCalledWith('mu wants to open spamblocker.bitsocial.net.\n\nFor post: Subject');
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
expect(container.querySelector('iframe')).toBeNull();
});
it('uses inline iframe confirmation on Android instead of window.confirm', async () => {
const publication = createPublication();
testState.capacitorPlatform = 'android';
testState.publicationType = 'reply';
testState.challenges = [
createStoredChallenge(
{
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
},
{
...publication,
content: '>>17\nreply from android',
title: '',
},
),
];
await renderModal();
expect(confirmSpy).not.toHaveBeenCalled();
expect(container.textContent).toContain('spamblocker.bitsocial.net');
expect(container.textContent).toContain('mu wants to open spamblocker.bitsocial.net.\n\nFor reply: >>17 reply from android');
expect(container.textContent).toContain('Open');
expect(container.textContent).toContain('close');
expect(container.textContent).not.toContain('Challenge for reply');
expect(container.querySelector('iframe')).toBeNull();
await clickButton('Open');
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute('src')).toContain('https://spamblocker.bitsocial.net/api/v1/iframe/session-123?theme=dark');
expect(testState.abandonCurrentChallengeMock).not.toHaveBeenCalled();
expect(container.querySelector('#challenge-modal-title')?.textContent).toContain('Challenge for reply');
expect(container.textContent).toContain('mu wants to open spamblocker.bitsocial.net.');
expect(container.textContent).not.toContain('For reply:');
expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}');
expect(container.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('>>17\nA reply body with enough context');
});
it('uses inline iframe confirmation in Android WebView even when Capacitor reports web', async () => {
testState.capacitorPlatform = 'web';
setNavigatorValue(
'userAgent',
'Mozilla/5.0 (Linux; Android 15; Pixel 9 Build/AP3A.241105.007) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.137 Mobile Safari/537.36 wv',
);
testState.publicationType = 'reply';
testState.challenges = [
createStoredChallenge(
{
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
},
{
...createPublication(),
content: '>>17\nreply from webview',
title: '',
},
),
];
await renderModal();
expect(confirmSpy).not.toHaveBeenCalled();
expect(container.textContent).toContain('mu wants to open spamblocker.bitsocial.net.\n\nFor reply: >>17 reply from webview');
expect(container.querySelector('iframe')).toBeNull();
await clickButton('Open');
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute('src')).toContain('https://spamblocker.bitsocial.net/api/v1/iframe/session-123?theme=dark');
});
it('abandons Android iframe challenges when inline confirmation is closed', async () => {
testState.capacitorPlatform = 'android';
it('abandons iframe challenges when the custom confirmation is closed', async () => {
testState.challenges = [
createStoredChallenge({
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
@@ -571,41 +536,8 @@ describe('ChallengeModal', () => {
expect(container.querySelector('iframe')).toBeNull();
});
it('uses inline iframe confirmation on Safari instead of window.confirm', async () => {
testState.publicationType = 'reply';
setNavigatorValue('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15');
setNavigatorValue('vendor', 'Apple Computer, Inc.');
testState.challenges = [
createStoredChallenge(
{
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
},
{
...createPublication(),
content: '>>17\nreply from safari',
title: '',
},
),
];
await renderModal();
expect(confirmSpy).not.toHaveBeenCalled();
expect(container.textContent).toContain('mu wants to open spamblocker.bitsocial.net.\n\nFor reply: >>17 reply from safari');
expect(container.querySelector('iframe')).toBeNull();
await clickButton('Open');
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute('src')).toContain('https://spamblocker.bitsocial.net/api/v1/iframe/session-123?theme=dark');
});
it('shows iframe challenge errors inline on Safari instead of relying on alert', async () => {
it('shows iframe challenge errors inline instead of relying on alert', async () => {
testState.account = { author: { address: '' } };
setNavigatorValue('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15');
setNavigatorValue('vendor', 'Apple Computer, Inc.');
testState.challenges = [
createStoredChallenge({
challenge: 'https://mintpass.org/auth?user={userAddress}',
@@ -614,7 +546,6 @@ describe('ChallengeModal', () => {
];
await renderModal();
await clickButton('Open');
expect(alertSpy).not.toHaveBeenCalled();
expect(container.textContent).toContain('Error: Unable to load challenge without your address. Please sign in and try again.');
@@ -625,24 +556,7 @@ describe('ChallengeModal', () => {
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
});
it('alerts when iframe challenges need a signer address and the account is missing one', async () => {
testState.account = { author: { address: '' } };
testState.challenges = [
createStoredChallenge({
challenge: 'https://mintpass.org/auth?user={userAddress}',
type: 'url/iframe',
}),
];
await renderModal();
expect(alertSpy).toHaveBeenCalledWith('Error: Unable to load challenge without your address. Please sign in and try again.');
expect(confirmSpy).not.toHaveBeenCalled();
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
expect(container.querySelector('iframe')).toBeNull();
});
it('abandons invalid iframe challenges and responds to Escape', async () => {
it('shows invalid iframe challenge errors inline and still responds to Escape', async () => {
testState.challenges = [
createStoredChallenge({
challenge: 'http://example.com/unsafe',
@@ -652,14 +566,15 @@ describe('ChallengeModal', () => {
await renderModal();
expect(alertSpy).toHaveBeenCalledWith('Error: Only HTTPS iframe challenges or localhost HTTP challenges are supported');
expect(alertSpy).not.toHaveBeenCalled();
expect(confirmSpy).not.toHaveBeenCalled();
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
expect(container.textContent).toContain('Error: Only HTTPS iframe challenges or localhost HTTP challenges are supported');
expect(testState.abandonCurrentChallengeMock).not.toHaveBeenCalled();
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
});
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledTimes(2);
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
});
});
@@ -113,6 +113,8 @@
}
.iframeConsentWrapper {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
margin: 10px 8px;
}
@@ -124,6 +126,20 @@
line-height: 1.35;
}
.iframeTrustCheckbox {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 10px;
line-height: 1.3;
}
.iframeTrustCheckbox input[type="checkbox"] {
width: auto;
margin: 1px 0 0;
flex: 0 0 auto;
}
.iframe {
width: 100%;
height: 100%;
@@ -138,6 +154,11 @@
margin-top: 10px;
}
.iframeFooter span {
display: inline-flex;
gap: 5px;
}
.iframeInstruction {
font-size: 11px;
color: var(--text-secondary, #666);
@@ -1,10 +1,10 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Challenge as ChallengeType, useAccount, useComment } from '@bitsocial/bitsocial-react-hooks';
import { Capacitor } from '@capacitor/core';
import { getPublicationPreview, getPublicationType, getVotePreview, type ChallengePublication } from '../../lib/utils/challenge-utils';
import { getPublicationPreview, getPublicationType, getVotePreview } from '../../lib/utils/challenge-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import useChallengesStore from '../../stores/use-challenges-store';
import useTrustedBoardUrlPermissionsStore from '../../stores/use-trusted-board-url-permissions-store';
import useTheme from '../../hooks/use-theme';
import styles from './challenge-modal.module.css';
import capitalize from 'lodash/capitalize';
@@ -19,57 +19,14 @@ const useParentAddress = (parentCid?: string) => {
interface ChallengeProps {
challenge: ChallengeType;
challengeId: number;
closeModal: () => void;
abandonModal: () => void;
}
const MAX_IFRAME_CONFIRM_EXCERPT_LENGTH = 80;
const getTrimmedExcerpt = (value: unknown) => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '');
const shortenConfirmExcerpt = (excerpt: string) =>
excerpt.length > MAX_IFRAME_CONFIRM_EXCERPT_LENGTH ? `${excerpt.slice(0, MAX_IFRAME_CONFIRM_EXCERPT_LENGTH).trimEnd()}...` : excerpt;
const getIframeConfirmExcerpt = (publication: ChallengePublication | undefined, publicationTarget: ChallengePublication | undefined, publicationType?: string) => {
const title = getTrimmedExcerpt(publication?.title);
const content = getTrimmedExcerpt(publication?.content);
const link = getTrimmedExcerpt(publication?.link);
const excerpt =
publicationType === 'vote' ? getTrimmedExcerpt(getPublicationPreview(publicationTarget)) : publicationType === 'reply' ? content || link : title || content || link;
return shortenConfirmExcerpt(excerpt || getTrimmedExcerpt(getPublicationPreview(publication)));
};
const getDisplayCommunityAddress = (shortCommunityAddress?: string, communityAddress?: string) =>
shortCommunityAddress || (communityAddress ? getShortAddress(communityAddress) : '') || communityAddress || '';
const iframeChallengeConfirmDecisions = new Map<string, 'accepted' | 'rejected'>();
const ANDROID_USER_AGENT_REGEX = /Android/;
const ANDROID_WEBVIEW_USER_AGENT_REGEX = /\bwv\b|Version\/\d+(?:\.\d+)?\s+Chrome\//;
const isAndroidWebViewUserAgent = () => {
if (typeof navigator === 'undefined') return false;
return ANDROID_USER_AGENT_REGEX.test(navigator.userAgent) && ANDROID_WEBVIEW_USER_AGENT_REGEX.test(navigator.userAgent);
};
const isIosWebKitUserAgent = () => {
if (typeof navigator === 'undefined') return false;
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
};
const isDesktopSafariUserAgent = () => {
if (typeof navigator === 'undefined') return false;
const isAppleVendor = navigator.vendor === 'Apple Computer, Inc.';
const isSafari = /Safari/.test(navigator.userAgent);
const isOtherBrowser = /Chrome|Chromium|CriOS|FxiOS|Edg|OPR|SamsungBrowser/.test(navigator.userAgent);
return isAppleVendor && isSafari && !isOtherBrowser;
};
const shouldUseInlineIframeConfirm = () => {
const platform = Capacitor.getPlatform();
return platform === 'android' || platform === 'ios' || isAndroidWebViewUserAgent() || isIosWebKitUserAgent() || isDesktopSafariUserAgent();
};
const stripLegacyIframeConfirmDetails = (message: string) => message.split(/\r?\n\s*\r?\n/)[0] ?? message;
const TextChallenge = ({ challenge }: { challenge: string }) => <div className={styles.challengeMedia}>{challenge}</div>;
@@ -136,36 +93,38 @@ const getIframeSessionId = (challengeUrl: string) => {
interface IframeChallengeProps {
challenge: string;
confirmKey: string;
confirmMessage: string;
inlineConfirm: boolean;
onCancel: () => void;
onDone: () => void;
onAutoComplete: (challengeAnswers: string[]) => void;
onReady: () => void;
openLabel: string;
closeLabel: string;
rememberPermissionLabel: string;
publicationDetails: React.ReactNode;
}
type IframeConfirmSource = 'native' | 'inline';
type IframeOpenMode = 'trusted' | 'manual';
const IframeChallenge = ({
challenge,
confirmKey,
confirmMessage,
inlineConfirm,
onCancel,
onDone,
onAutoComplete,
onReady,
openLabel,
closeLabel,
rememberPermissionLabel,
publicationDetails,
}: IframeChallengeProps) => {
const account = useAccount();
const [theme] = useTheme();
const trustOrigin = useTrustedBoardUrlPermissionsStore((state) => state.trustOrigin);
const [iframeUrlState, setIframeUrl] = useState('');
const [iframeOrigin, setIframeOrigin] = useState('');
const [inlineErrorMessage, setInlineErrorMessage] = useState('');
const [rememberPermission, setRememberPermission] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const attemptedLoadRef = useRef(false);
const mountedRef = useRef(false);
@@ -192,18 +151,14 @@ const IframeChallenge = ({
);
const handleLoadIframe = useCallback(
(confirmSource: IframeConfirmSource) => {
(mode: IframeOpenMode) => {
if (attemptedLoadRef.current) return;
const iframeUrl = challenge;
if (!iframeUrl) return;
const rejectChallenge = (message: string) => {
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
if (inlineConfirm) {
setInlineErrorMessage(message);
return;
}
alert(message);
onCancel();
attemptedLoadRef.current = true;
setInlineErrorMessage(message);
};
const rawUserAddress = account?.author?.address?.trim();
@@ -227,41 +182,31 @@ const IframeChallenge = ({
rejectChallenge('Error: Invalid URL for authentication challenge');
return;
}
const decision = iframeChallengeConfirmDecisions.get(confirmKey);
if (decision === 'rejected') {
onCancel();
const isTrusted = useTrustedBoardUrlPermissionsStore.getState().isOriginTrusted(validatedUrl.origin);
if (!isTrusted && mode === 'trusted') {
return;
}
if (decision !== 'accepted') {
if (confirmSource === 'native' && !window.confirm(confirmMessage)) {
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
onCancel();
return;
}
iframeChallengeConfirmDecisions.set(confirmKey, 'accepted');
attemptedLoadRef.current = true;
if (!isTrusted && rememberPermission) {
trustOrigin(validatedUrl.origin, getReadableIframeUrl(replacedUrl), null);
}
openValidatedIframe(validatedUrl);
},
[account, challenge, confirmKey, confirmMessage, inlineConfirm, onCancel, openValidatedIframe, theme],
[account, challenge, openValidatedIframe, rememberPermission, theme, trustOrigin],
);
useEffect(() => {
if (inlineConfirm) return;
if (attemptedLoadRef.current) return;
attemptedLoadRef.current = true;
handleLoadIframe('native');
}, [handleLoadIframe, inlineConfirm]);
handleLoadIframe('trusted');
}, [handleLoadIframe]);
const handleInlineConfirm = useCallback(() => {
if (attemptedLoadRef.current) return;
attemptedLoadRef.current = true;
handleLoadIframe('inline');
handleLoadIframe('manual');
}, [handleLoadIframe]);
const handleInlineCancel = useCallback(() => {
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
onCancel();
}, [confirmKey, onCancel]);
}, [onCancel]);
const sendThemeToIframe = useCallback(() => {
postThemeToIframe(iframeRef.current, iframeOrigin, theme);
@@ -302,28 +247,32 @@ const IframeChallenge = ({
}, [expectedSessionId, iframeOrigin, onAutoComplete]);
if (!iframeUrlState) {
if (inlineConfirm) {
return (
<>
<div className={`${styles.challengeMediaWrapper} ${styles.iframeConsentWrapper}`}>
<div className={styles.iframeConsentMessage}>{inlineErrorMessage || confirmMessage}</div>
</div>
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
<span>
{!inlineErrorMessage && (
<button type='button' onClick={handleInlineConfirm}>
{openLabel}
</button>
)}
<button type='button' onClick={handleInlineCancel}>
{closeLabel}
return (
<>
{publicationDetails}
<div className={`${styles.challengeMediaWrapper} ${styles.iframeConsentWrapper}`}>
<div className={styles.iframeConsentMessage}>{inlineErrorMessage || confirmMessage}</div>
{!inlineErrorMessage && (
<label className={styles.iframeTrustCheckbox}>
<input type='checkbox' checked={rememberPermission} onChange={(event) => setRememberPermission(event.target.checked)} />
{rememberPermissionLabel}
</label>
)}
</div>
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
<span>
{!inlineErrorMessage && (
<button type='button' onClick={handleInlineConfirm}>
{openLabel}
</button>
</span>
</div>
</>
);
}
return null;
)}
<button type='button' onClick={handleInlineCancel}>
{closeLabel}
</button>
</span>
</div>
</>
);
}
return (
@@ -347,7 +296,7 @@ const IframeChallenge = ({
);
};
const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: ChallengeProps) => {
const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
const { t } = useTranslation();
const challenges = challenge?.[0]?.challenges;
@@ -377,12 +326,10 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
}));
const currentChallenge = challenges?.[currentChallengeIndex];
const iframeChallengeKey = `${challengeId}:${currentChallengeIndex}:${currentChallenge?.challenge ?? ''}`;
const iframeChallengeKey = `${currentChallengeIndex}:${currentChallenge?.challenge ?? ''}`;
const isTextChallenge = currentChallenge?.type === 'text/plain';
const isImageChallenge = currentChallenge?.type === 'image/png';
const isIframeChallenge = currentChallenge?.type === 'url/iframe';
const inlineIframeConfirm = isIframeChallenge && shouldUseInlineIframeConfirm();
const isIframePending = isIframeChallenge && !inlineIframeConfirm && readyIframeChallengeKey !== iframeChallengeKey;
useEffect(() => {
inputRef.current?.focus();
@@ -474,7 +421,7 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
return null;
}
const isIframeVisible = isIframeChallenge;
const isIframeVisible = isIframeChallenge && readyIframeChallengeKey === iframeChallengeKey;
const containerClasses = [styles.container];
if (isIframeVisible) {
@@ -490,14 +437,14 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
const mobileX = isIframeVisible ? 5 : window.innerWidth / 2 - 150;
const mobileY = isIframeVisible ? Math.max(10, (window.innerHeight - 600) / 2) : window.innerHeight / 2 - 200;
const displayCommunityAddress = getDisplayCommunityAddress(shortCommunityAddress, communityAddress) || t('board');
const iframeConfirmMessage = t('iframe_challenge_confirm', {
board: displayCommunityAddress,
excerpt: getIframeConfirmExcerpt(publication, publicationTarget, publicationType) || t('none'),
interpolation: { escapeValue: false },
publicationType: publicationType ? t(publicationType) : t('post'),
site: readableUrl || t('webpage'),
});
const challengeTitle = isIframeChallenge ? readableUrl || t('iframe') : `Challenge for ${publicationType}`;
const iframeConfirmMessage = stripLegacyIframeConfirmDetails(
t('iframe_challenge_confirm', {
board: displayCommunityAddress,
interpolation: { escapeValue: false },
site: readableUrl || t('webpage'),
}),
);
const challengeTitle = `Challenge for ${publicationType || t('post')}`;
const publicationDetails = (
<>
@@ -532,7 +479,6 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
style={{
x: isMobile ? mobileX : x.to((value) => Math.round(value)),
y: isMobile ? mobileY : y.to((value) => Math.round(value)),
display: isIframePending ? 'none' : undefined,
touchAction: 'none',
}}
>
@@ -545,15 +491,15 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
<IframeChallenge
key={currentChallengeIndex}
challenge={currentChallenge?.challenge ?? ''}
confirmKey={iframeChallengeKey}
confirmMessage={iframeConfirmMessage}
inlineConfirm={inlineIframeConfirm}
onCancel={abandonModal}
onDone={onIframeDone}
onAutoComplete={onIframeAutoComplete}
onReady={onIframeReady}
openLabel={capitalize(t('open'))}
closeLabel={t('close')}
rememberPermissionLabel={t('trusted_board_link_checkbox')}
publicationDetails={publicationDetails}
/>
) : (
<>
@@ -611,7 +557,7 @@ const ChallengeModal = () => {
const challenge = current?.challenge;
const challengeId = current?.id ?? 0;
return isOpen && challenge ? <Challenge key={challengeId} challenge={challenge} challengeId={challengeId} closeModal={closeModal} abandonModal={abandonModal} /> : null;
return isOpen && challenge ? <Challenge key={challengeId} challenge={challenge} closeModal={closeModal} abandonModal={abandonModal} /> : null;
};
export default ChallengeModal;
@@ -42,6 +42,10 @@ vi.mock('../subscriptions-setting', () => ({
default: () => <div data-testid='subscriptions-settings-panel'>subscriptions-settings</div>,
}));
vi.mock('../trusted-board-links-setting', () => ({
default: () => <div data-testid='trusted-board-links-settings-panel'>trusted-board-links-settings</div>,
}));
const LocationProbe = () => {
const location = useLocation();
return <div data-testid='location'>{location.pathname + location.hash}</div>;
@@ -133,6 +137,7 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="media-hosting-settings-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull();
expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="trusted-board-links-settings-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="advanced-settings-panel"]')).not.toBeNull();
const collapseAllControl = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) =>
@@ -150,6 +155,7 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="media-hosting-settings-panel"]')).toBeNull();
expect(container.querySelector('[data-testid="account-settings"]')).toBeNull();
expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).toBeNull();
expect(container.querySelector('[data-testid="trusted-board-links-settings-panel"]')).toBeNull();
expect(container.querySelector('[data-testid="advanced-settings-panel"]')).toBeNull();
});
@@ -9,8 +9,16 @@ import InterfaceSettings from './interface-settings';
import MediaHostingSettings from './media-hosting-settings';
import AdvancedSettings from './advanced-settings';
import SubscriptionsSetting from './subscriptions-setting';
import TrustedBoardLinksSetting from './trusted-board-links-setting';
const allSectionIds = ['interface-settings', 'media-hosting-settings', 'account-settings', 'subscriptions-settings', 'advanced-settings'];
const allSectionIds = [
'interface-settings',
'media-hosting-settings',
'account-settings',
'subscriptions-settings',
'board-link-permissions-settings',
'advanced-settings',
];
const hashToSection = (hash: string): string | null => {
if (hash === 'crypto-address-settings' || hash === 'crypto-wallet-settings') return 'account-settings';
@@ -58,6 +66,7 @@ const SettingsModal = () => {
const showMediaHostingSettings = visibleExpandedSections.has('media-hosting-settings');
const showAccountSettings = visibleExpandedSections.has('account-settings');
const showSubscriptionsSettings = visibleExpandedSections.has('subscriptions-settings');
const showBoardLinkPermissionsSettings = visibleExpandedSections.has('board-link-permissions-settings');
const showAdvancedSettings = visibleExpandedSections.has('advanced-settings');
const allExpanded = useMemo(() => allSectionIds.every((id) => visibleExpandedSections.has(id)), [visibleExpandedSections]);
@@ -162,6 +171,13 @@ const SettingsModal = () => {
</label>
</div>
{showSubscriptionsSettings && <SubscriptionsSetting />}
<div id='board-link-permissions-settings' className={`${styles.setting} ${styles.category}`}>
<label onClick={() => handleCategoryClick('board-link-permissions-settings')}>
<span className={showBoardLinkPermissionsSettings ? styles.hideButton : styles.showButton} />
{t('board_link_permissions')}
</label>
</div>
{showBoardLinkPermissionsSettings && <TrustedBoardLinksSetting />}
<div id='advanced-settings' className={`${styles.setting} ${styles.category}`}>
<label onClick={() => handleCategoryClick('advanced-settings')}>
<span className={showAdvancedSettings ? styles.hideButton : styles.showButton} />
@@ -0,0 +1,62 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useTrustedBoardUrlPermissionsStore from '../../../../stores/use-trusted-board-url-permissions-store';
import TrustedBoardLinksSetting from '../trusted-board-links-setting';
(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>;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
let container: HTMLDivElement;
let root: Root;
const render = () => {
act(() => {
root.render(createElement(TrustedBoardLinksSetting));
});
};
describe('TrustedBoardLinksSetting', () => {
beforeEach(() => {
localStorage.clear();
useTrustedBoardUrlPermissionsStore.setState({ trustedOrigins: {} });
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('shows an empty state when no board links are trusted', () => {
render();
expect(container.textContent).toContain('trusted_board_links_intro');
expect(container.textContent).toContain('trusted_board_links_empty');
});
it('revokes a trusted board link permission', async () => {
useTrustedBoardUrlPermissionsStore.getState().trustOrigin('https://spamblocker.bitsocial.net', 'spamblocker.bitsocial.net');
render();
expect(container.textContent).toContain('spamblocker.bitsocial.net');
const revokeButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'revoke');
await act(async () => {
revokeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(useTrustedBoardUrlPermissionsStore.getState().isOriginTrusted('https://spamblocker.bitsocial.net')).toBe(false);
expect(container.textContent).toContain('trusted_board_links_empty');
});
});
@@ -0,0 +1 @@
export { default } from './trusted-board-links-setting';
@@ -0,0 +1,30 @@
.trustedBoardLinks {
margin: 0 10px 10px;
}
.description, .empty {
margin-bottom: 8px;
}
.list {
list-style: none;
padding: 0;
margin: 0;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.site {
min-width: 0;
overflow-wrap: anywhere;
}
.expiry {
white-space: nowrap;
}
@@ -0,0 +1,38 @@
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import useTrustedBoardUrlPermissionsStore from '../../../stores/use-trusted-board-url-permissions-store';
import styles from './trusted-board-links-setting.module.css';
const TrustedBoardLinksSetting = () => {
const { t } = useTranslation();
const trustedOriginsByOrigin = useTrustedBoardUrlPermissionsStore((state) => state.trustedOrigins);
const revokeOrigin = useTrustedBoardUrlPermissionsStore((state) => state.revokeOrigin);
const trustedOrigins = useMemo(
() => Object.values(trustedOriginsByOrigin).sort((a, b) => a.site.localeCompare(b.site) || a.origin.localeCompare(b.origin)),
[trustedOriginsByOrigin],
);
return (
<div className={styles.trustedBoardLinks}>
<div className={styles.description}>{t('trusted_board_links_intro')}</div>
{trustedOrigins.length ? (
<ul className={styles.list}>
{trustedOrigins.map((permission) => (
<li key={permission.origin} className={styles.item}>
<span className={styles.site}>
{permission.site} <span className={styles.expiry}>({t('trusted_board_link_until_revoked')})</span>
</span>
<button type='button' onClick={() => revokeOrigin(permission.origin)}>
{t('revoke')}
</button>
</li>
))}
</ul>
) : (
<div className={styles.empty}>{t('trusted_board_links_empty')}</div>
)}
</div>
);
};
export default memo(TrustedBoardLinksSetting);