2024-06-08 16:41:05 +02:00
|
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
2026-02-24 15:15:44 +08:00
|
|
|
import type { Placement } from '@floating-ui/react';
|
2024-06-08 16:41:05 +02:00
|
|
|
import { useTranslation } from 'react-i18next';
|
2026-02-24 15:15:44 +08:00
|
|
|
import { useDismiss, useFloating, useFocus, useHover, useInteractions, offset, shift, size, autoUpdate, FloatingPortal } from '@floating-ui/react';
|
2024-05-08 11:10:43 +02:00
|
|
|
import { getLinkMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
|
2024-06-15 16:02:24 +02:00
|
|
|
import { isCatalogView } from '../../lib/utils/view-utils';
|
2024-06-08 16:41:05 +02:00
|
|
|
import useIsMobile from '../../hooks/use-is-mobile';
|
2024-05-08 11:10:43 +02:00
|
|
|
import CommentMedia from '../comment-media';
|
2024-06-08 16:41:05 +02:00
|
|
|
import styles from './markdown.module.css';
|
2025-06-04 12:55:55 +02:00
|
|
|
import { Link, useLocation, useParams } from 'react-router-dom';
|
2024-08-31 14:22:02 +02:00
|
|
|
import { canEmbed } from '../embed';
|
2026-03-04 15:20:06 +08:00
|
|
|
import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } from '../../lib/utils/url-utils';
|
2026-03-12 17:41:11 +08:00
|
|
|
import { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils';
|
2026-03-06 14:55:40 +08:00
|
|
|
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
|
2026-02-10 16:23:15 +08:00
|
|
|
import usePostNumberStore from '../../stores/use-post-number-store';
|
2026-03-09 17:01:35 +08:00
|
|
|
import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages';
|
|
|
|
|
import { useComment } from '@bitsocialnet/bitsocial-react-hooks';
|
2026-02-10 16:23:15 +08:00
|
|
|
import ReplyQuotePreview from '../reply-quote-preview';
|
2026-03-12 17:41:11 +08:00
|
|
|
import ExternalNumberQuoteLink from './external-number-quote-link';
|
2024-03-26 15:07:28 +01:00
|
|
|
|
2026-02-24 15:15:44 +08:00
|
|
|
const safeParseUrl = (href: string): URL | null => {
|
|
|
|
|
try {
|
|
|
|
|
return href.startsWith('http') ? new URL(href) : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2024-06-08 16:41:05 +02:00
|
|
|
interface ContentLinkEmbedProps {
|
|
|
|
|
children: any;
|
|
|
|
|
href: string;
|
|
|
|
|
linkMediaInfo: any;
|
2024-03-26 15:07:28 +01:00
|
|
|
}
|
|
|
|
|
|
2024-06-08 16:41:05 +02:00
|
|
|
const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedProps) => {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const isMobile = useIsMobile();
|
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
|
const [showMedia, setShowMedia] = useState(false);
|
2026-02-24 15:15:44 +08:00
|
|
|
const [placement, setPlacement] = useState<Placement>('right');
|
2024-06-08 16:41:05 +02:00
|
|
|
const availableWidthRef = useRef<number>(0);
|
|
|
|
|
|
|
|
|
|
const { refs, floatingStyles, update, context } = useFloating({
|
|
|
|
|
open: isOpen,
|
|
|
|
|
onOpenChange: setIsOpen,
|
2026-02-24 15:15:44 +08:00
|
|
|
placement,
|
2024-06-08 16:41:05 +02:00
|
|
|
middleware: [
|
|
|
|
|
shift({ padding: 10 }),
|
|
|
|
|
offset({ mainAxis: 5 }),
|
|
|
|
|
size({
|
|
|
|
|
apply({ availableWidth, elements }) {
|
|
|
|
|
availableWidthRef.current = availableWidth;
|
|
|
|
|
if (availableWidth >= 250) {
|
|
|
|
|
elements.floating.style.maxWidth = `${availableWidth - 12}px`;
|
2026-02-24 15:15:44 +08:00
|
|
|
} else if (placement === 'right') {
|
|
|
|
|
setPlacement('left');
|
2024-06-08 16:41:05 +02:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
whileElementsMounted: autoUpdate,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const hover = useHover(context, { move: false });
|
|
|
|
|
const focus = useFocus(context);
|
|
|
|
|
const dismiss = useDismiss(context);
|
|
|
|
|
|
|
|
|
|
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleResize = () => {
|
|
|
|
|
const availableWidth = availableWidthRef.current;
|
|
|
|
|
if (availableWidth >= 250) {
|
2026-02-24 15:15:44 +08:00
|
|
|
setPlacement('right');
|
2024-06-08 16:41:05 +02:00
|
|
|
} else {
|
2026-02-24 15:15:44 +08:00
|
|
|
setPlacement('left');
|
2024-06-08 16:41:05 +02:00
|
|
|
}
|
|
|
|
|
update();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('resize', handleResize);
|
|
|
|
|
};
|
|
|
|
|
}, [update]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<a href={href} target='_blank' rel='noopener noreferrer'>
|
|
|
|
|
{children}
|
|
|
|
|
</a>{' '}
|
|
|
|
|
[
|
2026-02-24 15:15:44 +08:00
|
|
|
<span
|
|
|
|
|
className={styles.embedButton}
|
|
|
|
|
role='button'
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setShowMedia(!showMedia);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onClick={() => setShowMedia(!showMedia)}
|
|
|
|
|
ref={refs.setReference}
|
|
|
|
|
{...getReferenceProps()}
|
|
|
|
|
>
|
2024-06-08 16:41:05 +02:00
|
|
|
{showMedia ? t('remove') : isMobile ? t('open') : t('embed')}
|
|
|
|
|
</span>
|
|
|
|
|
]
|
|
|
|
|
{showMedia && (
|
|
|
|
|
<>
|
|
|
|
|
<br />
|
2025-12-31 15:23:56 +01:00
|
|
|
<CommentMedia commentMediaInfo={linkMediaInfo} disableToggle={true} isReply={false} setShowThumbnail={setShowMedia} showThumbnail={false} />
|
2024-06-08 16:41:05 +02:00
|
|
|
</>
|
|
|
|
|
)}
|
2024-08-31 14:22:02 +02:00
|
|
|
{getHasThumbnail(linkMediaInfo, href) && (
|
|
|
|
|
<FloatingPortal>
|
|
|
|
|
{isOpen && !isMobile && (
|
|
|
|
|
<div className={styles.floatingEmbed} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}>
|
2025-12-31 15:23:56 +01:00
|
|
|
<CommentMedia
|
|
|
|
|
commentMediaInfo={linkMediaInfo}
|
|
|
|
|
disableToggle={true}
|
|
|
|
|
isFloatingEmbed={true}
|
|
|
|
|
isReply={false}
|
|
|
|
|
setShowThumbnail={setShowMedia}
|
|
|
|
|
showThumbnail={false}
|
|
|
|
|
/>
|
2024-08-31 14:22:02 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</FloatingPortal>
|
|
|
|
|
)}
|
2024-06-08 16:41:05 +02:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-04 15:20:06 +08:00
|
|
|
const normalizeContent = (content: string): string => {
|
|
|
|
|
if (!content) return '';
|
|
|
|
|
let normalized = content.replace(/\n \n/g, '\n\n');
|
|
|
|
|
normalized = normalized.replace(/\n{3,}/g, '\n\n');
|
|
|
|
|
return normalized;
|
|
|
|
|
};
|
2024-03-26 15:07:28 +01:00
|
|
|
|
2026-03-04 15:20:06 +08:00
|
|
|
type Token =
|
|
|
|
|
| { type: 'text'; value: string }
|
|
|
|
|
| { type: 'url'; href: string }
|
|
|
|
|
| { type: 'quoteLink'; number: number }
|
2026-03-12 17:41:11 +08:00
|
|
|
| { type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
|
2026-03-04 15:20:06 +08:00
|
|
|
| { type: 'crossBoardLink'; display: string; route: string }
|
|
|
|
|
| { type: 'spoiler'; tokens: Token[] };
|
|
|
|
|
|
2026-03-09 15:31:53 +08:00
|
|
|
const SPOILER_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\]([\s\S]*?)\[\/[sS][pP][oO][iI][lL][eE][rR]\]/;
|
2026-03-04 15:20:06 +08:00
|
|
|
const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?))[.,:;!?]*/;
|
|
|
|
|
const QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
|
|
|
|
|
const URL_REGEX = /https?:\/\/[^\s<\[\]]*[^\s<\[\].,;:!?\"'\)\]>]/;
|
|
|
|
|
|
2026-03-12 17:41:11 +08:00
|
|
|
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',
|
|
|
|
|
);
|
2026-03-04 15:20:06 +08:00
|
|
|
|
|
|
|
|
function getCrossboardRoute(fullPattern: string): string | null {
|
|
|
|
|
const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, '');
|
|
|
|
|
if (!isValidCrossboardPattern(`>>>/${pathPart}`)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
if (/^[a-zA-Z0-9]{1,10}\/$/.test(pathPart)) {
|
|
|
|
|
return `/${pathPart.slice(0, -1)}`;
|
|
|
|
|
}
|
|
|
|
|
if (/^[a-zA-Z0-9]{1,10}\/[a-zA-Z0-9]{46}$/.test(pathPart)) {
|
|
|
|
|
const [code, cid] = pathPart.split('/');
|
|
|
|
|
return `/${code}/thread/${cid}`;
|
|
|
|
|
}
|
|
|
|
|
if (/^[^/]+\/[a-zA-Z0-9]{46}$/.test(pathPart)) {
|
|
|
|
|
const [address, cid] = pathPart.split('/');
|
|
|
|
|
return `/${address}/thread/${cid}`;
|
|
|
|
|
}
|
|
|
|
|
return `/${pathPart}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tokenize(text: string): Token[] {
|
|
|
|
|
const tokens: Token[] = [];
|
|
|
|
|
let lastIndex = 0;
|
|
|
|
|
|
|
|
|
|
const regex = new RegExp(COMBINED_REGEX.source, 'g');
|
|
|
|
|
let match: RegExpExecArray | null;
|
|
|
|
|
|
|
|
|
|
while ((match = regex.exec(text)) !== null) {
|
|
|
|
|
const fullMatch = match[0];
|
|
|
|
|
const matchStart = match.index;
|
|
|
|
|
|
|
|
|
|
if (matchStart > lastIndex) {
|
|
|
|
|
tokens.push({ type: 'text', value: text.slice(lastIndex, matchStart) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (match[1] !== undefined) {
|
|
|
|
|
const innerContent = match[2];
|
|
|
|
|
tokens.push({ type: 'spoiler', tokens: tokenize(innerContent) });
|
|
|
|
|
} else if (match[3] !== undefined) {
|
2026-03-12 17:41:11 +08:00
|
|
|
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];
|
2026-03-04 15:20:06 +08:00
|
|
|
const fullPattern = `>>>/${pathPart}`;
|
|
|
|
|
const route = getCrossboardRoute(fullPattern);
|
|
|
|
|
if (route) {
|
|
|
|
|
tokens.push({ type: 'crossBoardLink', display: fullPattern, route });
|
|
|
|
|
} else {
|
|
|
|
|
tokens.push({ type: 'text', value: fullMatch });
|
|
|
|
|
}
|
2026-03-12 17:41:11 +08:00
|
|
|
} else if (match[8] !== undefined) {
|
|
|
|
|
const number = parseInt(match[9], 10);
|
2026-03-04 15:20:06 +08:00
|
|
|
tokens.push({ type: 'quoteLink', number });
|
2026-03-12 17:41:11 +08:00
|
|
|
} else if (match[10] !== undefined) {
|
2026-03-04 15:20:06 +08:00
|
|
|
tokens.push({ type: 'url', href: fullMatch });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastIndex = regex.lastIndex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (lastIndex < text.length) {
|
|
|
|
|
tokens.push({ type: 'text', value: text.slice(lastIndex) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tokens;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RenderContext {
|
|
|
|
|
isInCatalogView: boolean;
|
|
|
|
|
postCid?: string;
|
|
|
|
|
subplebbitAddress?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderTokens(tokens: Token[], context: RenderContext): React.ReactNode[] {
|
|
|
|
|
const { isInCatalogView, postCid, subplebbitAddress } = context;
|
|
|
|
|
|
|
|
|
|
return tokens.map((token, i) => {
|
|
|
|
|
switch (token.type) {
|
|
|
|
|
case 'text':
|
|
|
|
|
return <React.Fragment key={i}>{token.value}</React.Fragment>;
|
|
|
|
|
case 'url': {
|
|
|
|
|
const href = token.href;
|
|
|
|
|
const linkMediaInfo = getLinkMediaInfo(href);
|
|
|
|
|
const embedUrl = safeParseUrl(href);
|
|
|
|
|
if (!isInCatalogView && ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href))) {
|
|
|
|
|
return (
|
|
|
|
|
<ContentLinkEmbed key={i} href={href} linkMediaInfo={linkMediaInfo}>
|
|
|
|
|
{href}
|
|
|
|
|
</ContentLinkEmbed>
|
|
|
|
|
);
|
2024-03-26 15:07:28 +01:00
|
|
|
}
|
2026-03-04 15:20:06 +08:00
|
|
|
return <React.Fragment key={i}>{renderAnchorLink(href, href, postCid, subplebbitAddress)}</React.Fragment>;
|
|
|
|
|
}
|
|
|
|
|
case 'quoteLink':
|
|
|
|
|
return (
|
|
|
|
|
<span key={i} className={styles.inlineQuoteLink}>
|
|
|
|
|
<NumberQuoteLink number={token.number} threadPostCid={postCid} subplebbitAddress={subplebbitAddress} />
|
|
|
|
|
</span>
|
|
|
|
|
);
|
2026-03-12 17:41:11 +08:00
|
|
|
case 'crossBoardNumberQuoteLink':
|
|
|
|
|
return (
|
|
|
|
|
<span key={i} className={styles.inlineQuoteLink}>
|
|
|
|
|
<ExternalNumberQuoteLink reference={token.reference} />
|
|
|
|
|
</span>
|
|
|
|
|
);
|
2026-03-04 15:20:06 +08:00
|
|
|
case 'crossBoardLink':
|
|
|
|
|
return (
|
|
|
|
|
<Link key={i} to={token.route}>
|
|
|
|
|
{token.display}
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
case 'spoiler':
|
|
|
|
|
return (
|
|
|
|
|
<span key={i} className='spoilertext'>
|
|
|
|
|
{renderTokens(token.tokens, context)}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
2024-03-26 15:07:28 +01:00
|
|
|
}
|
|
|
|
|
});
|
2026-03-04 15:20:06 +08:00
|
|
|
}
|
2025-01-28 15:12:39 +01:00
|
|
|
|
2024-06-08 16:41:05 +02:00
|
|
|
interface MarkdownProps {
|
|
|
|
|
content: string;
|
2024-06-15 18:14:48 +02:00
|
|
|
title?: string;
|
2026-02-10 16:23:15 +08:00
|
|
|
postCid?: string;
|
2026-02-26 15:08:33 +08:00
|
|
|
subplebbitAddress?: string;
|
2024-06-08 16:41:05 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 15:08:33 +08:00
|
|
|
const NumberQuoteLink = ({ number, threadPostCid, subplebbitAddress }: { number: number; threadPostCid?: string; subplebbitAddress?: string }) => {
|
|
|
|
|
const cid = usePostNumberStore((state) => (subplebbitAddress ? state.numberToCid[subplebbitAddress]?.[number] : undefined));
|
2026-02-10 16:23:15 +08:00
|
|
|
const commentFromStore = useSubplebbitsPagesStore((state) => (cid ? state.comments[cid] : undefined));
|
|
|
|
|
const commentFromHook = useComment({ commentCid: cid, onlyIfCached: true });
|
|
|
|
|
const comment = commentFromHook?.number !== undefined ? commentFromHook : commentFromStore;
|
|
|
|
|
const isOP = Boolean(threadPostCid && cid === threadPostCid);
|
|
|
|
|
|
2026-03-06 19:24:49 +08:00
|
|
|
if (isUnavailableQuoteTarget(comment)) {
|
2026-03-06 14:55:40 +08:00
|
|
|
return (
|
|
|
|
|
<ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} quotelinkNumber={number} isQuotelinkUnavailable={true} isOP={isOP} showTrailingBreak={false} />
|
|
|
|
|
);
|
2026-02-10 16:23:15 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-12 17:41:11 +08:00
|
|
|
if (!cid && subplebbitAddress) {
|
|
|
|
|
return (
|
|
|
|
|
<ExternalNumberQuoteLink
|
|
|
|
|
reference={{
|
|
|
|
|
kind: 'same-board',
|
|
|
|
|
number,
|
|
|
|
|
raw: `>>${number}`,
|
|
|
|
|
subplebbitAddress,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 19:24:49 +08:00
|
|
|
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} quotelinkNumber={number} isOP={isOP} showTrailingBreak={false} />;
|
2026-02-10 16:23:15 +08:00
|
|
|
};
|
|
|
|
|
|
2026-02-26 15:08:33 +08:00
|
|
|
const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid?: string, subplebbitAddress?: string) => {
|
2025-06-04 12:55:55 +02:00
|
|
|
if (!href) {
|
|
|
|
|
return <span>{children}</span>;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 23:30:35 +02:00
|
|
|
if (is5chanLink(href)) {
|
|
|
|
|
const internalPath = transform5chanLinkToInternal(href);
|
2025-06-04 12:55:55 +02:00
|
|
|
if (internalPath) {
|
|
|
|
|
let shouldReplaceText = false;
|
|
|
|
|
|
|
|
|
|
if (typeof children === 'string') {
|
|
|
|
|
shouldReplaceText = children === href || children.trim() === href.trim();
|
|
|
|
|
} else if (Array.isArray(children) && children.length === 1 && typeof children[0] === 'string') {
|
|
|
|
|
shouldReplaceText = children[0] === href || children[0].trim() === href.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let displayText: React.ReactNode = children;
|
2026-03-01 18:25:20 +08:00
|
|
|
const childrenText = typeof children === 'string' ? children : Array.isArray(children) ? children[0] : '';
|
|
|
|
|
const isAutolinkedUrl = shouldReplaceText && typeof childrenText === 'string' && childrenText.startsWith('http');
|
|
|
|
|
|
|
|
|
|
if (isAutolinkedUrl) {
|
2026-03-04 15:20:06 +08:00
|
|
|
displayText = children;
|
2026-03-01 18:25:20 +08:00
|
|
|
} else if (shouldReplaceText && internalPath.match(/^\/[^/]+$/)) {
|
|
|
|
|
displayText = internalPath.substring(1);
|
2025-06-04 12:55:55 +02:00
|
|
|
} else if (shouldReplaceText) {
|
|
|
|
|
displayText = internalPath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <Link to={internalPath}>{displayText}</Link>;
|
|
|
|
|
} else {
|
2025-10-17 23:30:35 +02:00
|
|
|
console.warn('Failed to transform 5chan link to internal path:', href);
|
2025-06-04 12:55:55 +02:00
|
|
|
return <Link to={href}>{children}</Link>;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-07 12:50:58 +01:00
|
|
|
if (
|
|
|
|
|
href.startsWith('#/') ||
|
|
|
|
|
href.startsWith('/#/') ||
|
|
|
|
|
href.startsWith('/p/') ||
|
|
|
|
|
href.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/) ||
|
|
|
|
|
href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) ||
|
|
|
|
|
href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/)
|
|
|
|
|
) {
|
2025-06-04 12:55:55 +02:00
|
|
|
return <Link to={href}>{children}</Link>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<a href={href} target='_blank' rel='noopener noreferrer'>
|
|
|
|
|
{children}
|
|
|
|
|
</a>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-26 15:08:33 +08:00
|
|
|
const Markdown = ({ content, title, postCid, subplebbitAddress }: MarkdownProps) => {
|
2026-02-12 20:00:32 +08:00
|
|
|
const location = useLocation();
|
|
|
|
|
const params = useParams();
|
|
|
|
|
const isInCatalogView = isCatalogView(location.pathname, params);
|
|
|
|
|
|
2026-03-04 15:20:06 +08:00
|
|
|
const rendered = useMemo(() => {
|
|
|
|
|
const normalized = normalizeContent(content || '');
|
|
|
|
|
const lines = normalized.split('\n');
|
|
|
|
|
const elements: React.ReactNode[] = [];
|
2024-06-15 16:02:24 +02:00
|
|
|
|
2026-03-04 15:20:06 +08:00
|
|
|
lines.forEach((line, lineIndex) => {
|
|
|
|
|
if (lineIndex > 0) {
|
|
|
|
|
elements.push(<br key={`br-${lineIndex}`} />);
|
|
|
|
|
}
|
2026-02-12 20:00:32 +08:00
|
|
|
|
2026-03-04 15:20:06 +08:00
|
|
|
if (line.length === 0) return;
|
2026-02-12 20:00:32 +08:00
|
|
|
|
2026-03-04 15:20:06 +08:00
|
|
|
const isGreentext = /^>[^>]/.test(line) || line === '>';
|
2026-02-12 20:00:32 +08:00
|
|
|
|
2026-03-04 15:20:06 +08:00
|
|
|
const tokens = tokenize(line);
|
|
|
|
|
const lineElements = renderTokens(tokens, { isInCatalogView, postCid, subplebbitAddress });
|
|
|
|
|
|
|
|
|
|
if (isGreentext) {
|
|
|
|
|
elements.push(
|
|
|
|
|
<span key={`line-${lineIndex}`} className='greentext'>
|
|
|
|
|
{lineElements}
|
|
|
|
|
</span>,
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
elements.push(<React.Fragment key={`line-${lineIndex}`}>{lineElements}</React.Fragment>);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return elements;
|
|
|
|
|
}, [content, isInCatalogView, postCid, subplebbitAddress]);
|
2025-06-04 12:55:55 +02:00
|
|
|
|
2024-03-26 15:07:28 +01:00
|
|
|
return (
|
|
|
|
|
<span className={styles.markdown}>
|
2024-06-15 18:14:48 +02:00
|
|
|
{isInCatalogView && title && (
|
|
|
|
|
<span>
|
|
|
|
|
<b>{title}</b>
|
|
|
|
|
{content ? ': ' : ''}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-03-04 15:20:06 +08:00
|
|
|
{rendered}
|
2024-03-26 15:07:28 +01:00
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
};
|
2024-04-07 17:19:59 +02:00
|
|
|
export default React.memo(Markdown);
|