mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
* feat(posting): add scoped options and qst formatting * fix(posting): address post options review feedback * fix(posting): sync option content before publish * fix(posting): handle final review nits
652 lines
22 KiB
TypeScript
652 lines
22 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { Placement } from '@floating-ui/react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useDismiss, useFloating, useFocus, useHover, useInteractions, offset, shift, size, autoUpdate, FloatingPortal } from '@floating-ui/react';
|
|
import { getLinkMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
|
|
import { isCatalogView } from '../../lib/utils/view-utils';
|
|
import useIsMobile from '../../hooks/use-is-mobile';
|
|
import CommentMedia from '../comment-media';
|
|
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, { getCidForPostNumber } from '../../stores/use-post-number-store';
|
|
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
|
import { useComment } from '@bitsocial/bitsocial-react-hooks';
|
|
import ReplyQuotePreview from '../reply-quote-preview';
|
|
import ExternalNumberQuoteLink from './external-number-quote-link';
|
|
import { createDiceRollMarkupRegex, createFortuneMarkupRegex, getMatchingFortuneEntry } from '../../lib/utils/post-options-utils';
|
|
|
|
const safeParseUrl = (href: string): URL | null => {
|
|
try {
|
|
return href.startsWith('http') ? new URL(href) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
interface ContentLinkEmbedProps {
|
|
children: any;
|
|
href: string;
|
|
linkMediaInfo: any;
|
|
}
|
|
|
|
const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedProps) => {
|
|
const { t } = useTranslation();
|
|
const isMobile = useIsMobile();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [showMedia, setShowMedia] = useState(false);
|
|
const [placement, setPlacement] = useState<Placement>('right');
|
|
const availableWidthRef = useRef<number>(0);
|
|
|
|
const { refs, floatingStyles, update, context } = useFloating({
|
|
open: isOpen,
|
|
onOpenChange: setIsOpen,
|
|
placement,
|
|
middleware: [
|
|
shift({ padding: 10 }),
|
|
offset({ mainAxis: 5 }),
|
|
size({
|
|
apply({ availableWidth, elements }) {
|
|
availableWidthRef.current = availableWidth;
|
|
if (availableWidth >= 250) {
|
|
elements.floating.style.maxWidth = `${availableWidth - 12}px`;
|
|
} else if (placement === 'right') {
|
|
setPlacement('left');
|
|
}
|
|
},
|
|
}),
|
|
],
|
|
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) {
|
|
setPlacement('right');
|
|
} else {
|
|
setPlacement('left');
|
|
}
|
|
update();
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
};
|
|
}, [update]);
|
|
|
|
return (
|
|
<>
|
|
<a href={href} target='_blank' rel='noopener noreferrer'>
|
|
{children}
|
|
</a>{' '}
|
|
[
|
|
<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()}
|
|
>
|
|
{showMedia ? t('remove') : isMobile ? t('open') : t('embed')}
|
|
</span>
|
|
]
|
|
{showMedia && (
|
|
<>
|
|
<br />
|
|
<CommentMedia commentMediaInfo={linkMediaInfo} disableToggle={true} isReply={false} setShowThumbnail={setShowMedia} showThumbnail={false} />
|
|
</>
|
|
)}
|
|
{getHasThumbnail(linkMediaInfo, href) && (
|
|
<FloatingPortal>
|
|
{isOpen && !isMobile && (
|
|
<div className={styles.floatingEmbed} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}>
|
|
<CommentMedia
|
|
commentMediaInfo={linkMediaInfo}
|
|
disableToggle={true}
|
|
isFloatingEmbed={true}
|
|
isReply={false}
|
|
setShowThumbnail={setShowMedia}
|
|
showThumbnail={false}
|
|
/>
|
|
</div>
|
|
)}
|
|
</FloatingPortal>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
type Token =
|
|
| { key: string; type: 'text'; value: string }
|
|
| { key: string; type: 'url'; href: string }
|
|
| { key: string; type: 'quoteLink'; number: number }
|
|
| { key: string; type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
|
|
| { key: string; type: 'crossBoardLink'; display: string; route: string }
|
|
| { key: string; type: 'spoiler'; tokens: Token[] };
|
|
|
|
const SPOILER_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\]([\s\S]*?)\[\/[sS][pP][oO][iI][lL][eE][rR]\]/;
|
|
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<>[\]]+/;
|
|
type QstBbcodeTag = 'b' | 'i' | 'red' | 'green' | 'blue';
|
|
const QST_BBCODE_OPEN_REGEX = /\[(b|i|red|green|blue)\]/g;
|
|
const QST_BBCODE_COLORS = {
|
|
red: '#C41E3A',
|
|
green: '#00A550',
|
|
blue: '#1d8dc4',
|
|
} satisfies Record<Extract<QstBbcodeTag, 'red' | 'green' | 'blue'>, string>;
|
|
const QST_BBCODE_COLOR_STYLES = {
|
|
red: { color: QST_BBCODE_COLORS.red },
|
|
green: { color: QST_BBCODE_COLORS.green },
|
|
blue: { color: QST_BBCODE_COLORS.blue },
|
|
} satisfies Record<Extract<QstBbcodeTag, 'red' | 'green' | 'blue'>, React.CSSProperties>;
|
|
|
|
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',
|
|
);
|
|
|
|
const makeTokenKey = (prefix: string, type: Token['type'], start: number, end: number): string => `${prefix}${type}:${start}:${end}`;
|
|
|
|
const isGreentextLine = (line: string): boolean => {
|
|
if (line === '>') return true;
|
|
if (!/^>+[^>]/.test(line)) return false;
|
|
return !/^>>\d/.test(line) && !line.startsWith('>>>/');
|
|
};
|
|
|
|
function normalizeInternalRouteHref(href: string): string {
|
|
if (href.startsWith('/#/')) {
|
|
return href.slice(2);
|
|
}
|
|
if (href.startsWith('#/')) {
|
|
return href.slice(1);
|
|
}
|
|
return href;
|
|
}
|
|
|
|
function splitUrlTrailingText(rawHref: string): { href: string; trailingText: string } {
|
|
let href = rawHref;
|
|
let trailingText = '';
|
|
|
|
while (href) {
|
|
const trailingPunctuationMatch = href.match(/[.,;:!?"']+$/);
|
|
if (trailingPunctuationMatch) {
|
|
trailingText = `${trailingPunctuationMatch[0]}${trailingText}`;
|
|
href = href.slice(0, -trailingPunctuationMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
if (href.endsWith(')')) {
|
|
const openingParens = (href.match(/\(/g) || []).length;
|
|
const closingParens = (href.match(/\)/g) || []).length;
|
|
if (closingParens > openingParens) {
|
|
trailingText = `)${trailingText}`;
|
|
href = href.slice(0, -1);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return { href, trailingText };
|
|
}
|
|
|
|
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, keyPrefix = ''): 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;
|
|
const matchEnd = regex.lastIndex;
|
|
|
|
if (matchStart > lastIndex) {
|
|
tokens.push({
|
|
key: makeTokenKey(keyPrefix, 'text', lastIndex, matchStart),
|
|
type: 'text',
|
|
value: text.slice(lastIndex, matchStart),
|
|
});
|
|
}
|
|
|
|
if (match[1] !== undefined) {
|
|
const innerContent = match[2];
|
|
const key = makeTokenKey(keyPrefix, 'spoiler', matchStart, matchEnd);
|
|
tokens.push({ key, type: 'spoiler', tokens: tokenize(innerContent, `${key}/`) });
|
|
} else if (match[3] !== undefined) {
|
|
const boardIdentifier = match[4];
|
|
const number = parseInt(match[5], 10);
|
|
if (boardIdentifier && !Number.isNaN(number)) {
|
|
tokens.push({
|
|
key: makeTokenKey(keyPrefix, 'crossBoardNumberQuoteLink', matchStart, matchEnd),
|
|
type: 'crossBoardNumberQuoteLink',
|
|
reference: {
|
|
boardIdentifier,
|
|
kind: 'cross-board',
|
|
number,
|
|
raw: `>>>/${boardIdentifier}/${number}`,
|
|
},
|
|
});
|
|
} else {
|
|
tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
|
|
}
|
|
} else if (match[6] !== undefined) {
|
|
const pathPart = match[7];
|
|
const fullPattern = `>>>/${pathPart}`;
|
|
const route = getCrossboardRoute(fullPattern);
|
|
if (route) {
|
|
const trailingText = fullMatch.startsWith(fullPattern) ? fullMatch.slice(fullPattern.length) : '';
|
|
const linkEnd = trailingText ? matchEnd - trailingText.length : matchEnd;
|
|
tokens.push({ key: makeTokenKey(keyPrefix, 'crossBoardLink', matchStart, linkEnd), type: 'crossBoardLink', display: fullPattern, route });
|
|
if (trailingText) {
|
|
tokens.push({ key: makeTokenKey(keyPrefix, 'text', linkEnd, matchEnd), type: 'text', value: trailingText });
|
|
}
|
|
} else {
|
|
tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch });
|
|
}
|
|
} else if (match[8] !== undefined) {
|
|
const number = parseInt(match[9], 10);
|
|
tokens.push({ key: makeTokenKey(keyPrefix, 'quoteLink', matchStart, matchEnd), type: 'quoteLink', number });
|
|
} else if (match[10] !== undefined) {
|
|
const { href, trailingText } = splitUrlTrailingText(fullMatch);
|
|
const linkEnd = trailingText ? matchEnd - trailingText.length : matchEnd;
|
|
tokens.push({ key: makeTokenKey(keyPrefix, 'url', matchStart, linkEnd), type: 'url', href });
|
|
if (trailingText) {
|
|
tokens.push({ key: makeTokenKey(keyPrefix, 'text', linkEnd, matchEnd), type: 'text', value: trailingText });
|
|
}
|
|
}
|
|
|
|
lastIndex = regex.lastIndex;
|
|
}
|
|
|
|
if (lastIndex < text.length) {
|
|
tokens.push({
|
|
key: makeTokenKey(keyPrefix, 'text', lastIndex, text.length),
|
|
type: 'text',
|
|
value: text.slice(lastIndex),
|
|
});
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
interface RenderContext {
|
|
isInCatalogView: boolean;
|
|
postCid?: string;
|
|
communityAddress?: string;
|
|
enableQstBbcode: boolean;
|
|
}
|
|
|
|
interface MarkdownProps {
|
|
content: string;
|
|
title?: string;
|
|
postCid?: string;
|
|
communityAddress?: string;
|
|
}
|
|
|
|
const NumberQuoteLink = ({ number, threadPostCid, communityAddress }: { number: number; threadPostCid?: string; communityAddress?: string }) => {
|
|
const cid = usePostNumberStore((state) => getCidForPostNumber(state.numberToCid, communityAddress, number));
|
|
const threadPostNumber = usePostNumberStore((state) => (threadPostCid ? state.cidToNumber[threadPostCid] : undefined));
|
|
const commentFromStore = useCommunitiesPagesStore((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) || (threadPostNumber !== undefined && number === threadPostNumber));
|
|
|
|
if (isUnavailableQuoteTarget(comment)) {
|
|
return (
|
|
<ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} quotelinkNumber={number} isQuotelinkUnavailable={true} isOP={isOP} showTrailingBreak={false} />
|
|
);
|
|
}
|
|
|
|
if (!cid && communityAddress) {
|
|
return (
|
|
<ExternalNumberQuoteLink
|
|
isOP={isOP}
|
|
reference={{
|
|
kind: 'same-board',
|
|
number,
|
|
raw: `>>${number}`,
|
|
communityAddress,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} quotelinkNumber={number} isOP={isOP} showTrailingBreak={false} />;
|
|
};
|
|
|
|
const AnchorLink = ({ href, text }: { href: string; text: string }) => {
|
|
if (!href) {
|
|
return <span>{text}</span>;
|
|
}
|
|
|
|
if (is5chanLink(href)) {
|
|
const internalPath = transform5chanLinkToInternal(href);
|
|
if (internalPath) {
|
|
const internalRoute = normalizeInternalRouteHref(internalPath);
|
|
let displayText: React.ReactNode = text;
|
|
const isAutolinkedUrl = text.startsWith('http');
|
|
|
|
if (isAutolinkedUrl) {
|
|
displayText = text;
|
|
} else if (internalRoute.match(/^\/[^/]+$/)) {
|
|
displayText = internalRoute.substring(1);
|
|
} else {
|
|
displayText = internalRoute;
|
|
}
|
|
|
|
return <Link to={internalRoute}>{displayText}</Link>;
|
|
} else {
|
|
console.warn('Failed to transform 5chan link to internal path:', href);
|
|
return <Link to={href}>{text}</Link>;
|
|
}
|
|
}
|
|
|
|
if (
|
|
href.startsWith('#/') ||
|
|
href.startsWith('/#/') ||
|
|
href.startsWith('/p/') ||
|
|
href.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/) ||
|
|
href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) ||
|
|
href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/)
|
|
) {
|
|
return <Link to={normalizeInternalRouteHref(href)}>{text}</Link>;
|
|
}
|
|
|
|
return (
|
|
<a href={href} target='_blank' rel='noopener noreferrer'>
|
|
{text}
|
|
</a>
|
|
);
|
|
};
|
|
|
|
const TokenNode = ({ token, context }: { token: Token; context: RenderContext }) => {
|
|
const { isInCatalogView, postCid, communityAddress } = context;
|
|
|
|
switch (token.type) {
|
|
case 'text':
|
|
return context.enableQstBbcode ? <QstBbcodeText text={token.value} tokenKey={token.key} /> : <>{token.value}</>;
|
|
case 'url': {
|
|
const href = token.href;
|
|
const linkMediaInfo = getLinkMediaInfo(href);
|
|
const embedUrl = safeParseUrl(href);
|
|
if (!isInCatalogView && ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href))) {
|
|
return (
|
|
<ContentLinkEmbed href={href} linkMediaInfo={linkMediaInfo}>
|
|
{href}
|
|
</ContentLinkEmbed>
|
|
);
|
|
}
|
|
return <AnchorLink href={href} text={href} />;
|
|
}
|
|
case 'quoteLink':
|
|
return (
|
|
<span className={styles.inlineQuoteLink}>
|
|
<NumberQuoteLink number={token.number} threadPostCid={postCid} communityAddress={communityAddress} />
|
|
</span>
|
|
);
|
|
case 'crossBoardNumberQuoteLink':
|
|
return (
|
|
<span className={styles.inlineQuoteLink}>
|
|
<ExternalNumberQuoteLink reference={token.reference} />
|
|
</span>
|
|
);
|
|
case 'crossBoardLink':
|
|
return <Link to={token.route}>{token.display}</Link>;
|
|
case 'spoiler':
|
|
return (
|
|
<span className='spoilertext'>
|
|
<TokenList tokens={token.tokens} context={context} />
|
|
</span>
|
|
);
|
|
}
|
|
};
|
|
|
|
const TokenList = ({ tokens, context }: { tokens: Token[]; context: RenderContext }) => {
|
|
return (
|
|
<>
|
|
{tokens.map((token) => (
|
|
<TokenNode key={token.key} token={token} context={context} />
|
|
))}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const findMatchingQstBbcodeClose = (text: string, tag: QstBbcodeTag, searchStart: number): number => {
|
|
const tagRegex = new RegExp(`\\[(/?)${tag}\\]`, 'g');
|
|
tagRegex.lastIndex = searchStart;
|
|
let depth = 1;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = tagRegex.exec(text)) !== null) {
|
|
depth += match[1] ? -1 : 1;
|
|
if (depth === 0) {
|
|
return match.index;
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
};
|
|
|
|
const renderQstBbcodeElement = (tag: QstBbcodeTag, key: string, children: React.ReactNode[]) => {
|
|
if (tag === 'b') {
|
|
return <strong key={key}>{children}</strong>;
|
|
}
|
|
if (tag === 'i') {
|
|
return <em key={key}>{children}</em>;
|
|
}
|
|
return (
|
|
<span key={key} style={QST_BBCODE_COLOR_STYLES[tag]}>
|
|
{children}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const renderQstBbcodeText = (text: string, keyPrefix: string): React.ReactNode[] => {
|
|
const elements: React.ReactNode[] = [];
|
|
const regex = new RegExp(QST_BBCODE_OPEN_REGEX.source, 'g');
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = regex.exec(text)) !== null) {
|
|
const tag = match[1] as QstBbcodeTag;
|
|
const matchStart = match.index;
|
|
const contentStart = regex.lastIndex;
|
|
const closeStart = findMatchingQstBbcodeClose(text, tag, contentStart);
|
|
|
|
if (closeStart === -1) {
|
|
continue;
|
|
}
|
|
|
|
if (matchStart > lastIndex) {
|
|
elements.push(<React.Fragment key={`${keyPrefix}text-${lastIndex}-${matchStart}`}>{text.slice(lastIndex, matchStart)}</React.Fragment>);
|
|
}
|
|
|
|
const closeEnd = closeStart + tag.length + 3;
|
|
const childKeyPrefix = `${keyPrefix}${tag}-${matchStart}-`;
|
|
elements.push(renderQstBbcodeElement(tag, `${keyPrefix}${tag}-${matchStart}-${closeEnd}`, renderQstBbcodeText(text.slice(contentStart, closeStart), childKeyPrefix)));
|
|
lastIndex = closeEnd;
|
|
regex.lastIndex = closeEnd;
|
|
}
|
|
|
|
if (lastIndex < text.length) {
|
|
elements.push(<React.Fragment key={`${keyPrefix}text-${lastIndex}-${text.length}`}>{text.slice(lastIndex)}</React.Fragment>);
|
|
}
|
|
|
|
return elements;
|
|
};
|
|
|
|
const QstBbcodeText = ({ text, tokenKey }: { text: string; tokenKey: string }) => <>{renderQstBbcodeText(text, `${tokenKey}/`)}</>;
|
|
|
|
const Fortune = ({ color, text }: { color: string; text: string }) => (
|
|
<span className='fortune' style={{ color }}>
|
|
<br />
|
|
<br />
|
|
<strong>Your fortune: {text}</strong>
|
|
</span>
|
|
);
|
|
|
|
const DiceRoll = ({ text }: { text: string }) => (
|
|
<strong>
|
|
{text}
|
|
<br />
|
|
<br />
|
|
</strong>
|
|
);
|
|
|
|
const renderLineContent = (line: string, context: RenderContext): React.ReactNode[] => {
|
|
const elements: React.ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
const fortuneMarkupRegex = createFortuneMarkupRegex();
|
|
const diceRollMarkupRegex = createDiceRollMarkupRegex();
|
|
|
|
while (lastIndex < line.length) {
|
|
fortuneMarkupRegex.lastIndex = lastIndex;
|
|
diceRollMarkupRegex.lastIndex = lastIndex;
|
|
|
|
const fortuneMatch = fortuneMarkupRegex.exec(line);
|
|
const diceMatch = diceRollMarkupRegex.exec(line);
|
|
const nextMatch =
|
|
fortuneMatch && diceMatch
|
|
? fortuneMatch.index <= diceMatch.index
|
|
? { type: 'fortune' as const, match: fortuneMatch }
|
|
: { type: 'dice' as const, match: diceMatch }
|
|
: fortuneMatch
|
|
? { type: 'fortune' as const, match: fortuneMatch }
|
|
: diceMatch
|
|
? { type: 'dice' as const, match: diceMatch }
|
|
: null;
|
|
|
|
if (!nextMatch) {
|
|
break;
|
|
}
|
|
|
|
const [fullMatch] = nextMatch.match;
|
|
const matchStart = nextMatch.match.index;
|
|
const matchEnd = matchStart + fullMatch.length;
|
|
|
|
if (matchStart > lastIndex) {
|
|
elements.push(<TokenList key={`text-${lastIndex}-${matchStart}`} tokens={tokenize(line.slice(lastIndex, matchStart), `${lastIndex}:`)} context={context} />);
|
|
}
|
|
|
|
if (nextMatch.type === 'dice') {
|
|
elements.push(<DiceRoll key={`dice-${matchStart}`} text={nextMatch.match[1]} />);
|
|
} else {
|
|
const [, color, text] = nextMatch.match;
|
|
const fortune = getMatchingFortuneEntry(color, text);
|
|
if (fortune) {
|
|
elements.push(<Fortune key={`fortune-${matchStart}`} color={fortune.color} text={fortune.text} />);
|
|
} else {
|
|
elements.push(<TokenList key={`text-${matchStart}-${matchEnd}`} tokens={tokenize(fullMatch, `${matchStart}:`)} context={context} />);
|
|
}
|
|
}
|
|
|
|
lastIndex = matchEnd;
|
|
}
|
|
|
|
if (lastIndex < line.length) {
|
|
elements.push(<TokenList key={`text-${lastIndex}-${line.length}`} tokens={tokenize(line.slice(lastIndex), `${lastIndex}:`)} context={context} />);
|
|
}
|
|
|
|
return elements;
|
|
};
|
|
|
|
const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) => {
|
|
const location = useLocation();
|
|
const params = useParams();
|
|
const isInCatalogView = isCatalogView(location.pathname, params);
|
|
const enableQstBbcode = location.pathname.split('/').filter(Boolean)[0] === 'qst';
|
|
|
|
const rendered = useMemo(() => {
|
|
const normalized = normalizeContent(content || '');
|
|
const lines = normalized.split('\n');
|
|
const elements: React.ReactNode[] = [];
|
|
|
|
const context = { isInCatalogView, postCid, communityAddress, enableQstBbcode };
|
|
|
|
lines.forEach((line, lineIndex) => {
|
|
if (lineIndex > 0) {
|
|
elements.push(<br key={`br-${lineIndex}`} />);
|
|
}
|
|
|
|
if (line.length === 0) return;
|
|
|
|
const isGreentext = isGreentextLine(line);
|
|
|
|
const lineElements = renderLineContent(line, context);
|
|
|
|
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, communityAddress, enableQstBbcode]);
|
|
|
|
return (
|
|
<span className={styles.markdown}>
|
|
{isInCatalogView && title && (
|
|
<span>
|
|
<b>{title}</b>
|
|
{content ? ': ' : ''}
|
|
</span>
|
|
)}
|
|
{rendered}
|
|
</span>
|
|
);
|
|
};
|
|
export default React.memo(Markdown);
|