From 207e93b6a8a5c5a5dad53767f4755f455996e205 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 10 Oct 2023 19:58:57 -0500 Subject: [PATCH] LinkPreview: check RTL of text --- src/components/preview-card.tsx | 40 ++++++++++++++---------- src/rtl.ts | 55 +++++++++++++++++++++++---------- 2 files changed, 61 insertions(+), 34 deletions(-) diff --git a/src/components/preview-card.tsx b/src/components/preview-card.tsx index d11479e03..756e7d528 100644 --- a/src/components/preview-card.tsx +++ b/src/components/preview-card.tsx @@ -3,23 +3,14 @@ import { List as ImmutableList } from 'immutable'; import React, { useState, useEffect } from 'react'; import Blurhash from 'soapbox/components/blurhash'; -import Icon from 'soapbox/components/icon'; -import { HStack, Stack, Text } from 'soapbox/components/ui'; +import { HStack, Stack, Text, Icon } from 'soapbox/components/ui'; import { normalizeAttachment } from 'soapbox/normalizers'; +import { getTextDirection } from 'soapbox/rtl'; import { addAutoPlay } from 'soapbox/utils/media'; import type { Card as CardEntity, Attachment } from 'soapbox/types/entities'; -const trim = (text: string, len: number): string => { - const cut = text.indexOf(' ', len); - - if (cut === -1) { - return text; - } - - return text.substring(0, cut) + (text.length > len ? '…' : ''); -}; - +/** Props for `PreviewCard`. */ interface IPreviewCard { card: CardEntity; maxTitle?: number; @@ -31,6 +22,7 @@ interface IPreviewCard { horizontal?: boolean; } +/** Displays a Mastodon link preview. Similar to OEmbed. */ const PreviewCard: React.FC = ({ card, defaultWidth = 467, @@ -48,6 +40,8 @@ const PreviewCard: React.FC = ({ setEmbedded(false); }, [card.url]); + const direction = getTextDirection(card.title + card.description); + const trimmedTitle = trim(card.title, maxTitle); const trimmedDescription = trim(card.description, maxDescription); @@ -123,26 +117,27 @@ const PreviewCard: React.FC = ({ title={trimmedTitle} rel='noopener' target='_blank' + dir={direction} > - {trimmedTitle} + {trimmedTitle} ) : ( - {trimmedTitle} + {trimmedTitle} ); const description = ( {trimmedTitle && ( - {title} + {title} )} {trimmedDescription && ( - {trimmedDescription} + {trimmedDescription} )} - + {card.provider_name} @@ -253,4 +248,15 @@ const PreviewCard: React.FC = ({ ); }; +/** 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; diff --git a/src/rtl.ts b/src/rtl.ts index d14518acc..3b9f1424f 100644 --- a/src/rtl.ts +++ b/src/rtl.ts @@ -1,38 +1,59 @@ -// 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 - +/** Unicode character ranges for RTL characters. */ 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) { return false; } + // 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, ''); // Remove email address links text = text.replace(/(mailto:)([^\s@]+@[^\s@]+\.[^\s@]+)/g, ''); - // Remove Phone numbe links + // Remove phone number links text = text.replace(/(tel:)([+\d\s()-]+)/g, ''); + // Remove mentions text = text.replace(/(?:^|[^/\w])@([a-z0-9_]+(@[a-z0-9.-]+)?)/ig, ''); + // Remove hashtags text = text.replace(/(?:^|[^/\w])#([\S]+)/ig, ''); + // Remove all non-word characters text = text.replace(/\s+/g, ''); const matches = text.match(rtlChars); if (!matches) { - 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 }; \ No newline at end of file