Merge remote-tracking branch 'origin/develop' into sensitive-video-fix

This commit is contained in:
Alex Gleason
2022-11-23 11:49:27 -06:00
37 changed files with 280 additions and 358 deletions

View File

@ -1,77 +1,77 @@
.status-content p {
[data-markup] p {
@apply mb-4 whitespace-pre-wrap;
}
.status-content p:last-child {
[data-markup] p:last-child {
@apply mb-0;
}
.status-content a {
[data-markup] a {
@apply text-primary-600 dark:text-accent-blue hover:underline;
}
.status-content strong {
[data-markup] strong {
@apply font-bold;
}
.status-content em {
[data-markup] em {
@apply italic;
}
.status-content ul,
.status-content ol {
[data-markup] ul,
[data-markup] ol {
@apply pl-10 mb-4;
}
.status-content ul {
[data-markup] ul {
@apply list-disc list-outside;
}
.status-content ol {
[data-markup] ol {
@apply list-decimal list-outside;
}
.status-content blockquote {
[data-markup] blockquote {
@apply py-1 pl-4 mb-4 border-l-4 border-solid border-gray-400 text-gray-500 dark:text-gray-400;
}
.status-content code {
[data-markup] code {
@apply cursor-text font-mono;
}
.status-content p > code,
.status-content pre {
[data-markup] p > code,
[data-markup] pre {
@apply bg-gray-100 dark:bg-primary-800;
}
/* Inline code */
.status-content p > code {
[data-markup] p > code {
@apply py-0.5 px-1 rounded-sm;
}
/* Code block */
.status-content pre {
[data-markup] pre {
@apply py-2 px-3 mb-4 leading-6 overflow-x-auto rounded-md break-all;
}
.status-content pre:last-child {
[data-markup] pre:last-child {
@apply mb-0;
}
/* Markdown images */
.status-content img:not(.emojione):not([width][height]) {
[data-markup] img:not(.emojione):not([width][height]) {
@apply w-full h-72 object-contain rounded-lg overflow-hidden my-4 block;
}
/* User setting to underline links */
body.underline-links .status-content a {
body.underline-links [data-markup] a {
@apply underline;
}
.status-content .big-emoji img.emojione {
[data-markup] .big-emoji img.emojione {
@apply inline w-9 h-9 p-1;
}
.status-content .status-link {
[data-markup] .status-link {
@apply hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue;
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import Text, { IText } from './ui/text/text';
import './markup.css';
interface IMarkup extends IText {
}
/** Styles HTML markup returned by the API, such as in account bios and statuses. */
const Markup = React.forwardRef<any, IMarkup>((props, ref) => {
return (
<Text ref={ref} {...props} data-markup />
);
});
export default Markup;

View File

@ -160,16 +160,22 @@ const Item: React.FC<IItem> = ({
</div>
);
} else if (attachment.type === 'image') {
const letterboxed = shouldLetterbox(attachment);
const letterboxed = total === 1 && shouldLetterbox(attachment);
thumbnail = (
<a
className={classNames('media-gallery__item-thumbnail', { letterboxed })}
className='media-gallery__item-thumbnail'
href={attachment.url}
onClick={handleClick}
target='_blank'
>
<StillImage src={attachment.url} alt={attachment.description} />
<StillImage
className='w-full h-full'
src={attachment.url}
alt={attachment.description}
letterboxed={letterboxed}
showExt
/>
</a>
);
} else if (attachment.type === 'gifv') {

View File

@ -136,7 +136,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
</HStack>
) : null}
{account.source.get('note', '').length > 0 && (
{account.note.length > 0 && (
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
)}
</Stack>

View File

@ -1,4 +1,3 @@
import classNames from 'clsx';
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -16,6 +15,7 @@ import { initReport } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
import StatusActionButton from 'soapbox/components/status-action-button';
import { HStack } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { isLocal } from 'soapbox/utils/accounts';
@ -127,8 +127,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
} else {
onOpenUnauthorizedModal('REPLY');
}
e.stopPropagation();
};
const handleShareClick = () => {
@ -146,18 +144,13 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
} else {
onOpenUnauthorizedModal('FAVOURITE');
}
e.stopPropagation();
};
const handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(toggleBookmark(status));
};
const handleReblogClick: React.EventHandler<React.MouseEvent> = e => {
e.stopPropagation();
if (me) {
const modalReblog = () => dispatch(toggleReblog(status));
const boostModal = settings.get('boostModal');
@ -172,8 +165,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleQuoteClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
if (me) {
dispatch(quoteCompose(status));
} else {
@ -199,12 +190,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleDeleteClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
doDeleteStatus();
};
const handleRedraftClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
doDeleteStatus(true);
};
@ -213,35 +202,29 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(togglePin(status));
};
const handleMentionClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(mentionCompose(status.account as Account));
};
const handleDirectClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(directCompose(status.account as Account));
};
const handleChatClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
const account = status.account as Account;
dispatch(launchChat(account.id, history));
};
const handleMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(initMuteModal(status.account as Account));
};
const handleBlockClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
const account = status.get('account') as Account;
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/ban.svg'),
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.get('acct') }} />,
@ -257,7 +240,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleOpen: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
history.push(`/@${status.getIn(['account', 'acct'])}/posts/${status.id}`);
};
@ -269,12 +251,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleReport: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(initReport(status.account as Account, status));
};
const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(toggleMuteStatus(status));
};
@ -282,8 +262,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const { uri } = status;
const textarea = document.createElement('textarea');
e.stopPropagation();
textarea.textContent = uri;
textarea.style.position = 'fixed';
@ -300,18 +278,15 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const onModerate: React.MouseEventHandler = (e) => {
e.stopPropagation();
const account = status.account as Account;
dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id }));
};
const handleDeleteStatus: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(deleteStatusModal(intl, status.id));
};
const handleToggleStatusSensitivity: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation();
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
};
@ -550,74 +525,75 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const canShare = ('share' in navigator) && status.visibility === 'public';
return (
<div
data-testid='status-action-bar'
className={classNames('flex flex-row', {
'justify-between': space === 'expand',
'space-x-2': space === 'compact',
})}
>
<StatusActionButton
title={replyTitle}
icon={require('@tabler/icons/message-circle-2.svg')}
onClick={handleReplyClick}
count={replyCount}
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
/>
<HStack data-testid='status-action-bar'>
<HStack
justifyContent={space === 'expand' ? 'between' : undefined}
space={space === 'compact' ? 2 : undefined}
grow={space === 'expand'}
onClick={e => e.stopPropagation()}
>
<StatusActionButton
title={replyTitle}
icon={require('@tabler/icons/message-circle-2.svg')}
onClick={handleReplyClick}
count={replyCount}
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
/>
{(features.quotePosts && me) ? (
<DropdownMenuContainer
items={reblogMenu}
disabled={!publicStatus}
onShiftClick={handleReblogClick}
>
{reblogButton}
</DropdownMenuContainer>
) : (
reblogButton
)}
{(features.quotePosts && me) ? (
<DropdownMenuContainer
items={reblogMenu}
disabled={!publicStatus}
onShiftClick={handleReblogClick}
>
{reblogButton}
</DropdownMenuContainer>
) : (
reblogButton
)}
{features.emojiReacts ? (
<EmojiButtonWrapper statusId={status.id}>
{features.emojiReacts ? (
<EmojiButtonWrapper statusId={status.id}>
<StatusActionButton
title={meEmojiTitle}
icon={require('@tabler/icons/heart.svg')}
filled
color='accent'
active={Boolean(meEmojiReact)}
count={emojiReactCount}
emoji={meEmojiReact}
text={withLabels ? meEmojiTitle : undefined}
/>
</EmojiButtonWrapper>
) : (
<StatusActionButton
title={meEmojiTitle}
title={intl.formatMessage(messages.favourite)}
icon={require('@tabler/icons/heart.svg')}
filled
color='accent'
filled
onClick={handleFavouriteClick}
active={Boolean(meEmojiReact)}
count={emojiReactCount}
emoji={meEmojiReact}
count={favouriteCount}
text={withLabels ? meEmojiTitle : undefined}
/>
</EmojiButtonWrapper>
) : (
<StatusActionButton
title={intl.formatMessage(messages.favourite)}
icon={require('@tabler/icons/heart.svg')}
color='accent'
filled
onClick={handleFavouriteClick}
active={Boolean(meEmojiReact)}
count={favouriteCount}
text={withLabels ? meEmojiTitle : undefined}
/>
)}
)}
{canShare && (
<StatusActionButton
title={intl.formatMessage(messages.share)}
icon={require('@tabler/icons/upload.svg')}
onClick={handleShareClick}
/>
)}
{canShare && (
<StatusActionButton
title={intl.formatMessage(messages.share)}
icon={require('@tabler/icons/upload.svg')}
onClick={handleShareClick}
/>
)}
<DropdownMenuContainer items={menu} status={status}>
<StatusActionButton
title={intl.formatMessage(messages.more)}
icon={require('@tabler/icons/dots.svg')}
/>
</DropdownMenuContainer>
</div>
<DropdownMenuContainer items={menu} status={status}>
<StatusActionButton
title={intl.formatMessage(messages.more)}
icon={require('@tabler/icons/dots.svg')}
/>
</DropdownMenuContainer>
</HStack>
</HStack>
);
};

View File

@ -10,19 +10,14 @@ import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content';
import { isRtl } from '../rtl';
import Markup from './markup';
import Poll from './polls/poll';
import './status-content.css';
import type { Status, Mention } from 'soapbox/types/entities';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
const BIG_EMOJI_LIMIT = 10;
type Point = [
x: number,
y: number,
]
interface IReadMoreButton {
onClick: React.MouseEventHandler,
}
@ -49,7 +44,6 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
const [collapsed, setCollapsed] = useState(false);
const [onlyEmoji, setOnlyEmoji] = useState(false);
const startXY = useRef<Point>();
const node = useRef<HTMLDivElement>(null);
const { greentext } = useSoapboxConfig();
@ -131,29 +125,6 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
updateStatusLinks();
});
const handleMouseDown: React.EventHandler<React.MouseEvent> = (e) => {
startXY.current = [e.clientX, e.clientY];
};
const handleMouseUp: React.EventHandler<React.MouseEvent> = (e) => {
if (!startXY.current) return;
const target = e.target as HTMLElement;
const parentNode = target.parentNode as HTMLElement;
const [startX, startY] = startXY.current;
const [deltaX, deltaY] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (target.localName === 'button' || target.localName === 'a' || (parentNode && (parentNode.localName === 'button' || parentNode.localName === 'a'))) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0 && !(e.ctrlKey || e.metaKey) && onClick) {
onClick();
}
startXY.current = undefined;
};
const parsedHtml = useMemo((): string => {
const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml;
@ -173,30 +144,24 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none';
const content = { __html: parsedHtml };
const directionStyle: React.CSSProperties = { direction: 'ltr' };
const className = classNames(baseClassName, 'status-content', {
const direction = isRtl(status.search_index) ? 'rtl' : 'ltr';
const className = classNames(baseClassName, {
'cursor-pointer': onClick,
'whitespace-normal': withSpoiler,
'max-h-[300px]': collapsed,
'leading-normal big-emoji': onlyEmoji,
});
if (isRtl(status.search_index)) {
directionStyle.direction = 'rtl';
}
if (onClick) {
const output = [
<div
<Markup
ref={node}
tabIndex={0}
key='content'
className={className}
style={directionStyle}
direction={direction}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
/>,
];
@ -212,14 +177,14 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
return <div className={classNames({ 'bg-gray-100 dark:bg-primary-800 rounded-md p-4': hasPoll })}>{output}</div>;
} else {
const output = [
<div
<Markup
ref={node}
tabIndex={0}
key='content'
className={classNames(baseClassName, 'status-content', {
className={classNames(baseClassName, {
'leading-normal big-emoji': onlyEmoji,
})}
style={directionStyle}
direction={direction}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
/>,

View File

@ -167,7 +167,16 @@ const StatusMedia: React.FC<IStatusMedia> = ({
);
}
return media;
if (media) {
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onClick={e => e.stopPropagation()}>
{media}
</div>
);
} else {
return null;
}
};
export default StatusMedia;

View File

@ -110,6 +110,11 @@ const Status: React.FC<IStatus> = (props) => {
const handleClick = (e?: React.MouseEvent): void => {
e?.stopPropagation();
// If the user is selecting text, don't focus the status.
if (getSelection()?.toString().length) {
return;
}
if (!e || !(e.ctrlKey || e.metaKey)) {
if (onClick) {
onClick();

View File

@ -12,10 +12,14 @@ interface IStillImage {
src: string,
/** Extra CSS styles on the outer <div> element. */
style?: React.CSSProperties,
/** Whether to display the image contained vs filled in its container. */
letterboxed?: boolean,
/** Whether to show the file extension in the corner. */
showExt?: boolean,
}
/** Renders images on a canvas, only playing GIFs if autoPlayGif is enabled. */
const StillImage: React.FC<IStillImage> = ({ alt, className, src, style }) => {
const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterboxed = false, showExt = false }) => {
const settings = useSettings();
const autoPlayGif = settings.get('autoPlayGif');
@ -34,10 +38,56 @@ const StillImage: React.FC<IStillImage> = ({ alt, className, src, style }) => {
}
};
/** ClassNames shared between the `<img>` and `<canvas>` elements. */
const baseClassName = classNames('w-full h-full block', {
'object-contain': letterboxed,
'object-cover': !letterboxed,
});
return (
<div data-testid='still-image-container' className={classNames(className, 'still-image', { 'still-image--play-on-hover': hoverToPlay })} style={style}>
<img src={src} alt={alt} ref={img} onLoad={handleImageLoad} />
{hoverToPlay && <canvas ref={canvas} />}
<div
data-testid='still-image-container'
className={classNames(className, 'relative group overflow-hidden isolate')}
style={style}
>
<img
src={src}
alt={alt}
ref={img}
onLoad={handleImageLoad}
className={classNames(baseClassName, {
'invisible group-hover:visible': hoverToPlay,
})}
/>
{hoverToPlay && (
<canvas
ref={canvas}
className={classNames(baseClassName, {
'group-hover:invisible': hoverToPlay,
})}
/>
)}
{(hoverToPlay && showExt) && (
<div className='group-hover:hidden absolute opacity-90 left-2 bottom-2 pointer-events-none'>
<ExtensionBadge ext='GIF' />
</div>
)}
</div>
);
};
interface IExtensionBadge {
/** File extension. */
ext: string,
}
/** Badge displaying a file extension. */
const ExtensionBadge: React.FC<IExtensionBadge> = ({ ext }) => {
return (
<div className='inline-flex items-center px-2 py-0.5 rounded text-sm font-medium bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'>
{ext}
</div>
);
};

View File

@ -33,7 +33,7 @@ const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant = 'def
ref={ref}
{...filteredProps}
className={classNames({
'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden': variant === 'rounded',
'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden isolate': variant === 'rounded',
[sizes[size]]: variant === 'rounded',
}, className)}
>

View File

@ -27,7 +27,7 @@ const spaces = {
8: 'space-x-8',
};
interface IHStack {
interface IHStack extends Pick<React.HTMLAttributes<HTMLDivElement>, 'onClick'> {
/** Vertical alignment of children. */
alignItems?: keyof typeof alignItemsOptions
/** Extra class names on the <div> element. */

View File

@ -54,7 +54,9 @@ export type Sizes = keyof typeof sizes
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label'
type Directions = 'ltr' | 'rtl'
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML' | 'tabIndex' | 'lang'> {
/** Text content. */
children?: React.ReactNode,
/** How to align the text. */
align?: keyof typeof alignments,
/** Extra class names for the outer element. */
@ -84,8 +86,8 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
}
/** UI-friendly text container with dark mode support. */
const Text: React.FC<IText> = React.forwardRef(
(props: IText, ref: React.LegacyRef<any>) => {
const Text = React.forwardRef<any, IText>(
(props, ref) => {
const {
align,
className,