diff --git a/app/soapbox/actions/modals.ts b/app/soapbox/actions/modals.ts index 83b52cb3e..20ae13f0a 100644 --- a/app/soapbox/actions/modals.ts +++ b/app/soapbox/actions/modals.ts @@ -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 { diff --git a/app/soapbox/components/modal-root.tsx b/app/soapbox/components/modal-root.tsx index 2358a951f..5881612bf 100644 --- a/app/soapbox/components/modal-root.tsx +++ b/app/soapbox/components/modal-root.tsx @@ -252,6 +252,7 @@ const ModalRoot: React.FC = ({ 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} diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 13e1a4a3c..bc8f7026c 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -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 = ({ 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 = ({ onClick={handleReblogClick} count={reblogCount} text={withLabels ? intl.formatMessage(messages.reblog) : undefined} + theme={statusActionButtonTheme} /> ); @@ -583,13 +586,22 @@ const StatusActionBar: React.FC = ({ const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group'); + const spacing: { + [key: string]: React.ComponentProps['space'] + } = { + 'sm': 2, + 'md': 8, + 'lg': 0, // using justifyContent instead on the HStack + }; + return ( e.stopPropagation()} + alignItems='center' > = ({ count={replyCount} text={withLabels ? intl.formatMessage(messages.reply) : undefined} disabled={replyDisabled} + theme={statusActionButtonTheme} /> @@ -628,6 +641,7 @@ const StatusActionBar: React.FC = ({ count={emojiReactCount} emoji={meEmojiReact} text={withLabels ? meEmojiTitle : undefined} + theme={statusActionButtonTheme} /> ) : ( @@ -640,6 +654,7 @@ const StatusActionBar: React.FC = ({ active={Boolean(meEmojiName)} count={favouriteCount} text={withLabels ? meEmojiTitle : undefined} + theme={statusActionButtonTheme} /> )} @@ -653,6 +668,7 @@ const StatusActionBar: React.FC = ({ active={status.disliked} count={status.dislikes_count} text={withLabels ? intl.formatMessage(messages.disfavourite) : undefined} + theme={statusActionButtonTheme} /> )} @@ -661,6 +677,7 @@ const StatusActionBar: React.FC = ({ title={intl.formatMessage(messages.share)} icon={require('@tabler/icons/upload.svg')} onClick={handleShareClick} + theme={statusActionButtonTheme} /> )} @@ -668,6 +685,7 @@ const StatusActionBar: React.FC = ({ diff --git a/app/soapbox/components/status-action-button.tsx b/app/soapbox/components/status-action-button.tsx index 47b3c11b8..10f952065 100644 --- a/app/soapbox/components/status-action-button.tsx +++ b/app/soapbox/components/status-action-button.tsx @@ -35,10 +35,11 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes text?: React.ReactNode + theme?: 'default' | 'inverse' } const StatusActionButton = React.forwardRef((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 { /** 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} diff --git a/app/soapbox/features/event/event-discussion.tsx b/app/soapbox/features/event/event-discussion.tsx index 3c96c73b8..77475e222 100644 --- a/app/soapbox/features/event/event-discussion.tsx +++ b/app/soapbox/features/event/event-discussion.tsx @@ -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'; diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index cd81dd0d2..46b2a338e 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -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 = ({ onOpenCompareHistoryModal, onToggleMediaVisibility, showMedia, + withMedia = true, }) => { const intl = useIntl(); @@ -151,7 +153,7 @@ const DetailedStatus: React.FC = ({ - {(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && ( + {(withMedia && (quote || actualStatus.card || actualStatus.media_attachments.size > 0)) && ( statusId, + (state: RootState) => state.contexts.inReplyTos, +], (statusId, inReplyTos) => { + let ancestorsIds = ImmutableOrderedSet(); + 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(); + 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(); + let descendantsIds = ImmutableOrderedSet(); + + 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(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia)); + + const node = useRef(null); + const statusRef = useRef(null); + const scroller = useRef(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(`#thread [data-index="${index}"] .focusable`); + + if (element) { + element.focus(); + } + }, + }); + }; + + const renderTombstone = (id: string) => { + return ( +
+ +
+ ); + }; + + const renderStatus = (id: string) => { + return ( + + ); + }; + + const renderPendingStatus = (id: string) => { + const idempotencyKey = id.replace(/^末pending-/, ''); + + return ( + + ); + }; + + const renderChildren = (list: ImmutableOrderedSet) => { + 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('.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 = ( +
+ +
+ + + + {!isUnderReview ? ( + <> +
+ + + + ) : null} +
+
+ + {hasDescendants && ( +
+ )} +
+ ); + + const children: JSX.Element[] = []; + + if (!useWindowScroll) { + // Add padding to the top of the Thread (for Media Modal) + children.push(
); + } + + if (hasAncestors) { + children.push(...renderChildren(ancestorsIds).toArray()); + } + + children.push(focusedStatus); + + if (hasDescendants) { + children.push(...renderChildren(descendantsIds).toArray()); + } + + return ( + +
+ } + initialTopMostItemIndex={ancestorsIds.size} + useWindowScroll={useWindowScroll} + itemClassName={itemClassName} + className={ + clsx({ + 'h-full': !useWindowScroll, + }) + } + > + {children} + +
+ + {!me && } +
+ ); +}; + +export default Thread; \ No newline at end of file diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index e79a2b8f9..f2b882e0a 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -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(); - 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(); - 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 = (props) => { - const intl = useIntl(); - const history = useHistory(); +const StatusDetails: React.FC = (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(); - let descendantsIds = ImmutableOrderedSet(); - - 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(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia)); const [isLoaded, setIsLoaded] = useState(!!status); const [next, setNext] = useState(); - const node = useRef(null); - const statusRef = useRef(null); - const scroller = useRef(null); - /** Fetch the status (and context) from the API. */ const fetchData = async () => { const { params } = props; @@ -173,234 +66,11 @@ const Thread: React.FC = (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(`#thread [data-index="${index}"] .focusable`); - - if (element) { - element.focus(); - } - }, - }); - }; - - const renderTombstone = (id: string) => { - return ( -
- -
- ); - }; - - const renderStatus = (id: string) => { - return ( - - ); - }; - - const renderPendingStatus = (id: string) => { - const idempotencyKey = id.replace(/^末pending-/, ''); - - return ( - - ); - }; - - const renderChildren = (list: ImmutableOrderedSet) => { - 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('.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 = (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 ( @@ -436,73 +101,6 @@ const Thread: React.FC = (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 = ( -
- -
- - - - {!isUnderReview ? ( - <> -
- - - - ) : null} -
-
- - {hasDescendants && ( -
- )} -
- ); - - 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 ; @@ -517,25 +115,14 @@ const Thread: React.FC = (props) => { return ( - -
- } - initialTopMostItemIndex={ancestorsIds.size} - > - {children} - -
- - {!me && } -
+
); }; -export default Thread; +export default StatusDetails; diff --git a/app/soapbox/features/ui/components/modals/media-modal.tsx b/app/soapbox/features/ui/components/modals/media-modal.tsx index 530b1e6cc..18c24a5da 100644 --- a/app/soapbox/features/ui/components/modals/media-modal.tsx +++ b/app/soapbox/features/ui/components/modals/media-modal.tsx @@ -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 status?: Status index: number time?: number - onClose: () => void + onClose(): void } const MediaModal: React.FC = (props) => { @@ -38,29 +59,24 @@ const MediaModal: React.FC = (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(!!status); + const [next, setNext] = useState(); const [index, setIndex] = useState(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 = (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 = (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 = (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 && ( - - ); - - const rightNav = media.size > 1 && ( - - ); - - if (media.size > 1) { - pagination = media.toArray().map((item, i) => ( -
  • - -
  • - )); - } - - 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 = (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 ( + + ); + } else if (!actualStatus) { + return ; + } return ( -
    +
    - - {content} - -
    + + -
    - + + - {leftNav} - {rightNav} + setIsFullScreen(!isFullScreen)} + /> + + - {(status && !isMultiMedia[getIndex()]) && ( -
    1 })}> - - - + {/* Height based on height of top/bottom bars */} +
    + {hasMultipleImages && ( +
    + +
    + )} + + + {content} + + + {hasMultipleImages && ( +
    + +
    + )}
    - )} -
      - {pagination} -
    + + + + + +
    ); diff --git a/app/styles/components/modal.scss b/app/styles/components/modal.scss index b236c4428..b4c31ff4f 100644 --- a/app/styles/components/modal.scss +++ b/app/styles/components/modal.scss @@ -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 {