Merge branch 'preview-card' into 'main'

RTL fixes for PreviewCard, Chats, Error page

See merge request soapbox-pub/soapbox!2792
environments/review-main-yi2y9f/deployments/4131
Alex Gleason 12 months ago
commit c3ac8c365b

@ -7,7 +7,7 @@ import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { Input, Portal } from 'soapbox/components/ui'; import { Input, Portal } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account'; 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 { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu'; import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';

@ -5,7 +5,7 @@ import Textarea from 'react-textarea-autosize';
import { Portal } from 'soapbox/components/ui'; import { Portal } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account'; 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 { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestEmoji from './autosuggest-emoji'; import AutosuggestEmoji from './autosuggest-emoji';

@ -14,18 +14,6 @@ import SiteLogo from './site-logo';
import type { RootState } from 'soapbox/store'; 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<typeof mapStateToProps> { interface Props extends ReturnType<typeof mapStateToProps> {
children: React.ReactNode; children: React.ReactNode;
} }
@ -152,7 +140,8 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
<div className='mt-10'> <div className='mt-10'>
<a href='/' className='text-base font-medium text-primary-600 hover:underline dark:text-accent-blue'> <a href='/' className='text-base font-medium text-primary-600 hover:underline dark:text-accent-blue'>
<FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' /> <FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' />
<span aria-hidden='true'> &rarr;</span> {' '}
<span className='inline-block rtl:rotate-180' aria-hidden='true'>&rarr;</span>
</a> </a>
</div> </div>
</div> </div>
@ -165,6 +154,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
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' 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} value={errorText}
onClick={this.handleCopy} onClick={this.handleCopy}
dir='ltr'
readOnly readOnly
/> />
)} )}
@ -215,4 +205,18 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
} }
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); export default connect(mapStateToProps)(ErrorBoundary);

@ -3,24 +3,15 @@ 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 { addAutoPlay } from 'soapbox/utils/media'; import { addAutoPlay } from 'soapbox/utils/media';
import { getTextDirection } from 'soapbox/utils/rtl';
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); interface IPreviewCard {
if (cut === -1) {
return text;
}
return text.substring(0, cut) + (text.length > len ? '…' : '');
};
interface ICard {
card: CardEntity; card: CardEntity;
maxTitle?: number; maxTitle?: number;
maxDescription?: number; maxDescription?: number;
@ -31,7 +22,8 @@ interface ICard {
horizontal?: boolean; horizontal?: boolean;
} }
const Card: React.FC<ICard> = ({ /** Displays a Mastodon link preview. Similar to OEmbed. */
const PreviewCard: React.FC<IPreviewCard> = ({
card, card,
defaultWidth = 467, defaultWidth = 467,
maxTitle = 120, maxTitle = 120,
@ -48,6 +40,8 @@ const Card: React.FC<ICard> = ({
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 Card: React.FC<ICard> = ({
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 Card: React.FC<ICard> = ({
); );
}; };
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;

@ -6,7 +6,7 @@ import { useHistory } from 'react-router-dom';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content'; import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content';
import { isRtl } from '../rtl'; import { getTextDirection } from '../utils/rtl';
import Markup from './markup'; import Markup from './markup';
import Poll from './polls/poll'; import Poll from './polls/poll';
@ -142,7 +142,7 @@ const StatusContent: React.FC<IStatusContent> = ({
const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none'; const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none';
const content = { __html: parsedHtml }; const content = { __html: parsedHtml };
const direction = isRtl(status.search_index) ? 'rtl' : 'ltr'; const direction = getTextDirection(status.search_index);
const className = clsx(baseClassName, { const className = clsx(baseClassName, {
'cursor-pointer': onClick, 'cursor-pointer': onClick,
'whitespace-normal': withSpoiler, 'whitespace-normal': withSpoiler,

@ -2,9 +2,9 @@ import React, { Suspense } from 'react';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs'; 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 { GroupLinkPreview } from 'soapbox/features/groups/components/group-link-preview';
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card'; 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 { MediaGallery, Video, Audio } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
@ -118,7 +118,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
); );
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) { } else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
media = ( media = (
<Card <PreviewCard
onOpenMedia={openMedia} onOpenMedia={openMedia}
card={status.card} card={status.card}
compact compact

@ -121,7 +121,7 @@ const ChatPageMain = () => {
<HStack alignItems='center'> <HStack alignItems='center'>
<IconButton <IconButton
src={require('@tabler/icons/arrow-left.svg')} src={require('@tabler/icons/arrow-left.svg')}
className='mr-2 h-7 w-7 sm:mr-0 sm:hidden' className='mr-2 h-7 w-7 rtl:rotate-180 sm:mr-0 sm:hidden'
onClick={() => history.push('/chats')} onClick={() => history.push('/chats')}
/> />

@ -24,7 +24,7 @@ const ChatPageNew: React.FC<IChatPageNew> = () => {
<HStack alignItems='center'> <HStack alignItems='center'>
<IconButton <IconButton
src={require('@tabler/icons/arrow-left.svg')} src={require('@tabler/icons/arrow-left.svg')}
className='mr-2 h-7 w-7 sm:mr-0 sm:hidden' className='mr-2 h-7 w-7 rtl:rotate-180 sm:mr-0 sm:hidden'
onClick={() => history.push('/chats')} onClick={() => history.push('/chats')}
/> />

@ -51,7 +51,7 @@ const ChatPageSettings = () => {
<HStack alignItems='center'> <HStack alignItems='center'>
<IconButton <IconButton
src={require('@tabler/icons/arrow-left.svg')} src={require('@tabler/icons/arrow-left.svg')}
className='mr-2 h-7 w-7 sm:mr-0 sm:hidden' className='mr-2 h-7 w-7 rtl:rotate-180 sm:mr-0 sm:hidden'
onClick={() => history.push('/chats')} onClick={() => history.push('/chats')}
/> />

@ -96,7 +96,7 @@ const ChatSettings = () => {
<button onClick={closeSettings}> <button onClick={closeSettings}>
<Icon <Icon
src={require('@tabler/icons/arrow-left.svg')} src={require('@tabler/icons/arrow-left.svg')}
className='h-6 w-6 text-gray-600 dark:text-gray-400' className='h-6 w-6 text-gray-600 rtl:rotate-180 dark:text-gray-400'
/> />
</button> </button>

@ -73,7 +73,7 @@ const ChatWindow = () => {
<button onClick={closeChat}> <button onClick={closeChat}>
<Icon <Icon
src={require('@tabler/icons/arrow-left.svg')} src={require('@tabler/icons/arrow-left.svg')}
className='h-6 w-6 text-gray-600 dark:text-gray-400' className='h-6 w-6 text-gray-600 rtl:rotate-180 dark:text-gray-400'
/> />
</button> </button>
)} )}

@ -27,7 +27,7 @@ const ChatSearchHeader = () => {
> >
<Icon <Icon
src={require('@tabler/icons/arrow-left.svg')} src={require('@tabler/icons/arrow-left.svg')}
className='h-6 w-6 text-gray-600 dark:text-gray-400' className='h-6 w-6 text-gray-600 rtl:rotate-180 dark:text-gray-400'
/> />
</button> </button>

@ -5,7 +5,7 @@ import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import Markup from 'soapbox/components/markup'; import Markup from 'soapbox/components/markup';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container'; 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'; import type { Status } from 'soapbox/types/entities';
@ -50,7 +50,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
className='break-words' className='break-words'
size='sm' size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }} dangerouslySetInnerHTML={{ __html: status.contentHtml }}
direction={isRtl(status.search_index) ? 'rtl' : 'ltr'} direction={getTextDirection(status.search_index)}
/> />
{status.media_attachments.size > 0 && ( {status.media_attachments.size > 0 && (

@ -37,7 +37,7 @@ const Discover: React.FC = () => {
{isSearching ? ( {isSearching ? (
<IconButton <IconButton
src={require('@tabler/icons/arrow-left.svg')} src={require('@tabler/icons/arrow-left.svg')}
iconClassName='mr-2 h-5 w-5 fill-current text-gray-600' iconClassName='mr-2 h-5 w-5 fill-current text-gray-600 rtl:rotate-180'
onClick={cancelSearch} onClick={cancelSearch}
data-testid='group-search-icon' data-testid='group-search-icon'
/> />

@ -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;
}

@ -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 };
Loading…
Cancel
Save