Linkify untruncated text

This commit is contained in:
Fedor Indutny 2025-06-10 16:13:42 -07:00 committed by GitHub
parent 8ea030074e
commit 6297e12803
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 48 additions and 6 deletions

View file

@ -704,6 +704,7 @@ export function StoryViewer({
onExpandSpoiler={data => setIsSpoilerExpanded(data)} onExpandSpoiler={data => setIsSpoilerExpanded(data)}
renderLocation={RenderLocation.StoryViewer} renderLocation={RenderLocation.StoryViewer}
text={caption.text} text={caption.text}
originalText={caption.text}
/> />
{caption.hasReadMore && !hasExpandedCaption && ( {caption.hasReadMore && !hasExpandedCaption && (
<button <button

View file

@ -40,6 +40,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
showConversation: showConversation:
overrideProps.showConversation || action('showConversation'), overrideProps.showConversation || action('showConversation'),
text: overrideProps.text || '', text: overrideProps.text || '',
originalText: overrideProps.originalText || overrideProps.text || '',
textAttachment: overrideProps.textAttachment || { textAttachment: overrideProps.textAttachment || {
pending: false, pending: false,
}, },
@ -525,3 +526,15 @@ export function ZalgoText(): JSX.Element {
return <MessageBody {...props} />; return <MessageBody {...props} />;
} }
export function LinkOverReadMoreBoundary(): JSX.Element {
const text = 'https://hello.me';
const originalText = 'https://hello.me123';
const props = createProps({
text,
originalText,
});
return <MessageBody {...props} />;
}

View file

@ -60,6 +60,7 @@ export type Props = {
AttachmentType, AttachmentType,
'pending' | 'digest' | 'key' | 'wasTooBig' | 'path' 'pending' | 'digest' | 'key' | 'wasTooBig' | 'path'
>; >;
originalText: string;
}; };
/** /**
@ -84,8 +85,10 @@ export function MessageBody({
showConversation, showConversation,
text, text,
textAttachment, textAttachment,
originalText,
}: Props): JSX.Element { }: Props): JSX.Element {
const shouldDisableLinks = disableLinks || !shouldLinkifyMessage(text); const shouldDisableLinks =
disableLinks || !shouldLinkifyMessage(originalText);
const textWithSuffix = const textWithSuffix =
textAttachment?.pending || onIncreaseTextLength || textAttachment?.wasTooBig textAttachment?.pending || onIncreaseTextLength || textAttachment?.wasTooBig
? `${text}...` ? `${text}...`
@ -177,6 +180,7 @@ export function MessageBody({
i18n={i18n} i18n={i18n}
isSpoilerExpanded={isSpoilerExpanded} isSpoilerExpanded={isSpoilerExpanded}
messageText={textWithSuffix} messageText={textWithSuffix}
originalMessageText={originalText}
onMentionTrigger={conversationId => onMentionTrigger={conversationId =>
showConversation?.({ conversationId }) showConversation?.({ conversationId })
} }

View file

@ -83,6 +83,7 @@ export function MessageBodyReadMore({
showConversation={showConversation} showConversation={showConversation}
text={slicedText} text={slicedText}
textAttachment={textAttachment} textAttachment={textAttachment}
originalText={text}
/> />
); );
} }

View file

@ -45,6 +45,7 @@ type Props = {
i18n: LocalizerType; i18n: LocalizerType;
isSpoilerExpanded: Record<number, boolean>; isSpoilerExpanded: Record<number, boolean>;
messageText: string; messageText: string;
originalMessageText: string;
onExpandSpoiler?: (data: Record<number, boolean>) => void; onExpandSpoiler?: (data: Record<number, boolean>) => void;
onMentionTrigger: (conversationId: string) => void; onMentionTrigger: (conversationId: string) => void;
renderLocation: RenderLocation; renderLocation: RenderLocation;
@ -64,9 +65,12 @@ export function MessageTextRenderer({
onMentionTrigger, onMentionTrigger,
renderLocation, renderLocation,
textLength, textLength,
originalMessageText,
}: Props): JSX.Element { }: Props): JSX.Element {
const finalNodes = React.useMemo(() => { const finalNodes = React.useMemo(() => {
const links = disableLinks ? [] : extractLinks(messageText); const links = disableLinks
? []
: extractLinks(messageText, originalMessageText);
// We need mentions to come last; they can't have children for proper rendering // We need mentions to come last; they can't have children for proper rendering
const sortedRanges = sortBy(bodyRanges, range => const sortedRanges = sortBy(bodyRanges, range =>
@ -104,7 +108,7 @@ export function MessageTextRenderer({
// Group all contigusous spoilers to create one parent spoiler element in the DOM // Group all contigusous spoilers to create one parent spoiler element in the DOM
return groupContiguousSpoilers(nodes); return groupContiguousSpoilers(nodes);
}, [bodyRanges, disableLinks, messageText, textLength]); }, [bodyRanges, disableLinks, messageText, originalMessageText, textLength]);
return ( return (
<> <>
@ -424,19 +428,34 @@ function renderText({
} }
export function extractLinks( export function extractLinks(
messageText: string messageText: string,
// Full, untruncated message text
originalMessageText: string
): ReadonlyArray<BodyRange<{ url: string }>> { ): ReadonlyArray<BodyRange<{ url: string }>> {
// to support emojis immediately before links // to support emojis immediately before links
// we replace emojis with a space for each byte // we replace emojis with a space for each byte
const matches = linkify.match( const matches = linkify.match(
messageText.replace(EMOJI_REGEXP, s => ' '.repeat(s.length)) originalMessageText.replace(EMOJI_REGEXP, s => ' '.repeat(s.length))
); );
if (matches == null) { if (matches == null) {
return []; return [];
} }
return matches.map(match => { // Only return matches present in the `messageText`
const currentMatches = matches.filter(({ index, lastIndex, url }) => {
if (index >= messageText.length) {
return false;
}
if (lastIndex > messageText.length) {
return false;
}
return messageText.slice(index, lastIndex) === url;
});
return currentMatches.map(match => {
return { return {
start: match.index, start: match.index,
length: match.lastIndex - match.index, length: match.lastIndex - match.index,

View file

@ -378,6 +378,7 @@ export function Quote(props: Props): JSX.Element | null {
isSpoilerExpanded={EMPTY_OBJECT} isSpoilerExpanded={EMPTY_OBJECT}
renderLocation={RenderLocation.Quote} renderLocation={RenderLocation.Quote}
text={text} text={text}
originalText={text}
/> />
</div> </div>
); );

View file

@ -170,6 +170,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
prefix={draftPreview.prefix} prefix={draftPreview.prefix}
renderLocation={RenderLocation.ConversationList} renderLocation={RenderLocation.ConversationList}
text={draftPreview.text} text={draftPreview.text}
originalText={draftPreview.text}
/> />
</> </>
); );
@ -191,6 +192,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
prefix={lastMessage.prefix} prefix={lastMessage.prefix}
renderLocation={RenderLocation.ConversationList} renderLocation={RenderLocation.ConversationList}
text={lastMessage.text} text={lastMessage.text}
originalText={lastMessage.text}
/> />
); );
if (lastMessage.status) { if (lastMessage.status) {

View file

@ -168,6 +168,7 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
const messageText = ( const messageText = (
<MessageTextRenderer <MessageTextRenderer
messageText={cleanedSnippet} messageText={cleanedSnippet}
originalMessageText={cleanedSnippet}
bodyRanges={displayBodyRanges} bodyRanges={displayBodyRanges}
direction={undefined} direction={undefined}
disableLinks disableLinks