Improve the MediaModal with ability to like, comment, reply, etc

This commit is contained in:
Chewbacca 2023-05-30 09:04:50 -04:00
parent 3c00820382
commit 866c80d30b
11 changed files with 709 additions and 715 deletions

View file

@ -1,3 +1,5 @@
import { AppDispatch } from 'soapbox/store';
import type { ModalType } from 'soapbox/features/ui/components/modal-root';
export const MODAL_OPEN = 'MODAL_OPEN';
@ -5,13 +7,18 @@ export const MODAL_CLOSE = 'MODAL_CLOSE';
/** Open a modal of the given type */
export function openModal(type: ModalType, props?: any) {
return {
type: MODAL_OPEN,
modalType: type,
modalProps: props,
return (dispatch: AppDispatch) => {
dispatch(closeModal(type));
dispatch(openModalSuccess(type, props));
};
}
const openModalSuccess = (type: ModalType, props?: any) => ({
type: MODAL_OPEN,
modalType: type,
modalProps: props,
});
/** Close the modal */
export function closeModal(type?: ModalType) {
return {

View file

@ -252,6 +252,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
className={clsx({
'my-2 mx-auto relative pointer-events-none flex items-center min-h-[calc(100%-3.5rem)]': true,
'p-4 md:p-0': type !== 'MEDIA',
'!my-0': type === 'MEDIA',
})}
>
{children}

View file

@ -96,14 +96,16 @@ interface IStatusActionBar {
status: Status
withLabels?: boolean
expandable?: boolean
space?: 'expand' | 'compact'
space?: 'sm' | 'md' | 'lg'
statusActionButtonTheme?: 'default' | 'inverse'
}
const StatusActionBar: React.FC<IStatusActionBar> = ({
status,
withLabels = false,
expandable = true,
space = 'compact',
space = 'sm',
statusActionButtonTheme = 'default',
}) => {
const intl = useIntl();
const history = useHistory();
@ -572,6 +574,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
onClick={handleReblogClick}
count={reblogCount}
text={withLabels ? intl.formatMessage(messages.reblog) : undefined}
theme={statusActionButtonTheme}
/>
);
@ -583,13 +586,22 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group');
const spacing: {
[key: string]: React.ComponentProps<typeof HStack>['space']
} = {
'sm': 2,
'md': 8,
'lg': 0, // using justifyContent instead on the HStack
};
return (
<HStack data-testid='status-action-bar'>
<HStack
justifyContent={space === 'expand' ? 'between' : undefined}
space={space === 'compact' ? 2 : undefined}
grow={space === 'expand'}
justifyContent={space === 'lg' ? 'between' : undefined}
space={spacing[space]}
grow={space === 'lg'}
onClick={e => e.stopPropagation()}
alignItems='center'
>
<GroupPopover
group={status.group as any}
@ -602,6 +614,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
count={replyCount}
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
disabled={replyDisabled}
theme={statusActionButtonTheme}
/>
</GroupPopover>
@ -628,6 +641,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
count={emojiReactCount}
emoji={meEmojiReact}
text={withLabels ? meEmojiTitle : undefined}
theme={statusActionButtonTheme}
/>
</StatusReactionWrapper>
) : (
@ -640,6 +654,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
active={Boolean(meEmojiName)}
count={favouriteCount}
text={withLabels ? meEmojiTitle : undefined}
theme={statusActionButtonTheme}
/>
)}
@ -653,6 +668,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
active={status.disliked}
count={status.dislikes_count}
text={withLabels ? intl.formatMessage(messages.disfavourite) : undefined}
theme={statusActionButtonTheme}
/>
)}
@ -661,6 +677,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
title={intl.formatMessage(messages.share)}
icon={require('@tabler/icons/upload.svg')}
onClick={handleShareClick}
theme={statusActionButtonTheme}
/>
)}
@ -668,6 +685,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
<StatusActionButton
title={intl.formatMessage(messages.more)}
icon={require('@tabler/icons/dots.svg')}
theme={statusActionButtonTheme}
/>
</DropdownMenu>
</HStack>

View file

@ -35,10 +35,11 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes<HTMLButtonEleme
filled?: boolean
emoji?: ImmutableMap<string, any>
text?: React.ReactNode
theme?: 'default' | 'inverse'
}
const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButton>((props, ref): JSX.Element => {
const { icon, className, iconClassName, active, color, filled = false, count = 0, emoji, text, ...filteredProps } = props;
const { icon, className, iconClassName, active, color, filled = false, count = 0, emoji, text, theme = 'default', ...filteredProps } = props;
const renderIcon = () => {
if (emoji) {
@ -82,10 +83,10 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
type='button'
className={clsx(
'flex items-center rounded-full p-1 rtl:space-x-reverse',
'text-gray-600 hover:text-gray-600 dark:hover:text-white',
'bg-white dark:bg-transparent',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:ring-offset-0',
{
'text-gray-600 hover:text-gray-600 dark:hover:text-white bg-white dark:bg-transparent': theme === 'default',
'text-white/80 hover:text-white bg-transparent dark:bg-transparent': theme === 'inverse',
'text-black dark:text-white': active && emoji,
'hover:text-gray-600 dark:hover:text-white': !filteredProps.disabled,
'text-accent-300 hover:text-accent-300 dark:hover:text-accent-300': active && !emoji && color === COLORS.accent,

View file

@ -12,7 +12,7 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Text to display next ot the button. */
text?: string
/** Predefined styles to display for the button. */
theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent'
theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent' | 'dark'
/** Override the data-testid */
'data-testid'?: string
}
@ -29,6 +29,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef
'bg-white dark:bg-transparent': theme === 'seamless',
'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500': theme === 'outlined',
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200': theme === 'secondary',
'bg-gray-900 text-white': theme === 'dark',
'opacity-50': filteredProps.disabled,
}, className)}
{...filteredProps}

View file

@ -15,7 +15,7 @@ import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetStatus } from 'soapbox/selectors';
import ComposeForm from '../compose/components/compose-form';
import { getDescendantsIds } from '../status';
import { getDescendantsIds } from '../status/components/thread';
import ThreadStatus from '../status/components/thread-status';
import type { VirtuosoHandle } from 'react-virtuoso';

View file

@ -19,7 +19,8 @@ import type { Group, Status as StatusEntity } from 'soapbox/types/entities';
interface IDetailedStatus {
status: StatusEntity
showMedia: boolean
showMedia?: boolean
withMedia?: boolean
onOpenCompareHistoryModal: (status: StatusEntity) => void
onToggleMediaVisibility: () => void
}
@ -29,6 +30,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
onOpenCompareHistoryModal,
onToggleMediaVisibility,
showMedia,
withMedia = true,
}) => {
const intl = useIntl();
@ -151,7 +153,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
<TranslateButton status={actualStatus} />
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
{(withMedia && (quote || actualStatus.card || actualStatus.media_attachments.size > 0)) && (
<Stack space={4}>
<StatusMedia
status={actualStatus}

View file

@ -0,0 +1,468 @@
import { createSelector } from '@reduxjs/toolkit';
import clsx from 'clsx';
import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
import React, { useEffect, useRef, useState } from 'react';
import { HotKeys } from 'react-hotkeys';
import { useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { type VirtuosoHandle } from 'react-virtuoso';
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
import { favourite, reblog, unfavourite, unreblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { getSettings } from 'soapbox/actions/settings';
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
import ScrollableList from 'soapbox/components/scrollable-list';
import StatusActionBar from 'soapbox/components/status-action-bar';
import Tombstone from 'soapbox/components/tombstone';
import { Stack } from 'soapbox/components/ui';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
import PendingStatus from 'soapbox/features/ui/components/pending-status';
import { useAppDispatch, useAppSelector, useOwnAccount, useSettings } from 'soapbox/hooks';
import { RootState } from 'soapbox/store';
import { type Account, type Status } from 'soapbox/types/entities';
import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status';
import DetailedStatus from './detailed-status';
import ThreadLoginCta from './thread-login-cta';
import ThreadStatus from './thread-status';
type DisplayMedia = 'default' | 'hide_all' | 'show_all';
const getAncestorsIds = createSelector([
(_: RootState, statusId: string | undefined) => statusId,
(state: RootState) => state.contexts.inReplyTos,
], (statusId, inReplyTos) => {
let ancestorsIds = ImmutableOrderedSet<string>();
let id: string | undefined = statusId;
while (id && !ancestorsIds.includes(id)) {
ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds);
id = inReplyTos.get(id);
}
return ancestorsIds;
});
export const getDescendantsIds = createSelector([
(_: RootState, statusId: string) => statusId,
(state: RootState) => state.contexts.replies,
], (statusId, contextReplies) => {
let descendantsIds = ImmutableOrderedSet<string>();
const ids = [statusId];
while (ids.length > 0) {
const id = ids.shift();
if (!id) break;
const replies = contextReplies.get(id);
if (descendantsIds.includes(id)) {
break;
}
if (statusId !== id) {
descendantsIds = descendantsIds.union([id]);
}
if (replies) {
replies.reverse().forEach((reply: string) => {
ids.unshift(reply);
});
}
}
return descendantsIds;
});
interface IThread {
status: Status
withMedia?: boolean
useWindowScroll?: boolean
itemClassName?: string
next: string | undefined
handleLoadMore: () => void
}
const Thread = (props: IThread) => {
const {
handleLoadMore,
itemClassName,
next,
status,
useWindowScroll = true,
withMedia = true,
} = props;
const dispatch = useAppDispatch();
const history = useHistory();
const intl = useIntl();
const me = useOwnAccount();
const settings = useSettings();
const displayMedia = settings.get('displayMedia') as DisplayMedia;
const isUnderReview = status?.visibility === 'self';
const { ancestorsIds, descendantsIds } = useAppSelector((state) => {
let ancestorsIds = ImmutableOrderedSet<string>();
let descendantsIds = ImmutableOrderedSet<string>();
if (status) {
const statusId = status.id;
ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos.get(statusId));
descendantsIds = getDescendantsIds(state, statusId);
ancestorsIds = ancestorsIds.delete(statusId).subtract(descendantsIds);
descendantsIds = descendantsIds.delete(statusId).subtract(ancestorsIds);
}
return {
status,
ancestorsIds,
descendantsIds,
};
});
const [showMedia, setShowMedia] = useState<boolean>(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
const node = useRef<HTMLDivElement>(null);
const statusRef = useRef<HTMLDivElement>(null);
const scroller = useRef<VirtuosoHandle>(null);
const handleToggleMediaVisibility = () => {
setShowMedia(!showMedia);
};
const handleHotkeyReact = () => {
if (statusRef.current) {
const firstEmoji: HTMLButtonElement | null = statusRef.current.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
firstEmoji?.focus();
}
};
const handleFavouriteClick = (status: Status) => {
if (status.favourited) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
};
const handleReplyClick = (status: Status) => dispatch(replyCompose(status));
const handleModalReblog = (status: Status) => dispatch(reblog(status));
const handleReblogClick = (status: Status, e?: React.MouseEvent) => {
dispatch((_, getState) => {
const boostModal = getSettings(getState()).get('boostModal');
if (status.reblogged) {
dispatch(unreblog(status));
} else {
if ((e && e.shiftKey) || !boostModal) {
handleModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: handleModalReblog }));
}
}
});
};
const handleMentionClick = (account: Account) => dispatch(mentionCompose(account));
const handleHotkeyOpenMedia = (e?: KeyboardEvent) => {
const media = status?.media_attachments;
e?.preventDefault();
if (media && media.size) {
const firstAttachment = media.first()!;
if (media.size === 1 && firstAttachment.type === 'video') {
dispatch(openModal('VIDEO', { media: firstAttachment, status: status }));
} else {
dispatch(openModal('MEDIA', { media, index: 0, status: status }));
}
}
};
const handleToggleHidden = (status: Status) => {
if (status.hidden) {
dispatch(revealStatus(status.id));
} else {
dispatch(hideStatus(status.id));
}
};
const handleHotkeyMoveUp = () => {
handleMoveUp(status!.id);
};
const handleHotkeyMoveDown = () => {
handleMoveDown(status!.id);
};
const handleHotkeyReply = (e?: KeyboardEvent) => {
e?.preventDefault();
handleReplyClick(status!);
};
const handleHotkeyFavourite = () => {
handleFavouriteClick(status!);
};
const handleHotkeyBoost = () => {
handleReblogClick(status!);
};
const handleHotkeyMention = (e?: KeyboardEvent) => {
e?.preventDefault();
const { account } = status!;
if (!account || typeof account !== 'object') return;
handleMentionClick(account);
};
const handleHotkeyOpenProfile = () => {
history.push(`/@${status!.getIn(['account', 'acct'])}`);
};
const handleHotkeyToggleHidden = () => {
handleToggleHidden(status!);
};
const handleHotkeyToggleSensitive = () => {
handleToggleMediaVisibility();
};
const handleMoveUp = (id: string) => {
if (id === status?.id) {
_selectChild(ancestorsIds.size - 1);
} else {
let index = ImmutableList(ancestorsIds).indexOf(id);
if (index === -1) {
index = ImmutableList(descendantsIds).indexOf(id);
_selectChild(ancestorsIds.size + index);
} else {
_selectChild(index - 1);
}
}
};
const handleMoveDown = (id: string) => {
if (id === status?.id) {
_selectChild(ancestorsIds.size + 1);
} else {
let index = ImmutableList(ancestorsIds).indexOf(id);
if (index === -1) {
index = ImmutableList(descendantsIds).indexOf(id);
_selectChild(ancestorsIds.size + index + 2);
} else {
_selectChild(index + 1);
}
}
};
const _selectChild = (index: number) => {
scroller.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
const element = document.querySelector<HTMLDivElement>(`#thread [data-index="${index}"] .focusable`);
if (element) {
element.focus();
}
},
});
};
const renderTombstone = (id: string) => {
return (
<div className='py-4 pb-8'>
<Tombstone
key={id}
id={id}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
</div>
);
};
const renderStatus = (id: string) => {
return (
<ThreadStatus
key={id}
id={id}
focusedStatusId={status!.id}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
contextType='thread'
/>
);
};
const renderPendingStatus = (id: string) => {
const idempotencyKey = id.replace(/^末pending-/, '');
return (
<PendingStatus
key={id}
idempotencyKey={idempotencyKey}
thread
/>
);
};
const renderChildren = (list: ImmutableOrderedSet<string>) => {
return list.map(id => {
if (id.endsWith('-tombstone')) {
return renderTombstone(id);
} else if (id.startsWith('末pending-')) {
return renderPendingStatus(id);
} else {
return renderStatus(id);
}
});
};
// Reset media visibility if status changes.
useEffect(() => {
setShowMedia(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
}, [status.id]);
// Scroll focused status into view when thread updates.
useEffect(() => {
scroller.current?.scrollToIndex({
index: ancestorsIds.size,
offset: -146,
});
setImmediate(() => statusRef.current?.querySelector<HTMLDivElement>('.detailed-actualStatus')?.focus());
}, [status.id, ancestorsIds.size]);
const handleOpenCompareHistoryModal = (status: Status) => {
dispatch(openModal('COMPARE_HISTORY', {
statusId: status.id,
}));
};
const hasAncestors = ancestorsIds.size > 0;
const hasDescendants = descendantsIds.size > 0;
type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void };
const handlers: HotkeyHandlers = {
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
reply: handleHotkeyReply,
favourite: handleHotkeyFavourite,
boost: handleHotkeyBoost,
mention: handleHotkeyMention,
openProfile: handleHotkeyOpenProfile,
toggleHidden: handleHotkeyToggleHidden,
toggleSensitive: handleHotkeyToggleSensitive,
openMedia: handleHotkeyOpenMedia,
react: handleHotkeyReact,
};
const focusedStatus = (
<div className={clsx({ 'pb-4': hasDescendants })} key={status.id}>
<HotKeys handlers={handlers}>
<div
ref={statusRef}
className='focusable relative'
tabIndex={0}
// FIXME: no "reblogged by" text is added for the screen reader
aria-label={textForScreenReader(intl, status)}
>
<DetailedStatus
status={status}
showMedia={showMedia}
withMedia={withMedia}
onToggleMediaVisibility={handleToggleMediaVisibility}
onOpenCompareHistoryModal={handleOpenCompareHistoryModal}
/>
{!isUnderReview ? (
<>
<hr className='-mx-4 mb-2 max-w-[100vw] border-t-2 dark:border-primary-800' />
<StatusActionBar
status={status}
expandable={false}
space='lg'
withLabels
/>
</>
) : null}
</div>
</HotKeys>
{hasDescendants && (
<hr className='-mx-4 mt-2 max-w-[100vw] border-t-2 dark:border-primary-800' />
)}
</div>
);
const children: JSX.Element[] = [];
if (!useWindowScroll) {
// Add padding to the top of the Thread (for Media Modal)
children.push(<div className='h-4' />);
}
if (hasAncestors) {
children.push(...renderChildren(ancestorsIds).toArray());
}
children.push(focusedStatus);
if (hasDescendants) {
children.push(...renderChildren(descendantsIds).toArray());
}
return (
<Stack
space={2}
className={
clsx({
'h-full': !useWindowScroll,
'mt-2': useWindowScroll,
})
}
>
<div
ref={node}
className={
clsx('thread', {
'h-full': !useWindowScroll,
})
}
>
<ScrollableList
id='thread'
ref={scroller}
hasMore={!!next}
onLoadMore={handleLoadMore}
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
initialTopMostItemIndex={ancestorsIds.size}
useWindowScroll={useWindowScroll}
itemClassName={itemClassName}
className={
clsx({
'h-full': !useWindowScroll,
})
}
>
{children}
</ScrollableList>
</div>
{!me && <ThreadLoginCta />}
</Stack>
);
};
export default Thread;

View file

@ -1,49 +1,20 @@
import clsx from 'clsx';
import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { HotKeys } from 'react-hotkeys';
import React, { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Redirect, useHistory } from 'react-router-dom';
import { createSelector } from 'reselect';
import { Redirect } from 'react-router-dom';
import {
replyCompose,
mentionCompose,
} from 'soapbox/actions/compose';
import {
favourite,
unfavourite,
reblog,
unreblog,
} from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { getSettings } from 'soapbox/actions/settings';
import {
hideStatus,
revealStatus,
fetchStatusWithContext,
fetchNext,
} from 'soapbox/actions/statuses';
import MissingIndicator from 'soapbox/components/missing-indicator';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import ScrollableList from 'soapbox/components/scrollable-list';
import StatusActionBar from 'soapbox/components/status-action-bar';
import Tombstone from 'soapbox/components/tombstone';
import { Column, Stack } from 'soapbox/components/ui';
import { Column } from 'soapbox/components/ui';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
import PendingStatus from 'soapbox/features/ui/components/pending-status';
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetStatus } from 'soapbox/selectors';
import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status';
import DetailedStatus from './components/detailed-status';
import ThreadLoginCta from './components/thread-login-cta';
import ThreadStatus from './components/thread-status';
import type { VirtuosoHandle } from 'react-virtuoso';
import type { RootState } from 'soapbox/store';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
import Thread from './components/thread';
const messages = defineMessages({
title: { id: 'status.title', defaultMessage: 'Post Details' },
@ -63,104 +34,26 @@ const messages = defineMessages({
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
});
const getAncestorsIds = createSelector([
(_: RootState, statusId: string | undefined) => statusId,
(state: RootState) => state.contexts.inReplyTos,
], (statusId, inReplyTos) => {
let ancestorsIds = ImmutableOrderedSet<string>();
let id: string | undefined = statusId;
while (id && !ancestorsIds.includes(id)) {
ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds);
id = inReplyTos.get(id);
}
return ancestorsIds;
});
export const getDescendantsIds = createSelector([
(_: RootState, statusId: string) => statusId,
(state: RootState) => state.contexts.replies,
], (statusId, contextReplies) => {
let descendantsIds = ImmutableOrderedSet<string>();
const ids = [statusId];
while (ids.length > 0) {
const id = ids.shift();
if (!id) break;
const replies = contextReplies.get(id);
if (descendantsIds.includes(id)) {
break;
}
if (statusId !== id) {
descendantsIds = descendantsIds.union([id]);
}
if (replies) {
replies.reverse().forEach((reply: string) => {
ids.unshift(reply);
});
}
}
return descendantsIds;
});
type DisplayMedia = 'default' | 'hide_all' | 'show_all';
type RouteParams = {
statusId: string
groupId?: string
groupSlug?: string
};
interface IThread {
interface IStatusDetails {
params: RouteParams
}
const Thread: React.FC<IThread> = (props) => {
const intl = useIntl();
const history = useHistory();
const StatusDetails: React.FC<IStatusDetails> = (props) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const settings = useSettings();
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector((state) => getStatus(state, { id: props.params.statusId }));
const me = useAppSelector(state => state.me);
const status = useAppSelector(state => getStatus(state, { id: props.params.statusId }));
const displayMedia = settings.get('displayMedia') as DisplayMedia;
const isUnderReview = status?.visibility === 'self';
const { ancestorsIds, descendantsIds } = useAppSelector(state => {
let ancestorsIds = ImmutableOrderedSet<string>();
let descendantsIds = ImmutableOrderedSet<string>();
if (status) {
const statusId = status.id;
ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos.get(statusId));
descendantsIds = getDescendantsIds(state, statusId);
ancestorsIds = ancestorsIds.delete(statusId).subtract(descendantsIds);
descendantsIds = descendantsIds.delete(statusId).subtract(ancestorsIds);
}
return {
status,
ancestorsIds,
descendantsIds,
};
});
const [showMedia, setShowMedia] = useState<boolean>(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
const [next, setNext] = useState<string>();
const node = useRef<HTMLDivElement>(null);
const statusRef = useRef<HTMLDivElement>(null);
const scroller = useRef<VirtuosoHandle>(null);
/** Fetch the status (and context) from the API. */
const fetchData = async () => {
const { params } = props;
@ -173,234 +66,11 @@ const Thread: React.FC<IThread> = (props) => {
useEffect(() => {
fetchData().then(() => {
setIsLoaded(true);
}).catch(error => {
}).catch(() => {
setIsLoaded(true);
});
}, [props.params.statusId]);
const handleToggleMediaVisibility = () => {
setShowMedia(!showMedia);
};
const handleHotkeyReact = () => {
if (statusRef.current) {
const firstEmoji: HTMLButtonElement | null = statusRef.current.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
firstEmoji?.focus();
}
};
const handleFavouriteClick = (status: StatusEntity) => {
if (status.favourited) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
};
const handleReplyClick = (status: StatusEntity) => {
dispatch(replyCompose(status));
};
const handleModalReblog = (status: StatusEntity) => {
dispatch(reblog(status));
};
const handleReblogClick = (status: StatusEntity, e?: React.MouseEvent) => {
dispatch((_, getState) => {
const boostModal = getSettings(getState()).get('boostModal');
if (status.reblogged) {
dispatch(unreblog(status));
} else {
if ((e && e.shiftKey) || !boostModal) {
handleModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: handleModalReblog }));
}
}
});
};
const handleMentionClick = (account: AccountEntity) => {
dispatch(mentionCompose(account));
};
const handleHotkeyOpenMedia = (e?: KeyboardEvent) => {
const media = status?.media_attachments;
e?.preventDefault();
if (media && media.size) {
const firstAttachment = media.first()!;
if (media.size === 1 && firstAttachment.type === 'video') {
dispatch(openModal('VIDEO', { media: firstAttachment, status: status }));
} else {
dispatch(openModal('MEDIA', { media, index: 0, status: status }));
}
}
};
const handleToggleHidden = (status: StatusEntity) => {
if (status.hidden) {
dispatch(revealStatus(status.id));
} else {
dispatch(hideStatus(status.id));
}
};
const handleHotkeyMoveUp = () => {
handleMoveUp(status!.id);
};
const handleHotkeyMoveDown = () => {
handleMoveDown(status!.id);
};
const handleHotkeyReply = (e?: KeyboardEvent) => {
e?.preventDefault();
handleReplyClick(status!);
};
const handleHotkeyFavourite = () => {
handleFavouriteClick(status!);
};
const handleHotkeyBoost = () => {
handleReblogClick(status!);
};
const handleHotkeyMention = (e?: KeyboardEvent) => {
e?.preventDefault();
const { account } = status!;
if (!account || typeof account !== 'object') return;
handleMentionClick(account);
};
const handleHotkeyOpenProfile = () => {
history.push(`/@${status!.getIn(['account', 'acct'])}`);
};
const handleHotkeyToggleHidden = () => {
handleToggleHidden(status!);
};
const handleHotkeyToggleSensitive = () => {
handleToggleMediaVisibility();
};
const handleMoveUp = (id: string) => {
if (id === status?.id) {
_selectChild(ancestorsIds.size - 1);
} else {
let index = ImmutableList(ancestorsIds).indexOf(id);
if (index === -1) {
index = ImmutableList(descendantsIds).indexOf(id);
_selectChild(ancestorsIds.size + index);
} else {
_selectChild(index - 1);
}
}
};
const handleMoveDown = (id: string) => {
if (id === status?.id) {
_selectChild(ancestorsIds.size + 1);
} else {
let index = ImmutableList(ancestorsIds).indexOf(id);
if (index === -1) {
index = ImmutableList(descendantsIds).indexOf(id);
_selectChild(ancestorsIds.size + index + 2);
} else {
_selectChild(index + 1);
}
}
};
const _selectChild = (index: number) => {
scroller.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
const element = document.querySelector<HTMLDivElement>(`#thread [data-index="${index}"] .focusable`);
if (element) {
element.focus();
}
},
});
};
const renderTombstone = (id: string) => {
return (
<div className='py-4 pb-8'>
<Tombstone
key={id}
id={id}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
</div>
);
};
const renderStatus = (id: string) => {
return (
<ThreadStatus
key={id}
id={id}
focusedStatusId={status!.id}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
contextType='thread'
/>
);
};
const renderPendingStatus = (id: string) => {
const idempotencyKey = id.replace(/^末pending-/, '');
return (
<PendingStatus
key={id}
idempotencyKey={idempotencyKey}
thread
/>
);
};
const renderChildren = (list: ImmutableOrderedSet<string>) => {
return list.map(id => {
if (id.endsWith('-tombstone')) {
return renderTombstone(id);
} else if (id.startsWith('末pending-')) {
return renderPendingStatus(id);
} else {
return renderStatus(id);
}
});
};
// Reset media visibility if status changes.
useEffect(() => {
setShowMedia(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
}, [status?.id]);
// Scroll focused status into view when thread updates.
useEffect(() => {
scroller.current?.scrollToIndex({
index: ancestorsIds.size,
offset: -146,
});
setImmediate(() => statusRef.current?.querySelector<HTMLDivElement>('.detailed-actualStatus')?.focus());
}, [props.params.statusId, status?.id, ancestorsIds.size, isLoaded]);
const handleRefresh = () => {
return fetchData();
};
const handleLoadMore = useCallback(debounce(() => {
if (next && status) {
dispatch(fetchNext(status.id, next)).then(({ next }) => {
@ -409,15 +79,10 @@ const Thread: React.FC<IThread> = (props) => {
}
}, 300, { leading: true }), [next, status]);
const handleOpenCompareHistoryModal = (status: StatusEntity) => {
dispatch(openModal('COMPARE_HISTORY', {
statusId: status.id,
}));
const handleRefresh = () => {
return fetchData();
};
const hasAncestors = ancestorsIds.size > 0;
const hasDescendants = descendantsIds.size > 0;
if (status?.event) {
return (
<Redirect to={`/@${status.getIn(['account', 'acct'])}/events/${status.id}`} />
@ -436,73 +101,6 @@ const Thread: React.FC<IThread> = (props) => {
);
}
type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void };
const handlers: HotkeyHandlers = {
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
reply: handleHotkeyReply,
favourite: handleHotkeyFavourite,
boost: handleHotkeyBoost,
mention: handleHotkeyMention,
openProfile: handleHotkeyOpenProfile,
toggleHidden: handleHotkeyToggleHidden,
toggleSensitive: handleHotkeyToggleSensitive,
openMedia: handleHotkeyOpenMedia,
react: handleHotkeyReact,
};
const focusedStatus = (
<div className={clsx({ 'pb-4': hasDescendants })} key={status.id}>
<HotKeys handlers={handlers}>
<div
ref={statusRef}
className='focusable relative'
tabIndex={0}
// FIXME: no "reblogged by" text is added for the screen reader
aria-label={textForScreenReader(intl, status)}
>
<DetailedStatus
status={status}
showMedia={showMedia}
onToggleMediaVisibility={handleToggleMediaVisibility}
onOpenCompareHistoryModal={handleOpenCompareHistoryModal}
/>
{!isUnderReview ? (
<>
<hr className='-mx-4 mb-2 max-w-[100vw] border-t-2 dark:border-primary-800' />
<StatusActionBar
status={status}
expandable={false}
space='expand'
withLabels
/>
</>
) : null}
</div>
</HotKeys>
{hasDescendants && (
<hr className='-mx-4 mt-2 max-w-[100vw] border-t-2 dark:border-primary-800' />
)}
</div>
);
const children: JSX.Element[] = [];
if (hasAncestors) {
children.push(...renderChildren(ancestorsIds).toArray());
}
children.push(focusedStatus);
if (hasDescendants) {
children.push(...renderChildren(descendantsIds).toArray());
}
if (status.group && typeof status.group === 'object') {
if (status.group.slug && !props.params.groupSlug) {
return <Redirect to={`/group/${status.group.slug}/posts/${props.params.statusId}`} />;
@ -517,25 +115,14 @@ const Thread: React.FC<IThread> = (props) => {
return (
<Column label={intl.formatMessage(titleMessage())}>
<PullToRefresh onRefresh={handleRefresh}>
<Stack space={2} className='mt-2'>
<div ref={node} className='thread'>
<ScrollableList
id='thread'
ref={scroller}
hasMore={!!next}
onLoadMore={handleLoadMore}
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
initialTopMostItemIndex={ancestorsIds.size}
>
{children}
</ScrollableList>
</div>
{!me && <ThreadLoginCta />}
</Stack>
<Thread
status={status}
next={next}
handleLoadMore={handleLoadMore}
/>
</PullToRefresh>
</Column>
);
};
export default Thread;
export default StatusDetails;

View file

@ -1,15 +1,21 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import ReactSwipeableViews from 'react-swipeable-views';
import { fetchNext, fetchStatusWithContext } from 'soapbox/actions/statuses';
import ExtendedVideoPlayer from 'soapbox/components/extended-video-player';
import Icon from 'soapbox/components/icon';
import IconButton from 'soapbox/components/icon-button';
import MissingIndicator from 'soapbox/components/missing-indicator';
import StatusActionBar from 'soapbox/components/status-action-bar';
import { Icon, IconButton, HStack, Stack } from 'soapbox/components/ui';
import Audio from 'soapbox/features/audio';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
import Thread from 'soapbox/features/status/components/thread';
import Video from 'soapbox/features/video';
import { useAppDispatch } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetStatus } from 'soapbox/selectors';
import ImageLoader from '../image-loader';
@ -18,16 +24,31 @@ import type { Attachment, Status } from 'soapbox/types/entities';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
expand: { id: 'lightbox.expand', defaultMessage: 'Expand' },
minimize: { id: 'lightbox.minimize', defaultMessage: 'Minimize' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
});
// you can't use 100vh, because the viewport height is taller
// than the visible part of the document in some mobile
// browsers when it's address bar is visible.
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
const swipeableViewsStyle: React.CSSProperties = {
width: '100%',
height: '100%',
};
const containerStyle: React.CSSProperties = {
alignItems: 'center', // center vertically
};
interface IMediaModal {
media: ImmutableList<Attachment>
status?: Status
index: number
time?: number
onClose: () => void
onClose(): void
}
const MediaModal: React.FC<IMediaModal> = (props) => {
@ -38,29 +59,24 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
time = 0,
} = props;
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const history = useHistory();
const intl = useIntl();
const getStatus = useCallback(makeGetStatus(), []);
const actualStatus = useAppSelector((state) => getStatus(state, { id: status?.id as string }));
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
const [next, setNext] = useState<string>();
const [index, setIndex] = useState<number | null>(null);
const [navigationHidden, setNavigationHidden] = useState(false);
const [isFullScreen, setIsFullScreen] = useState(false);
const handleSwipe = (index: number) => {
setIndex(index % media.size);
};
const hasMultipleImages = media.size > 1;
const handleNextClick = () => {
setIndex((getIndex() + 1) % media.size);
};
const handlePrevClick = () => {
setIndex((media.size + getIndex() - 1) % media.size);
};
const handleChangeIndex: React.MouseEventHandler<HTMLButtonElement> = (e) => {
const index = Number(e.currentTarget.getAttribute('data-index'));
setIndex(index % media.size);
};
const handleSwipe = (index: number) => setIndex(index % media.size);
const handleNextClick = () => setIndex((getIndex() + 1) % media.size);
const handlePrevClick = () => setIndex((media.size + getIndex() - 1) % media.size);
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
@ -77,13 +93,10 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
}
};
useEffect(() => {
window.addEventListener('keydown', handleKeyDown, false);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [index]);
const handleDownload = () => {
const mediaItem = hasMultipleImages ? media.get(index as number) : media.get(0);
window.open(mediaItem?.url);
};
const getIndex = () => index !== null ? index : props.index;
@ -105,61 +118,6 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
}
};
const handleCloserClick: React.MouseEventHandler = ({ target }) => {
const whitelist = ['zoomable-image'];
const activeSlide = document.querySelector('.media-modal .react-swipeable-view-container > div[aria-hidden="false"]');
const isClickOutside = target === activeSlide || !activeSlide?.contains(target as Element);
const isWhitelisted = whitelist.some(w => (target as Element).classList.contains(w));
if (isClickOutside || isWhitelisted) {
onClose();
}
};
let pagination: React.ReactNode[] = [];
const leftNav = media.size > 1 && (
<button
tabIndex={0}
className='media-modal__nav media-modal__nav--left'
onClick={handlePrevClick}
aria-label={intl.formatMessage(messages.previous)}
>
<Icon src={require('@tabler/icons/arrow-left.svg')} />
</button>
);
const rightNav = media.size > 1 && (
<button
tabIndex={0}
className='media-modal__nav media-modal__nav--right'
onClick={handleNextClick}
aria-label={intl.formatMessage(messages.next)}
>
<Icon src={require('@tabler/icons/arrow-right.svg')} />
</button>
);
if (media.size > 1) {
pagination = media.toArray().map((item, i) => (
<li className='media-modal__page-dot' key={i}>
<button
tabIndex={0}
className={clsx('media-modal__button', {
'media-modal__button--active': i === getIndex(),
})}
onClick={handleChangeIndex}
data-index={i}
>
{i + 1}
</button>
</li>
));
}
const isMultiMedia = media.map((image) => image.type !== 'image').toArray();
const content = media.map((attachment, i) => {
const width = (attachment.meta.getIn(['original', 'width']) || undefined) as number | undefined;
const height = (attachment.meta.getIn(['original', 'height']) || undefined) as number | undefined;
@ -230,62 +188,154 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
return null;
}).toArray();
// you can't use 100vh, because the viewport height is taller
// than the visible part of the document in some mobile
// browsers when it's address bar is visible.
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
const swipeableViewsStyle: React.CSSProperties = {
width: '100%',
height: '100%',
const handleLoadMore = useCallback(debounce(() => {
if (next && status) {
dispatch(fetchNext(status?.id, next)).then(({ next }) => {
setNext(next);
}).catch(() => { });
}
}, 300, { leading: true }), [next, status]);
/** Fetch the status (and context) from the API. */
const fetchData = async () => {
const { next } = await dispatch(fetchStatusWithContext(status?.id as string));
setNext(next);
};
const containerStyle: React.CSSProperties = {
alignItems: 'center', // center vertically
};
// Load data.
useEffect(() => {
fetchData().then(() => {
setIsLoaded(true);
}).catch(() => {
setIsLoaded(true);
});
}, [status?.id]);
const navigationClassName = clsx('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
});
useEffect(() => {
window.addEventListener('keydown', handleKeyDown, false);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [index]);
if (!actualStatus && isLoaded) {
return (
<MissingIndicator />
);
} else if (!actualStatus) {
return <PlaceholderStatus />;
}
return (
<div className='modal-root__modal media-modal'>
<div className='media-modal pointer-events-auto fixed inset-0 z-[9999] h-full bg-gray-900/90'>
<div
className='media-modal__closer'
className='absolute inset-0'
role='presentation'
onClick={handleCloserClick}
>
<ReactSwipeableViews
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={handleSwipe}
index={getIndex()}
<Stack
className={
clsx('fixed inset-0 h-full grow transition-all', {
'xl:pr-96': !isFullScreen,
'xl:pr-0': isFullScreen,
})
}
justifyContent='between'
>
{content}
</ReactSwipeableViews>
</div>
<HStack alignItems='center' justifyContent='between' className='flex-[0_0_60px] p-4'>
<IconButton
title={intl.formatMessage(messages.close)}
src={require('@tabler/icons/x.svg')}
onClick={onClose}
theme='dark'
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
/>
<div className={navigationClassName}>
<IconButton
className='media-modal__close'
title={intl.formatMessage(messages.close)}
src={require('@tabler/icons/x.svg')}
onClick={onClose}
/>
<HStack alignItems='center' space={2}>
<IconButton
src={require('@tabler/icons/download.svg')}
// title={intl.formatMessage(isFullScreen ? messages.minimize : messages.expand)}
theme='dark'
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
onClick={handleDownload}
/>
{leftNav}
{rightNav}
<IconButton
src={isFullScreen ? require('@tabler/icons/arrows-minimize.svg') : require('@tabler/icons/arrows-maximize.svg')}
title={intl.formatMessage(isFullScreen ? messages.minimize : messages.expand)}
theme='dark'
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
onClick={() => setIsFullScreen(!isFullScreen)}
/>
</HStack>
</HStack>
{(status && !isMultiMedia[getIndex()]) && (
<div className={clsx('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
<a href={status.url} onClick={handleStatusClick}>
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
</a>
{/* Height based on height of top/bottom bars */}
<div className='relative h-[calc(100vh-120px)] w-full grow'>
{hasMultipleImages && (
<div className='absolute inset-y-0 left-5 z-10 flex items-center'>
<button
tabIndex={0}
className='flex h-10 w-10 items-center justify-center rounded-full bg-gray-900 text-white'
onClick={handlePrevClick}
aria-label={intl.formatMessage(messages.previous)}
>
<Icon src={require('@tabler/icons/arrow-left.svg')} className='h-5 w-5' />
</button>
</div>
)}
<ReactSwipeableViews
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={handleSwipe}
index={getIndex()}
>
{content}
</ReactSwipeableViews>
{hasMultipleImages && (
<div className='absolute inset-y-0 right-5 z-10 flex items-center'>
<button
tabIndex={0}
className='flex h-10 w-10 items-center justify-center rounded-full bg-gray-900 text-white'
onClick={handleNextClick}
aria-label={intl.formatMessage(messages.next)}
>
<Icon src={require('@tabler/icons/arrow-right.svg')} className='h-5 w-5' />
</button>
</div>
)}
</div>
)}
<ul className='media-modal__pagination'>
{pagination}
</ul>
<HStack justifyContent='center' className='flex-[0_0_60px]'>
<StatusActionBar
status={actualStatus}
space='md'
statusActionButtonTheme='inverse'
/>
</HStack>
</Stack>
<div
className={
clsx('-right-96 hidden bg-white transition-all xl:fixed xl:inset-y-0 xl:right-0 xl:flex xl:w-96 xl:flex-col', {
'xl:!-right-96': isFullScreen,
})
}
>
<Thread
status={actualStatus}
withMedia={false}
useWindowScroll={false}
itemClassName='px-4'
next={next}
handleLoadMore={handleLoadMore}
/>
</div>
</div>
</div>
);

View file

@ -7,9 +7,6 @@
}
.media-modal {
// https://stackoverflow.com/a/8468131
@apply w-full h-full absolute inset-0;
.audio-player.detailed,
.extended-video-player {
display: flex;
@ -30,126 +27,6 @@
@apply max-w-full max-h-[80%];
}
}
&__closer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
&__navigation {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
transition: opacity 0.3s linear;
will-change: opacity;
* {
pointer-events: auto;
}
&--hidden {
opacity: 0;
* {
pointer-events: none;
}
}
}
&__nav {
@apply absolute top-0 bottom-0 my-auto mx-0 box-border flex h-[20vmax] cursor-pointer items-center border-0 bg-black/50 text-2xl text-white;
padding: 30px 15px;
@media screen and (max-width: 600px) {
@apply px-0.5;
}
.svg-icon {
@apply h-6 w-6;
}
&--left {
left: 0;
}
&--right {
right: 0;
}
}
&__pagination {
width: 100%;
text-align: center;
position: absolute;
left: 0;
bottom: 20px;
pointer-events: none;
}
&__meta {
text-align: center;
position: absolute;
left: 0;
bottom: 20px;
width: 100%;
pointer-events: none;
&--shifted {
bottom: 62px;
}
a {
text-decoration: none;
font-weight: 500;
color: #fff;
&:hover,
&:focus,
&:active {
text-decoration: underline;
}
}
}
&__page-dot {
display: inline-block;
}
&__button {
background-color: #fff;
height: 12px;
width: 12px;
border-radius: 6px;
margin: 10px;
padding: 0;
border: 0;
font-size: 0;
&--active {
@apply bg-accent-500;
}
}
&__close {
position: absolute;
right: 8px;
top: 8px;
height: 48px;
width: 48px;
z-index: 100;
color: #fff;
.svg-icon {
height: 48px;
width: 48px;
}
}
}
.error-modal {
@ -198,24 +75,6 @@
min-width: 33px;
}
}
&__nav {
border: 0;
font-size: 14px;
font-weight: 500;
padding: 10px 25px;
line-height: inherit;
height: auto;
margin: -10px;
border-radius: 4px;
background-color: transparent;
&:hover,
&:focus,
&:active {
@apply text-gray-400;
}
}
}
.actions-modal {