diff --git a/src/components/autosuggest-input.tsx b/src/components/autosuggest-input.tsx index 1846b02d0..b41934e7c 100644 --- a/src/components/autosuggest-input.tsx +++ b/src/components/autosuggest-input.tsx @@ -7,7 +7,7 @@ import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji'; import Icon from 'soapbox/components/icon'; import { Input, Portal } from 'soapbox/components/ui'; import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account'; -import { isRtl } from 'soapbox/rtl'; +import { isRtl } from 'soapbox/utils/rtl'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu'; diff --git a/src/components/autosuggest-textarea.tsx b/src/components/autosuggest-textarea.tsx index bc19c15e5..233c1e4e3 100644 --- a/src/components/autosuggest-textarea.tsx +++ b/src/components/autosuggest-textarea.tsx @@ -5,7 +5,7 @@ import Textarea from 'react-textarea-autosize'; import { Portal } from 'soapbox/components/ui'; import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account'; -import { isRtl } from 'soapbox/rtl'; +import { isRtl } from 'soapbox/utils/rtl'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import AutosuggestEmoji from './autosuggest-emoji'; diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx index 8c45cea1e..b25ca2aae 100644 --- a/src/components/error-boundary.tsx +++ b/src/components/error-boundary.tsx @@ -14,18 +14,6 @@ import SiteLogo from './site-logo'; import type { RootState } from 'soapbox/store'; -const goHome = () => location.href = '/'; - -const mapStateToProps = (state: RootState) => { - const { links, logo } = getSoapboxConfig(state); - - return { - siteTitle: state.instance.title, - logo, - links, - }; -}; - interface Props extends ReturnType { children: React.ReactNode; } @@ -152,7 +140,8 @@ class ErrorBoundary extends React.PureComponent {
- + {' '} +
@@ -165,6 +154,7 @@ class ErrorBoundary extends React.PureComponent { className='block h-48 w-full rounded-md border-gray-300 bg-gray-100 p-4 font-mono text-gray-900 shadow-sm focus:border-primary-500 focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 sm:text-sm' value={errorText} onClick={this.handleCopy} + dir='ltr' readOnly /> )} @@ -215,4 +205,18 @@ class ErrorBoundary extends React.PureComponent { } +function goHome() { + location.href = '/'; +} + +function mapStateToProps(state: RootState) { + const { links, logo } = getSoapboxConfig(state); + + return { + siteTitle: state.instance.title, + logo, + links, + }; +} + export default connect(mapStateToProps)(ErrorBoundary); diff --git a/src/features/status/components/card.tsx b/src/components/preview-card.tsx similarity index 88% rename from src/features/status/components/card.tsx rename to src/components/preview-card.tsx index fd205b723..16a1a467c 100644 --- a/src/features/status/components/card.tsx +++ b/src/components/preview-card.tsx @@ -3,24 +3,15 @@ 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 { addAutoPlay } from 'soapbox/utils/media'; +import { getTextDirection } from 'soapbox/utils/rtl'; 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 ? '…' : ''); -}; - -interface ICard { +/** Props for `PreviewCard`. */ +interface IPreviewCard { card: CardEntity; maxTitle?: number; maxDescription?: number; @@ -31,7 +22,8 @@ interface ICard { horizontal?: boolean; } -const Card: React.FC = ({ +/** Displays a Mastodon link preview. Similar to OEmbed. */ +const PreviewCard: React.FC = ({ card, defaultWidth = 467, maxTitle = 120, @@ -48,6 +40,8 @@ const Card: 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 Card: 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 Card: React.FC = ({ ); }; -export default Card; +/** 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/components/status-content.tsx b/src/components/status-content.tsx index 7c322dcb8..ffc044df2 100644 --- a/src/components/status-content.tsx +++ b/src/components/status-content.tsx @@ -6,7 +6,7 @@ import { useHistory } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content'; -import { isRtl } from '../rtl'; +import { getTextDirection } from '../utils/rtl'; import Markup from './markup'; import Poll from './polls/poll'; @@ -142,7 +142,7 @@ const StatusContent: React.FC = ({ const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none'; const content = { __html: parsedHtml }; - const direction = isRtl(status.search_index) ? 'rtl' : 'ltr'; + const direction = getTextDirection(status.search_index); const className = clsx(baseClassName, { 'cursor-pointer': onClick, 'whitespace-normal': withSpoiler, diff --git a/src/components/status-media.tsx b/src/components/status-media.tsx index 7147e3a54..2c96c0078 100644 --- a/src/components/status-media.tsx +++ b/src/components/status-media.tsx @@ -2,9 +2,9 @@ import React, { Suspense } from 'react'; import { openModal } from 'soapbox/actions/modals'; import AttachmentThumbs from 'soapbox/components/attachment-thumbs'; +import PreviewCard from 'soapbox/components/preview-card'; import { GroupLinkPreview } from 'soapbox/features/groups/components/group-link-preview'; import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card'; -import Card from 'soapbox/features/status/components/card'; import { MediaGallery, Video, Audio } from 'soapbox/features/ui/util/async-components'; import { useAppDispatch } from 'soapbox/hooks'; @@ -118,7 +118,7 @@ const StatusMedia: React.FC = ({ ); } else if (status.spoiler_text.length === 0 && !status.quote && status.card) { media = ( - { history.push('/chats')} /> diff --git a/src/features/chats/components/chat-page/components/chat-page-new.tsx b/src/features/chats/components/chat-page/components/chat-page-new.tsx index 0b1e0de8d..0a60c56b4 100644 --- a/src/features/chats/components/chat-page/components/chat-page-new.tsx +++ b/src/features/chats/components/chat-page/components/chat-page-new.tsx @@ -24,7 +24,7 @@ const ChatPageNew: React.FC = () => { history.push('/chats')} /> diff --git a/src/features/chats/components/chat-page/components/chat-page-settings.tsx b/src/features/chats/components/chat-page/components/chat-page-settings.tsx index c33f74013..404c56afd 100644 --- a/src/features/chats/components/chat-page/components/chat-page-settings.tsx +++ b/src/features/chats/components/chat-page/components/chat-page-settings.tsx @@ -51,7 +51,7 @@ const ChatPageSettings = () => { history.push('/chats')} /> diff --git a/src/features/chats/components/chat-widget/chat-settings.tsx b/src/features/chats/components/chat-widget/chat-settings.tsx index 5a38778e0..0e62f3cfd 100644 --- a/src/features/chats/components/chat-widget/chat-settings.tsx +++ b/src/features/chats/components/chat-widget/chat-settings.tsx @@ -96,7 +96,7 @@ const ChatSettings = () => { diff --git a/src/features/chats/components/chat-widget/chat-window.tsx b/src/features/chats/components/chat-widget/chat-window.tsx index 84845d7f7..66c02397f 100644 --- a/src/features/chats/components/chat-widget/chat-window.tsx +++ b/src/features/chats/components/chat-widget/chat-window.tsx @@ -73,7 +73,7 @@ const ChatWindow = () => { )} diff --git a/src/features/chats/components/chat-widget/headers/chat-search-header.tsx b/src/features/chats/components/chat-widget/headers/chat-search-header.tsx index d0fd40921..15b0c6a4b 100644 --- a/src/features/chats/components/chat-widget/headers/chat-search-header.tsx +++ b/src/features/chats/components/chat-widget/headers/chat-search-header.tsx @@ -27,7 +27,7 @@ const ChatSearchHeader = () => { > diff --git a/src/features/compose/components/reply-indicator.tsx b/src/features/compose/components/reply-indicator.tsx index d5478d204..bee36b661 100644 --- a/src/features/compose/components/reply-indicator.tsx +++ b/src/features/compose/components/reply-indicator.tsx @@ -5,7 +5,7 @@ import AttachmentThumbs from 'soapbox/components/attachment-thumbs'; import Markup from 'soapbox/components/markup'; import { Stack } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account-container'; -import { isRtl } from 'soapbox/rtl'; +import { getTextDirection } from 'soapbox/utils/rtl'; import type { Status } from 'soapbox/types/entities'; @@ -50,7 +50,7 @@ const ReplyIndicator: React.FC = ({ className, status, hideActi className='break-words' size='sm' dangerouslySetInnerHTML={{ __html: status.contentHtml }} - direction={isRtl(status.search_index) ? 'rtl' : 'ltr'} + direction={getTextDirection(status.search_index)} /> {status.media_attachments.size > 0 && ( diff --git a/src/features/groups/discover.tsx b/src/features/groups/discover.tsx index 47273d2ed..f91c3ca48 100644 --- a/src/features/groups/discover.tsx +++ b/src/features/groups/discover.tsx @@ -37,7 +37,7 @@ const Discover: React.FC = () => { {isSearching ? ( diff --git a/src/rtl.ts b/src/rtl.ts deleted file mode 100644 index d14518acc..000000000 --- a/src/rtl.ts +++ /dev/null @@ -1,38 +0,0 @@ -// 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 - -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 { - 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 - text = text.replace(/(tel:)([+\d\s()-]+)/g, ''); - text = text.replace(/(?:^|[^/\w])@([a-z0-9_]+(@[a-z0-9.-]+)?)/ig, ''); - text = text.replace(/(?:^|[^/\w])#([\S]+)/ig, ''); - text = text.replace(/\s+/g, ''); - - const matches = text.match(rtlChars); - - if (!matches) { - - return false; - } - - return matches.length / text.length > 0.3; -} diff --git a/src/utils/rtl.ts b/src/utils/rtl.ts new file mode 100644 index 000000000..3b9f1424f --- /dev/null +++ b/src/utils/rtl.ts @@ -0,0 +1,59 @@ +/** 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). + * + * - 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 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 > 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