mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(quotes): resolve external quote links across boards (#1064)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 18px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1100;
|
||||
max-width: min(90vw, 420px);
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||
border-radius: 4px;
|
||||
background: var(--quote-preview-background);
|
||||
color: var(--post-link-text-color);
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #a40000;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
top: 12px;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import useExternalQuoteStatusStore from '../../stores/use-external-quote-status-store';
|
||||
import styles from './external-quote-status.module.css';
|
||||
|
||||
const ExternalQuoteStatus = () => {
|
||||
const message = useExternalQuoteStatusStore((state) => state.message);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} ${styles.error}`} role='alert'>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExternalQuoteStatus;
|
||||
@@ -0,0 +1,149 @@
|
||||
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 ExternalNumberQuoteLink from '../external-number-quote-link';
|
||||
|
||||
(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(() => ({
|
||||
resolveExternalQuoteTargetMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.quote && options?.board) {
|
||||
return `${key}:${options.quote}:${options.board}`;
|
||||
}
|
||||
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => ({ id: 'account-1' }),
|
||||
}));
|
||||
|
||||
vi.mock('@floating-ui/react', () => ({
|
||||
autoUpdate: () => undefined,
|
||||
offset: () => ({}),
|
||||
shift: () => ({}),
|
||||
size: () => ({}),
|
||||
useFloating: () => ({
|
||||
floatingStyles: {},
|
||||
refs: {
|
||||
setFloating: () => undefined,
|
||||
setReference: () => undefined,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directories', () => ({
|
||||
findDirectoryByAddress: () => undefined,
|
||||
useDirectories: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||
default: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/utils/external-quote-resolver', () => ({
|
||||
resolveExternalQuoteTarget: (...args: unknown[]) => testState.resolveExternalQuoteTargetMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-external-quote-status-store', () => ({
|
||||
default: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
clearStatus: vi.fn(),
|
||||
setErrorStatus: vi.fn(),
|
||||
setLoadingStatus: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../loading-ellipsis', () => ({
|
||||
default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string),
|
||||
}));
|
||||
|
||||
vi.mock('../../../views/post', () => ({
|
||||
Post: ({ post }: { post?: { cid?: string } }) => createElement('div', { 'data-testid': 'post-preview' }, post?.cid || 'missing-post'),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
describe('ExternalNumberQuoteLink', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('uses hash-router hrefs for external numeric quotes', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{},
|
||||
createElement(ExternalNumberQuoteLink, {
|
||||
reference: {
|
||||
boardIdentifier: 'fit',
|
||||
kind: 'cross-board',
|
||||
number: 77,
|
||||
raw: '>>>/fit/77',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const link = container.querySelector('a');
|
||||
expect(link?.getAttribute('href')).toBe('#/fit');
|
||||
expect(link?.textContent).toBe('>>>/fit/77');
|
||||
});
|
||||
|
||||
it('shows the resolved post in the hover preview after lazy resolution', async () => {
|
||||
testState.resolveExternalQuoteTargetMock.mockResolvedValue({
|
||||
boardPath: 'fit',
|
||||
cid: 'cid-77',
|
||||
comment: { cid: 'cid-77' },
|
||||
isUnavailable: false,
|
||||
route: '/fit/thread/cid-77',
|
||||
subplebbitAddress: 'fit',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{},
|
||||
createElement(ExternalNumberQuoteLink, {
|
||||
reference: {
|
||||
boardIdentifier: 'fit',
|
||||
kind: 'cross-board',
|
||||
number: 77,
|
||||
raw: '>>>/fit/77',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector('a')?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(document.body.querySelector('[data-testid="post-preview"]')?.textContent).toBe('cid-77');
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,10 @@ vi.mock('../../reply-quote-preview', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../external-number-quote-link', () => ({
|
||||
default: ({ reference }: { reference: { raw: string } }) => createElement('a', { 'data-testid': 'external-number-quote-link', href: '#' }, reference.raw),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -204,6 +208,16 @@ describe('Markdown', () => {
|
||||
expect(quotePreview?.textContent).toBe('comment-42');
|
||||
});
|
||||
|
||||
it('renders lazy same-board and cross-board number quotes when the cid is not cached', async () => {
|
||||
await renderMarkdown({
|
||||
content: '>>42 >>>/fit/77',
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
});
|
||||
|
||||
const lazyLinks = Array.from(container.querySelectorAll('[data-testid="external-number-quote-link"]'));
|
||||
expect(lazyLinks.map((node) => node.textContent)).toEqual(['>>42', '>>>/fit/77']);
|
||||
});
|
||||
|
||||
it('toggles inline media embeds for embeddable links outside catalog view', async () => {
|
||||
testState.mediaInfoByHref = {
|
||||
'https://cdn.example/image.png': {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAccount } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import { useDirectories } from '../../hooks/use-directories';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import { resolveExternalQuoteTarget } from '../../lib/utils/external-quote-resolver';
|
||||
import { ExternalQuoteReference, getExternalQuoteBoardLabel, getExternalQuoteStatusMessage } from '../../lib/utils/external-quote-utils';
|
||||
import useExternalQuoteStatusStore from '../../stores/use-external-quote-status-store';
|
||||
import LoadingEllipsis from '../loading-ellipsis';
|
||||
import postStyles from '../../views/post/post.module.css';
|
||||
import { Post } from '../../views/post';
|
||||
import styles from './markdown.module.css';
|
||||
|
||||
interface ExternalNumberQuoteLinkProps {
|
||||
reference: ExternalQuoteReference;
|
||||
}
|
||||
|
||||
type ResolvedExternalQuoteTarget = NonNullable<Awaited<ReturnType<typeof resolveExternalQuoteTarget>>>;
|
||||
|
||||
type PreviewState =
|
||||
| {
|
||||
kind: 'idle';
|
||||
}
|
||||
| {
|
||||
kind: 'loading';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
kind: 'resolved';
|
||||
target: ResolvedExternalQuoteTarget;
|
||||
};
|
||||
|
||||
type PreviewPosition = {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
const ExternalNumberQuoteLink = ({ reference }: ExternalNumberQuoteLinkProps) => {
|
||||
const { t } = useTranslation();
|
||||
const account = useAccount();
|
||||
const directories = useDirectories();
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const setErrorStatus = useExternalQuoteStatusStore((state) => state.setErrorStatus);
|
||||
const [isResolving, setIsResolving] = useState(false);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [previewState, setPreviewState] = useState<PreviewState>({ kind: 'idle' });
|
||||
const [previewPosition, setPreviewPosition] = useState<PreviewPosition | null>(null);
|
||||
const anchorRef = useRef<HTMLAnchorElement | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const resolvedTargetRef = useRef<ResolvedExternalQuoteTarget | null | undefined>(undefined);
|
||||
const resolutionPromiseRef = useRef<Promise<ResolvedExternalQuoteTarget | null> | null>(null);
|
||||
const previewOpenRef = useRef(false);
|
||||
const latestStatusMessageRef = useRef('');
|
||||
|
||||
const boardLabel = getExternalQuoteBoardLabel(reference, directories);
|
||||
|
||||
const updatePreviewPosition = (anchor: HTMLElement | null) => {
|
||||
if (!anchor || isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const padding = 10;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const previewWidth = previewRef.current?.offsetWidth ?? Math.min(360, window.innerWidth - padding * 2);
|
||||
const previewHeight = previewRef.current?.offsetHeight ?? 0;
|
||||
const shouldPlaceLeft = rect.right + previewWidth + padding > window.innerWidth && rect.left - previewWidth - padding >= padding;
|
||||
const left = shouldPlaceLeft ? Math.max(padding, rect.left - previewWidth - 8) : Math.min(window.innerWidth - previewWidth - padding, rect.right + 8);
|
||||
const top = Math.min(Math.max(padding, rect.top - 8), Math.max(padding, window.innerHeight - previewHeight - padding));
|
||||
|
||||
setPreviewPosition({ left, top });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewOpen || isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reposition = () => updatePreviewPosition(anchorRef.current);
|
||||
reposition();
|
||||
|
||||
window.addEventListener('resize', reposition);
|
||||
window.addEventListener('scroll', reposition, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', reposition);
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
};
|
||||
}, [isMobile, isPreviewOpen, previewState.kind]);
|
||||
|
||||
const getInitialSearchMessage = () =>
|
||||
getExternalQuoteStatusMessage(t, {
|
||||
boardLabel,
|
||||
phase: 'search-board',
|
||||
quoteDisplay: reference.raw,
|
||||
});
|
||||
|
||||
const getUnavailableMessage = () => t('external_quote_unavailable');
|
||||
const getCannotResolveMessage = () =>
|
||||
t('external_quote_cannot_resolve', {
|
||||
board: boardLabel,
|
||||
interpolation: { escapeValue: false },
|
||||
quote: reference.raw,
|
||||
});
|
||||
|
||||
const resolveTarget = async ({ updatePreview }: { updatePreview: boolean }) => {
|
||||
if (!account?.id) {
|
||||
throw new Error('Missing active account while resolving external quote');
|
||||
}
|
||||
|
||||
if (resolvedTargetRef.current !== undefined) {
|
||||
return resolvedTargetRef.current;
|
||||
}
|
||||
|
||||
if (!resolutionPromiseRef.current) {
|
||||
resolutionPromiseRef.current = resolveExternalQuoteTarget({
|
||||
account,
|
||||
directories,
|
||||
onStatus: (status) => {
|
||||
const message = getExternalQuoteStatusMessage(t, status);
|
||||
latestStatusMessageRef.current = message;
|
||||
|
||||
if (updatePreview && previewOpenRef.current) {
|
||||
setPreviewState({ kind: 'loading', message });
|
||||
}
|
||||
},
|
||||
reference,
|
||||
}).then((target) => {
|
||||
resolvedTargetRef.current = target;
|
||||
return target;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const target = await resolutionPromiseRef.current;
|
||||
resolutionPromiseRef.current = null;
|
||||
return target;
|
||||
} catch (error) {
|
||||
resolutionPromiseRef.current = null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
anchorRef.current = e.currentTarget;
|
||||
previewOpenRef.current = true;
|
||||
setIsPreviewOpen(true);
|
||||
updatePreviewPosition(e.currentTarget);
|
||||
|
||||
if (resolvedTargetRef.current === null) {
|
||||
setPreviewState({ kind: 'error', message: getCannotResolveMessage() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedTargetRef.current?.comment) {
|
||||
setPreviewState({ kind: 'resolved', target: resolvedTargetRef.current });
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewState({ kind: 'loading', message: latestStatusMessageRef.current || getInitialSearchMessage() });
|
||||
|
||||
let target: ResolvedExternalQuoteTarget | null;
|
||||
|
||||
try {
|
||||
target = await resolveTarget({ updatePreview: true });
|
||||
} catch {
|
||||
if (previewOpenRef.current) {
|
||||
setPreviewState({ kind: 'error', message: t('external_quote_resolution_unavailable') });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!previewOpenRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
setPreviewState({ kind: 'error', message: getCannotResolveMessage() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.isUnavailable || !target.comment) {
|
||||
setPreviewState({ kind: 'error', message: getUnavailableMessage() });
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewState({ kind: 'resolved', target });
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
previewOpenRef.current = false;
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewPosition(null);
|
||||
};
|
||||
|
||||
const handleClick = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isResolving) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!account?.id) {
|
||||
setErrorStatus(t('external_quote_resolution_unavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResolving(true);
|
||||
|
||||
const finishWithError = (message: string) => {
|
||||
setErrorStatus(message);
|
||||
setIsResolving(false);
|
||||
};
|
||||
|
||||
let target: ResolvedExternalQuoteTarget | null;
|
||||
|
||||
try {
|
||||
target = await resolveTarget({ updatePreview: false });
|
||||
} catch {
|
||||
finishWithError(getCannotResolveMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
finishWithError(getCannotResolveMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.isUnavailable) {
|
||||
finishWithError(getUnavailableMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResolving(false);
|
||||
navigate(target.route);
|
||||
};
|
||||
|
||||
const previewContent =
|
||||
previewState.kind === 'idle' ? null : previewState.kind === 'resolved' ? (
|
||||
previewState.target.comment ? (
|
||||
<Post post={previewState.target.comment} showReplies={false} />
|
||||
) : (
|
||||
<div className={`${styles.externalQuotePreviewState} ${styles.externalQuotePreviewError}`}>{getUnavailableMessage()}</div>
|
||||
)
|
||||
) : previewState.kind === 'loading' ? (
|
||||
<div className={styles.externalQuotePreviewState}>
|
||||
<LoadingEllipsis string={previewState.message} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.externalQuotePreviewState} ${styles.externalQuotePreviewError}`}>{previewState.message}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
aria-busy={isResolving || undefined}
|
||||
className={isResolving ? styles.inlineQuoteLinkResolving : undefined}
|
||||
href={`#/${boardLabel}`}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={anchorRef}
|
||||
>
|
||||
{reference.raw}
|
||||
</a>
|
||||
{!isMobile &&
|
||||
isPreviewOpen &&
|
||||
previewPosition &&
|
||||
previewContent &&
|
||||
createPortal(
|
||||
<div
|
||||
className={postStyles.replyQuotePreview}
|
||||
data-thread-scroll-preview='true'
|
||||
ref={previewRef}
|
||||
style={{ left: previewPosition.left, position: 'fixed', top: previewPosition.top, zIndex: 1000 }}
|
||||
>
|
||||
{previewContent}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExternalNumberQuoteLink;
|
||||
@@ -18,6 +18,21 @@
|
||||
text-decoration: var(--post-quotelink-text-decoration-hover);
|
||||
}
|
||||
|
||||
.inlineQuoteLinkResolving {
|
||||
cursor: progress;
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.externalQuotePreviewState {
|
||||
min-width: 220px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.externalQuotePreviewError {
|
||||
color: #a40000;
|
||||
}
|
||||
|
||||
.embedButton {
|
||||
color: var(--button-desktop-text-color);
|
||||
text-transform: capitalize;
|
||||
@@ -46,4 +61,4 @@
|
||||
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ import styles from './markdown.module.css';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { canEmbed } from '../embed';
|
||||
import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } from '../../lib/utils/url-utils';
|
||||
import { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils';
|
||||
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
|
||||
import usePostNumberStore from '../../stores/use-post-number-store';
|
||||
import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages';
|
||||
import { useComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import ReplyQuotePreview from '../reply-quote-preview';
|
||||
import ExternalNumberQuoteLink from './external-number-quote-link';
|
||||
|
||||
const safeParseUrl = (href: string): URL | null => {
|
||||
try {
|
||||
@@ -142,6 +144,7 @@ type Token =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'url'; href: string }
|
||||
| { type: 'quoteLink'; number: number }
|
||||
| { type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
|
||||
| { type: 'crossBoardLink'; display: string; route: string }
|
||||
| { type: 'spoiler'; tokens: Token[] };
|
||||
|
||||
@@ -150,7 +153,10 @@ const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA
|
||||
const QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
|
||||
const URL_REGEX = /https?:\/\/[^\s<\[\]]*[^\s<\[\].,;:!?\"'\)\]>]/;
|
||||
|
||||
const COMBINED_REGEX = new RegExp(`(${SPOILER_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`, 'g');
|
||||
const COMBINED_REGEX = new RegExp(
|
||||
`(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
|
||||
'g',
|
||||
);
|
||||
|
||||
function getCrossboardRoute(fullPattern: string): string | null {
|
||||
const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, '');
|
||||
@@ -190,7 +196,23 @@ function tokenize(text: string): Token[] {
|
||||
const innerContent = match[2];
|
||||
tokens.push({ type: 'spoiler', tokens: tokenize(innerContent) });
|
||||
} else if (match[3] !== undefined) {
|
||||
const pathPart = match[4];
|
||||
const boardIdentifier = match[4];
|
||||
const number = parseInt(match[5], 10);
|
||||
if (boardIdentifier && !Number.isNaN(number)) {
|
||||
tokens.push({
|
||||
type: 'crossBoardNumberQuoteLink',
|
||||
reference: {
|
||||
boardIdentifier,
|
||||
kind: 'cross-board',
|
||||
number,
|
||||
raw: `>>>/${boardIdentifier}/${number}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
tokens.push({ type: 'text', value: fullMatch });
|
||||
}
|
||||
} else if (match[6] !== undefined) {
|
||||
const pathPart = match[7];
|
||||
const fullPattern = `>>>/${pathPart}`;
|
||||
const route = getCrossboardRoute(fullPattern);
|
||||
if (route) {
|
||||
@@ -198,10 +220,10 @@ function tokenize(text: string): Token[] {
|
||||
} else {
|
||||
tokens.push({ type: 'text', value: fullMatch });
|
||||
}
|
||||
} else if (match[5] !== undefined) {
|
||||
const number = parseInt(match[6], 10);
|
||||
} else if (match[8] !== undefined) {
|
||||
const number = parseInt(match[9], 10);
|
||||
tokens.push({ type: 'quoteLink', number });
|
||||
} else if (match[7] !== undefined) {
|
||||
} else if (match[10] !== undefined) {
|
||||
tokens.push({ type: 'url', href: fullMatch });
|
||||
}
|
||||
|
||||
@@ -247,6 +269,12 @@ function renderTokens(tokens: Token[], context: RenderContext): React.ReactNode[
|
||||
<NumberQuoteLink number={token.number} threadPostCid={postCid} subplebbitAddress={subplebbitAddress} />
|
||||
</span>
|
||||
);
|
||||
case 'crossBoardNumberQuoteLink':
|
||||
return (
|
||||
<span key={i} className={styles.inlineQuoteLink}>
|
||||
<ExternalNumberQuoteLink reference={token.reference} />
|
||||
</span>
|
||||
);
|
||||
case 'crossBoardLink':
|
||||
return (
|
||||
<Link key={i} to={token.route}>
|
||||
@@ -283,6 +311,19 @@ const NumberQuoteLink = ({ number, threadPostCid, subplebbitAddress }: { number:
|
||||
);
|
||||
}
|
||||
|
||||
if (!cid && subplebbitAddress) {
|
||||
return (
|
||||
<ExternalNumberQuoteLink
|
||||
reference={{
|
||||
kind: 'same-board',
|
||||
number,
|
||||
raw: `>>${number}`,
|
||||
subplebbitAddress,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} quotelinkNumber={number} isOP={isOP} showTrailingBreak={false} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -25,11 +25,14 @@ const testState = vi.hoisted(() => ({
|
||||
handleUploadMock: vi.fn(),
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: false,
|
||||
isResolvingExternalQuotes: false,
|
||||
navigateMock: vi.fn(),
|
||||
offlineTitle: 'offline board',
|
||||
postIndex: undefined as number | undefined,
|
||||
publishPostMock: vi.fn(),
|
||||
publishReplyMock: vi.fn(),
|
||||
publishReplyError: null as string | null,
|
||||
publishReplyStateMessage: null as string | null,
|
||||
replyIndex: undefined as number | undefined,
|
||||
resetPublishPostOptionsMock: vi.fn(),
|
||||
resetPublishReplyOptionsMock: vi.fn(),
|
||||
@@ -140,7 +143,10 @@ vi.mock('../../../hooks/use-publish-reply', async () => {
|
||||
});
|
||||
|
||||
return {
|
||||
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
||||
publishReply: testState.publishReplyMock,
|
||||
publishReplyError: testState.publishReplyError,
|
||||
publishReplyStateMessage: testState.publishReplyStateMessage,
|
||||
replyIndex: testState.replyIndex,
|
||||
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
||||
setPublishReplyOptions: (options: Record<string, unknown>) => {
|
||||
@@ -273,8 +279,11 @@ describe('PostForm', () => {
|
||||
testState.gifFrameStatus = 'idle';
|
||||
testState.isOffline = false;
|
||||
testState.isOnlineStatusLoading = false;
|
||||
testState.isResolvingExternalQuotes = false;
|
||||
testState.offlineTitle = 'offline board';
|
||||
testState.postIndex = undefined;
|
||||
testState.publishReplyError = null;
|
||||
testState.publishReplyStateMessage = null;
|
||||
testState.replyIndex = undefined;
|
||||
testState.resolvedSubplebbitAddress = undefined;
|
||||
testState.showUploadControls = true;
|
||||
|
||||
@@ -186,4 +186,8 @@
|
||||
font-weight: bold;
|
||||
padding: 5px;
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const LinkTypePreviewer = ({ link }: { link: string }) => {
|
||||
};
|
||||
|
||||
const PostFormActions = ({
|
||||
disableReplyPublish = false,
|
||||
variant,
|
||||
t,
|
||||
isInPostView,
|
||||
@@ -47,6 +48,7 @@ const PostFormActions = ({
|
||||
isUploading,
|
||||
showUploadControls,
|
||||
}: {
|
||||
disableReplyPublish?: boolean;
|
||||
variant: 'reply' | 'post' | 'upload';
|
||||
t: (key: string) => string;
|
||||
isInPostView: boolean;
|
||||
@@ -58,7 +60,7 @@ const PostFormActions = ({
|
||||
}) => {
|
||||
if (variant === 'reply' && isInPostView) {
|
||||
return (
|
||||
<button onClick={onPublishReply} disabled={isUploading}>
|
||||
<button onClick={onPublishReply} disabled={disableReplyPublish || isUploading}>
|
||||
{t('post')}
|
||||
</button>
|
||||
);
|
||||
@@ -106,6 +108,7 @@ interface PostFormFieldsProps {
|
||||
onPublishReply: () => void;
|
||||
onPublishPost: () => void;
|
||||
handleUpload: () => void;
|
||||
disableReplyPublish: boolean;
|
||||
}
|
||||
|
||||
const PostFormFields = ({
|
||||
@@ -138,6 +141,7 @@ const PostFormFields = ({
|
||||
onPublishReply,
|
||||
onPublishPost,
|
||||
handleUpload,
|
||||
disableReplyPublish,
|
||||
}: PostFormFieldsProps) => (
|
||||
<>
|
||||
<tr>
|
||||
@@ -164,6 +168,7 @@ const PostFormFields = ({
|
||||
onPublishReply={onPublishReply}
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
disableReplyPublish={disableReplyPublish}
|
||||
isUploading={isUploading}
|
||||
showUploadControls={showUploadControls}
|
||||
/>
|
||||
@@ -187,6 +192,7 @@ const PostFormFields = ({
|
||||
onPublishReply={onPublishReply}
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
disableReplyPublish={disableReplyPublish}
|
||||
isUploading={isUploading}
|
||||
showUploadControls={showUploadControls}
|
||||
/>
|
||||
@@ -229,6 +235,7 @@ const PostFormFields = ({
|
||||
onPublishReply={onPublishReply}
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
disableReplyPublish={disableReplyPublish}
|
||||
isUploading={isUploading}
|
||||
showUploadControls={showUploadControls}
|
||||
/>
|
||||
@@ -384,7 +391,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
// in post page, publish a reply to the post
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
const cid = params?.commentCid as string;
|
||||
const { setPublishReplyOptions, resetPublishReplyOptions, replyIndex, publishReply } = usePublishReply({ cid, subplebbitAddress });
|
||||
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
|
||||
usePublishReply({ cid, subplebbitAddress });
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const content = e.target.value;
|
||||
@@ -452,41 +460,46 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
}, [displayName, isInPostView, setPublishReplyOptions, setPublishPostOptions]);
|
||||
|
||||
return (
|
||||
<table className={styles.postFormTable}>
|
||||
<tbody>
|
||||
<PostFormFields
|
||||
t={t}
|
||||
account={account}
|
||||
displayName={displayName}
|
||||
isInPostView={isInPostView}
|
||||
subjectRef={subjectRef}
|
||||
textRef={textRef}
|
||||
urlRef={urlRef}
|
||||
url={url}
|
||||
lengthError={lengthError}
|
||||
handleContentChange={handleContentChange}
|
||||
setPublishPostOptions={setPublishPostOptions}
|
||||
setPublishReplyOptions={setPublishReplyOptions}
|
||||
setUrl={setUrl}
|
||||
isUploading={isUploading}
|
||||
uploadedFileName={uploadedFileName}
|
||||
showUploadControls={showUploadControls}
|
||||
showSpoilerForPost={showSpoilerForPost}
|
||||
showSpoilerForReply={showSpoilerForReply}
|
||||
isInAllView={isInAllView}
|
||||
isInSubscriptionsView={isInSubscriptionsView}
|
||||
isInModView={isInModView}
|
||||
directories={directories}
|
||||
accountSubplebbitAddresses={accountSubplebbitAddresses}
|
||||
subscriptions={subscriptions}
|
||||
subplebbitAddress={subplebbitAddress}
|
||||
requirePostLinkIsMedia={requirePostLinkIsMedia}
|
||||
onPublishReply={onPublishReply}
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<>
|
||||
<table className={styles.postFormTable}>
|
||||
<tbody>
|
||||
<PostFormFields
|
||||
t={t}
|
||||
account={account}
|
||||
displayName={displayName}
|
||||
isInPostView={isInPostView}
|
||||
subjectRef={subjectRef}
|
||||
textRef={textRef}
|
||||
urlRef={urlRef}
|
||||
url={url}
|
||||
lengthError={lengthError}
|
||||
handleContentChange={handleContentChange}
|
||||
setPublishPostOptions={setPublishPostOptions}
|
||||
setPublishReplyOptions={setPublishReplyOptions}
|
||||
setUrl={setUrl}
|
||||
isUploading={isUploading}
|
||||
uploadedFileName={uploadedFileName}
|
||||
showUploadControls={showUploadControls}
|
||||
showSpoilerForPost={showSpoilerForPost}
|
||||
showSpoilerForReply={showSpoilerForReply}
|
||||
isInAllView={isInAllView}
|
||||
isInSubscriptionsView={isInSubscriptionsView}
|
||||
isInModView={isInModView}
|
||||
directories={directories}
|
||||
accountSubplebbitAddresses={accountSubplebbitAddresses}
|
||||
subscriptions={subscriptions}
|
||||
subplebbitAddress={subplebbitAddress}
|
||||
requirePostLinkIsMedia={requirePostLinkIsMedia}
|
||||
onPublishReply={onPublishReply}
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
disableReplyPublish={isResolvingExternalQuotes}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
{publishReplyError && <div className={styles.error}>{publishReplyError}</div>}
|
||||
{publishReplyStateMessage && <div className={styles.status}>{publishReplyStateMessage}</div>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const testState = vi.hoisted(() => ({
|
||||
} as Record<string, { address: string; features?: Record<string, unknown> }>,
|
||||
handleUploadMock: vi.fn(),
|
||||
isMobile: false,
|
||||
isResolvingExternalQuotes: false,
|
||||
isUploading: false,
|
||||
offlineTitle: '' as string | false,
|
||||
offlineStates: {} as Record<string, { isOffline: boolean; isOnlineStatusLoading: boolean; offlineTitle: string | false }>,
|
||||
@@ -26,6 +27,8 @@ const testState = vi.hoisted(() => ({
|
||||
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: '',
|
||||
@@ -128,7 +131,10 @@ vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
||||
|
||||
vi.mock('../../../hooks/use-publish-reply', () => ({
|
||||
default: () => ({
|
||||
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
||||
publishReply: testState.publishReplyMock,
|
||||
publishReplyError: testState.publishReplyError,
|
||||
publishReplyStateMessage: testState.publishReplyStateMessage,
|
||||
replyIndex: testState.replyIndex,
|
||||
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
||||
setPublishReplyOptions: (options: Record<string, unknown>) => testState.setPublishReplyOptionsMock(options),
|
||||
@@ -249,6 +255,7 @@ describe('ReplyModal', () => {
|
||||
};
|
||||
testState.handleUploadMock.mockReset();
|
||||
testState.isMobile = false;
|
||||
testState.isResolvingExternalQuotes = false;
|
||||
testState.isUploading = false;
|
||||
testState.offlineTitle = '';
|
||||
testState.offlineStates = {};
|
||||
@@ -256,6 +263,8 @@ describe('ReplyModal', () => {
|
||||
testState.offlineWarningVisible = false;
|
||||
testState.openEmpty = false;
|
||||
testState.publishReplyMock.mockReset();
|
||||
testState.publishReplyError = null;
|
||||
testState.publishReplyStateMessage = null;
|
||||
testState.quoteInsertNumber = undefined;
|
||||
testState.quoteInsertRequestId = 0;
|
||||
testState.quoteInsertSelectedText = '';
|
||||
|
||||
@@ -156,4 +156,14 @@
|
||||
padding: 3px 5px;
|
||||
margin-top: 1px;
|
||||
text-shadow: 0 1px rgba(0, 0, 0, 0.20);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
width: 294px;
|
||||
font-family: monospace;
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
font-size: 12px;
|
||||
color: var(--post-link-text-color);
|
||||
padding: 3px 5px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
@@ -41,11 +41,12 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
||||
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
||||
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
||||
const { setPublishReplyOptions, publishReply, resetPublishReplyOptions, replyIndex } = usePublishReply({
|
||||
cid: parentCid,
|
||||
subplebbitAddress,
|
||||
postCid,
|
||||
});
|
||||
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
|
||||
usePublishReply({
|
||||
cid: parentCid,
|
||||
subplebbitAddress,
|
||||
postCid,
|
||||
});
|
||||
const account = useAccount();
|
||||
const { displayName } = account?.author || {};
|
||||
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -362,11 +363,18 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
]
|
||||
</span>
|
||||
)}
|
||||
<button className={styles.publishButton} onClick={onPublishReply}>
|
||||
<button className={styles.publishButton} disabled={isResolvingExternalQuotes} onClick={onPublishReply}>
|
||||
{t('post')}
|
||||
</button>
|
||||
</div>
|
||||
{lengthError ? <div className={styles.error}>{lengthError}</div> : error && <div className={styles.error}>{error}</div>}
|
||||
{lengthError ? (
|
||||
<div className={styles.error}>{lengthError}</div>
|
||||
) : error ? (
|
||||
<div className={styles.error}>{error}</div>
|
||||
) : (
|
||||
publishReplyError && <div className={styles.error}>{publishReplyError}</div>
|
||||
)}
|
||||
{publishReplyStateMessage && <div className={styles.status}>{publishReplyStateMessage}</div>}
|
||||
<BoardOfflineAlert className={styles.offlineBoard} hidden={isInAllView || isInSubscriptionsView || isInModView} subplebbitAddress={subplebbitAddress} />
|
||||
</div>
|
||||
</animated.div>
|
||||
|
||||
Reference in New Issue
Block a user