LinkPreview: check RTL of text

This commit is contained in:
Alex Gleason 2023-10-10 19:58:57 -05:00
parent f583e7eeb5
commit 207e93b6a8
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
2 changed files with 61 additions and 34 deletions

View File

@ -3,23 +3,14 @@ import { List as ImmutableList } from 'immutable';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Blurhash from 'soapbox/components/blurhash'; import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon'; import { HStack, Stack, Text, Icon } from 'soapbox/components/ui';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import { normalizeAttachment } from 'soapbox/normalizers'; import { normalizeAttachment } from 'soapbox/normalizers';
import { getTextDirection } from 'soapbox/rtl';
import { addAutoPlay } from 'soapbox/utils/media'; import { addAutoPlay } from 'soapbox/utils/media';
import type { Card as CardEntity, Attachment } from 'soapbox/types/entities'; import type { Card as CardEntity, Attachment } from 'soapbox/types/entities';
const trim = (text: string, len: number): string => { /** Props for `PreviewCard`. */
const cut = text.indexOf(' ', len);
if (cut === -1) {
return text;
}
return text.substring(0, cut) + (text.length > len ? '…' : '');
};
interface IPreviewCard { interface IPreviewCard {
card: CardEntity; card: CardEntity;
maxTitle?: number; maxTitle?: number;
@ -31,6 +22,7 @@ interface IPreviewCard {
horizontal?: boolean; horizontal?: boolean;
} }
/** Displays a Mastodon link preview. Similar to OEmbed. */
const PreviewCard: React.FC<IPreviewCard> = ({ const PreviewCard: React.FC<IPreviewCard> = ({
card, card,
defaultWidth = 467, defaultWidth = 467,
@ -48,6 +40,8 @@ const PreviewCard: React.FC<IPreviewCard> = ({
setEmbedded(false); setEmbedded(false);
}, [card.url]); }, [card.url]);
const direction = getTextDirection(card.title + card.description);
const trimmedTitle = trim(card.title, maxTitle); const trimmedTitle = trim(card.title, maxTitle);
const trimmedDescription = trim(card.description, maxDescription); const trimmedDescription = trim(card.description, maxDescription);
@ -123,26 +117,27 @@ const PreviewCard: React.FC<IPreviewCard> = ({
title={trimmedTitle} title={trimmedTitle}
rel='noopener' rel='noopener'
target='_blank' target='_blank'
dir={direction}
> >
<span>{trimmedTitle}</span> <span dir={direction}>{trimmedTitle}</span>
</a> </a>
) : ( ) : (
<span title={trimmedTitle}>{trimmedTitle}</span> <span title={trimmedTitle} dir={direction}>{trimmedTitle}</span>
); );
const description = ( const description = (
<Stack space={2} className='flex-1 overflow-hidden p-4'> <Stack space={2} className='flex-1 overflow-hidden p-4'>
{trimmedTitle && ( {trimmedTitle && (
<Text weight='bold'>{title}</Text> <Text weight='bold' direction={direction}>{title}</Text>
)} )}
{trimmedDescription && ( {trimmedDescription && (
<Text>{trimmedDescription}</Text> <Text direction={direction}>{trimmedDescription}</Text>
)} )}
<HStack space={1} alignItems='center'> <HStack space={1} alignItems='center'>
<Text tag='span' theme='muted'> <Text tag='span' theme='muted'>
<Icon src={require('@tabler/icons/link.svg')} /> <Icon src={require('@tabler/icons/link.svg')} />
</Text> </Text>
<Text tag='span' theme='muted' size='sm'> <Text tag='span' theme='muted' size='sm' direction={direction}>
{card.provider_name} {card.provider_name}
</Text> </Text>
</HStack> </HStack>
@ -253,4 +248,15 @@ const PreviewCard: React.FC<IPreviewCard> = ({
); );
}; };
/** Trim the text, adding ellipses if it's too long. */
function trim(text: string, len: number): string {
const cut = text.indexOf(' ', len);
if (cut === -1) {
return text;
}
return text.substring(0, cut) + (text.length > len ? '…' : '');
}
export default PreviewCard; export default PreviewCard;

View File

@ -1,38 +1,59 @@
// U+0590 to U+05FF - Hebrew /** Unicode character ranges for RTL characters. */
// U+0600 to U+06FF - Arabic
// U+0700 to U+074F - Syriac
// U+0750 to U+077F - Arabic Supplement
// U+0780 to U+07BF - Thaana
// U+07C0 to U+07FF - N'Ko
// U+0800 to U+083F - Samaritan
// U+08A0 to U+08FF - Arabic Extended-A
// U+FB1D to U+FB4F - Hebrew presentation forms
// U+FB50 to U+FDFF - Arabic presentation forms A
// U+FE70 to U+FEFF - Arabic presentation forms B
const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg; const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
/** Check if text is right-to-left (eg Arabic). */ /**
export function isRtl(text: string): boolean { * Check if text is right-to-left (eg Arabic).
*
* - U+0590 to U+05FF - Hebrew
* - U+0600 to U+06FF - Arabic
* - U+0700 to U+074F - Syriac
* - U+0750 to U+077F - Arabic Supplement
* - U+0780 to U+07BF - Thaana
* - U+07C0 to U+07FF - N'Ko
* - U+0800 to U+083F - Samaritan
* - U+08A0 to U+08FF - Arabic Extended-A
* - U+FB1D to U+FB4F - Hebrew presentation forms
* - U+FB50 to U+FDFF - Arabic presentation forms A
* - U+FE70 to U+FEFF - Arabic presentation forms B
*/
function isRtl(text: string, confidence = 0.3): boolean {
if (text.length === 0) { if (text.length === 0) {
return false; return false;
} }
// Remove http(s), (s)ftp, ws(s), blob and smtp(s) links // Remove http(s), (s)ftp, ws(s), blob and smtp(s) links
text = text.replace(/(?:https?|ftp|sftp|ws|wss|blob|smtp|smtps):\/\/[\S]+/g, ''); text = text.replace(/(?:https?|ftp|sftp|ws|wss|blob|smtp|smtps):\/\/[\S]+/g, '');
// Remove email address links // Remove email address links
text = text.replace(/(mailto:)([^\s@]+@[^\s@]+\.[^\s@]+)/g, ''); text = text.replace(/(mailto:)([^\s@]+@[^\s@]+\.[^\s@]+)/g, '');
// Remove Phone numbe links // Remove phone number links
text = text.replace(/(tel:)([+\d\s()-]+)/g, ''); text = text.replace(/(tel:)([+\d\s()-]+)/g, '');
// Remove mentions
text = text.replace(/(?:^|[^/\w])@([a-z0-9_]+(@[a-z0-9.-]+)?)/ig, ''); text = text.replace(/(?:^|[^/\w])@([a-z0-9_]+(@[a-z0-9.-]+)?)/ig, '');
// Remove hashtags
text = text.replace(/(?:^|[^/\w])#([\S]+)/ig, ''); text = text.replace(/(?:^|[^/\w])#([\S]+)/ig, '');
// Remove all non-word characters
text = text.replace(/\s+/g, ''); text = text.replace(/\s+/g, '');
const matches = text.match(rtlChars); const matches = text.match(rtlChars);
if (!matches) { if (!matches) {
return false; return false;
} }
return matches.length / text.length > 0.3; return matches.length / text.length > confidence;
} }
interface GetTextDirectionOpts {
/** The default direction to return if the text is empty. */
fallback?: 'ltr' | 'rtl';
/** The confidence threshold (0-1) to use when determining the direction. */
confidence?: number;
}
/** Get the direction of the text. */
function getTextDirection(text: string, { fallback = 'ltr', confidence }: GetTextDirectionOpts = {}): 'ltr' | 'rtl' {
if (!text) return fallback;
return isRtl(text, confidence) ? 'rtl' : 'ltr';
}
export { getTextDirection, isRtl };