diff --git a/app/javascript/mastodon/actions/interactions_typed.ts b/app/javascript/mastodon/actions/interactions_typed.ts index 09d28da5257..30e89207755 100644 --- a/app/javascript/mastodon/actions/interactions_typed.ts +++ b/app/javascript/mastodon/actions/interactions_typed.ts @@ -35,6 +35,8 @@ import { editStatus, muteStatus, setStatusQuotePolicy, + translateStatus, + undoStatusTranslation, unmuteStatus, } from './statuses'; @@ -53,7 +55,8 @@ export type StatusInteractionIntent = | 'redraft' | 'reply' | 'report' - | 'revokeQuote'; + | 'revokeQuote' + | 'translate'; const messages = defineMessages({ noEdits: { @@ -77,12 +80,16 @@ export const statusInteraction = createAppThunk( contextType, intent, }: { - statusId: string; + statusId?: string; contextType?: StatusContextType; intent: StatusInteractionIntent; }, { getState, dispatch }, ) => { + if (!statusId) { + return; + } + const state = getState(); const statusImmutable = state.statuses.get(statusId); if (!statusImmutable) { @@ -255,6 +262,12 @@ export const statusInteraction = createAppThunk( }), ); return; + case 'translate': + if (status.translation) { + dispatch(undoStatusTranslation(statusId, status.poll)); + } else { + dispatch(translateStatus(statusId)); + } } }, ); diff --git a/app/javascript/mastodon/components/status/attachments.tsx b/app/javascript/mastodon/components/status/attachments.tsx index 7747698b930..ddb82759bc3 100644 --- a/app/javascript/mastodon/components/status/attachments.tsx +++ b/app/javascript/mastodon/components/status/attachments.tsx @@ -13,10 +13,8 @@ import type { MediaAttachmentShape, } from '@/mastodon/models/status'; import { isMediaAttachmentOfType } from '@/mastodon/models/status'; -import { - selectMediaMatchFilters, - selectPictureInPicture, -} from '@/mastodon/selectors/statuses'; +import { selectMediaFilters } from '@/mastodon/selectors/filters'; +import { selectPictureInPicture } from '@/mastodon/selectors/statuses'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { compareUrls } from '@/mastodon/utils/compare_urls'; @@ -125,7 +123,7 @@ const MediaAttachments: React.FC<{ ]) as Immutable.List; }); const mediaFilters = useAppSelector((state) => - selectMediaMatchFilters(state, { statusId, contextType }), + selectMediaFilters(state, { statusId, contextType }), ); const pictureInPicture = useAppSelector((state) => selectPictureInPicture(state, statusId), diff --git a/app/javascript/mastodon/components/status/hooks.ts b/app/javascript/mastodon/components/status/hooks.ts new file mode 100644 index 00000000000..6e662b44053 --- /dev/null +++ b/app/javascript/mastodon/components/status/hooks.ts @@ -0,0 +1,262 @@ +import { useCallback, useMemo } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { useHistory } from 'react-router'; + +import { mentionComposeById } from '@/mastodon/actions/compose'; +import type { StatusInteractionIntent } from '@/mastodon/actions/interactions_typed'; +import { statusInteraction } from '@/mastodon/actions/interactions_typed'; +import { openModal } from '@/mastodon/actions/modal'; +import { toggleStatusSpoilers } from '@/mastodon/actions/statuses'; +import { useExpandedStatus } from '@/mastodon/hooks/useStatus'; +import { useToggle } from '@/mastodon/hooks/useToggle'; +import type { ExpandedStatusShape } from '@/mastodon/models/status'; +import { selectStatusFilters } from '@/mastodon/selectors/filters'; +import { useAppSelector, useAppDispatch } from '@/mastodon/store'; + +import { FOCUS_TARGET } from '../navigation_focus_target'; + +import type { StatusContextType } from './types'; + +const messages = defineMessages({ + quote_noun: { + id: 'status.quote_noun', + defaultMessage: 'Quote', + description: 'Quote as a noun', + }, + contains_quote: { + id: 'status.contains_quote', + defaultMessage: 'Contains quote', + }, + boosted: { id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, +}); + +export function useStatusHandlers({ + status, + contextType, + onClick, +}: { + status?: ExpandedStatusShape; + contextType?: StatusContextType; + onClick?: () => void; +}) { + const matchedFilters = useAppSelector((state) => + selectStatusFilters(state, {}), + ); + const [showDespiteFilter, { onToggle: onFilterToggle }] = useToggle(false); + + const dispatch = useAppDispatch(); + const statusId = status?.id; + + // Display handlers + const onExpandedToggle = useCallback(() => { + dispatch(toggleStatusSpoilers(statusId)); + }, [dispatch, statusId]); + + const onToggleHidden = useCallback(() => { + if (!status) { + return; + } + if (!matchedFilters.length || showDespiteFilter) { + dispatch(toggleStatusSpoilers(status.id)); + } + + if (!status.hidden || !status.spoiler_text) { + onFilterToggle(); + } + }, [ + dispatch, + matchedFilters.length, + onFilterToggle, + showDespiteFilter, + status, + ]); + + // Interaction handlers + const handlerFactory = useCallback( + (intent: StatusInteractionIntent) => { + return () => { + dispatch(statusInteraction({ statusId, intent, contextType })); + }; + }, + [contextType, dispatch, statusId], + ); + + const accountId = status?.account.id; + const onMention = useCallback(() => { + dispatch(mentionComposeById(accountId)); + }, [dispatch, accountId]); + + // Navigation handlers + const history = useHistory(); + + const onOpen = useCallback( + (newTab = false) => { + if (onClick || !status) { + onClick?.(); + return; + } + + const path = `/@${status.account.acct}/${status.id}`; + + if (newTab) { + window.open(path, '_blank', 'noopener'); + } else if (history.location.pathname.replace('/deck/', '/') === path) { + history.replace(path, { focusTarget: FOCUS_TARGET.POST }); + } else { + history.push(path, { focusTarget: FOCUS_TARGET.POST }); + } + }, + [history, onClick, status], + ); + + const onOpenClick: React.MouseEventHandler = useCallback( + (event) => { + event.preventDefault(); + + if (event.button === 0 && !(event.ctrlKey || event.metaKey)) { + onOpen(); + } else if ( + event.button === 1 || + (event.button === 0 && (event.ctrlKey || event.metaKey)) + ) { + onOpen(true); + } + }, + [onOpen], + ); + + const onHeaderClick: React.MouseEventHandler = useCallback( + (event) => { + // Only handle clicks on the empty space above the content + if (event.target !== event.currentTarget && event.detail >= 1) { + return; + } + + onOpenClick(event); + }, + [onOpenClick], + ); + + const acct = status?.account.acct; + const onOpenProfile = useCallback(() => { + if (acct) { + history.push(`/@${acct}`); + } + }, [history, acct]); + + const onOpenMedia = useCallback(() => { + const attachment = status?.media_attachments[0]; + if (!attachment) { + return; + } + + const lang = status.translation?.language ?? status.language; + if (attachment.type === 'video') { + dispatch( + openModal({ + modalType: 'VIDEO', + modalProps: { + statusId: status.id, + media: attachment, + lang, + options: { startTime: 0 }, + }, + }), + ); + } else { + dispatch( + openModal({ + modalType: 'MEDIA', + modalProps: { + statusId: status.id, + media: status.media_attachments, + lang, + index: 0, + }, + }), + ); + } + }, [dispatch, status]); + + return useMemo( + () => ({ + showDespiteFilter, + onOpenClick, + onExpandedToggle, + onFilterToggle, + onHeaderClick, + onMention, + onOpen, + onOpenMedia, + onOpenProfile, + onToggleHidden, + onReply: handlerFactory('reply'), + onFavourite: handlerFactory('favourite'), + onBoost: handlerFactory('reblog'), + onQuote: handlerFactory('quote'), + onTranslate: handlerFactory('translate'), + }), + [ + handlerFactory, + onExpandedToggle, + onFilterToggle, + onHeaderClick, + onMention, + onOpen, + onOpenClick, + onOpenMedia, + onOpenProfile, + onToggleHidden, + showDespiteFilter, + ], + ); +} +export type StatusHandlers = ReturnType; + +const domParser = new DOMParser(); +export function useTextForScreenReader({ + statusId, + reblogAcct, + isQuote = false, +}: { + statusId?: string | null; + reblogAcct?: string; + isQuote?: boolean; +}) { + const intl = useIntl(); + const status = useExpandedStatus(statusId); + return useMemo(() => { + if (!status) { + return ''; + } + const displayName = status.account.display_name; + + const spoilerText = status.translation?.spoiler_text ?? status.spoiler_text; + const contentHtml = status.translation?.contentHtml ?? status.contentHtml; + const contentText = domParser.parseFromString(contentHtml, 'text/html') + .documentElement.textContent; + + const values = [ + isQuote ? intl.formatMessage(messages.quote_noun) : undefined, + displayName.length === 0 + ? status.account.acct.split('@')[0] + : displayName, + spoilerText && status.hidden ? spoilerText : contentText, + status.quote ? intl.formatMessage(messages.contains_quote) : undefined, + intl.formatDate(status.created_at, { + hour: '2-digit', + minute: '2-digit', + month: 'short', + day: 'numeric', + }), + status.account.acct, + reblogAcct + ? intl.formatMessage(messages.boosted, { name: reblogAcct }) + : false, + ].filter((val) => !!val); + + return values.join(', '); + }, [intl, isQuote, reblogAcct, status]); +} diff --git a/app/javascript/mastodon/components/status/prepend.tsx b/app/javascript/mastodon/components/status/prepend.tsx new file mode 100644 index 00000000000..d69eb8b621c --- /dev/null +++ b/app/javascript/mastodon/components/status/prepend.tsx @@ -0,0 +1,67 @@ +import { FormattedMessage } from 'react-intl'; + +import type { ExpandedStatusShape } from '@/mastodon/models/status'; +import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; +import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; + +import { LinkedDisplayName } from '../display_name'; +import { Icon } from '../icon'; +import { StatusThreadLabel } from '../status_thread_label'; + +export const StatusPrepend: React.FC<{ + status: ExpandedStatusShape; + showThread?: boolean; + isReblog?: boolean; +}> = ({ status, showThread, isReblog }) => { + if (isReblog) { + return ( +
+
+ +
+ + ), + }} + tagName='span' + /> +
+ ); + } + + if (status.visibility === 'direct') { + return ( +
+
+ +
+ +
+ ); + } + + if (showThread && status.in_reply_to_account_id) { + return ( + + ); + } + + return null; +}; diff --git a/app/javascript/mastodon/components/status/status.tsx b/app/javascript/mastodon/components/status/status.tsx new file mode 100644 index 00000000000..d5ff4616dee --- /dev/null +++ b/app/javascript/mastodon/components/status/status.tsx @@ -0,0 +1,353 @@ +import type React from 'react'; +import { useCallback, useMemo } from 'react'; + +import classNames from 'classnames'; + +import type { Merge } from 'type-fest'; + +import { selectPlainAccount } from '@/mastodon/selectors/accounts'; +import { selectStatusFilters } from '@/mastodon/selectors/filters'; +import { selectExpandedStatus } from '@/mastodon/selectors/statuses'; +import { createAppSelector, useAppSelector } from '@/mastodon/store'; + +import { ContentWarning } from '../content_warning'; +import { FilterWarning } from '../filter_warning'; +import { computeHashtagBarForStatus, HashtagBar } from '../hashtag_bar'; +import { Hotkeys } from '../hotkeys'; + +import { StatusActionBar } from './action_bar'; +import { StatusAttachments } from './attachments'; +import { StatusContent } from './content'; +import { StatusHeader } from './header'; +import type { StatusHandlers } from './hooks'; +import { useStatusHandlers, useTextForScreenReader } from './hooks'; +import { StatusPrepend } from './prepend'; +import type { StatusContainerProps, StatusContextType } from './types'; + +type StatusRedesignProps = Merge< + Omit, + { + accountId?: string; + contextType?: StatusContextType; + onClick?: () => void; + } +>; + +const selectStatusReblog = createAppSelector( + [(state, id?: string | null) => selectExpandedStatus(state, id ?? undefined)], + (status) => { + if (!status) { + return {}; + } + if (!status.reblog) { + return { status }; + } + + const { reblog, ...statusRest } = status; + return { + status: reblog, + parent: statusRest, + }; + }, +); + +export const StatusRedesign: React.FC = ({ + id, + muted, + rootId, + previousId, + nextId, + unread, + skipPrepend, + unfocusable, + contextType, + featured, + isQuotedPost, + accountId, + hidden, + shouldHighlightOnMount, + showActions, + scrollKey, + children, + headerRenderFn, + avatarSize, + withCounters, + withDismiss, + onClick, + showThread, +}) => { + // Select data from store + const { status, parent } = useAppSelector((state) => + selectStatusReblog(state, id), + ); + const account = useAppSelector( + (state) => + parent?.account ?? selectPlainAccount(state, accountId) ?? undefined, + ); + const matchedFilters = useAppSelector((state) => + selectStatusFilters(state, { contextType, statusId: parent?.id ?? id }), + ); + const statusId = status?.id; + + // Display + const screenReaderText = useTextForScreenReader({ + statusId, + reblogAcct: parent?.account.acct, + isQuote: isQuotedPost, + }); + const { statusContent, hashtagsInBar } = useMemo( + (): Partial> => + status ? computeHashtagBarForStatus(status) : {}, + [status], + ); + + // Handlers + const { + showDespiteFilter, + onHeaderClick, + onExpandedToggle, + onFilterToggle, + onOpenClick, + onTranslate, + ...handlers + } = useStatusHandlers({ status, contextType, onClick }); + + if (!status) { + return null; // loading state + } + + const actualStatus = parent ?? status; + + const expanded = + (matchedFilters.length === 0 || showDespiteFilter) && + (!status.hidden || !status.spoiler_text); + + const hotkeysProps = { + ...handlers, + onTranslate, + muted, + unfocusable, + } satisfies Omit, 'children'>; + + if (hidden) { + return ( + +
+ {status.account.display_name || status.account.username} + {status.spoiler_text && {status.spoiler_text}} + {expanded && {status.content}} +
+
+ ); + } + + const header = headerRenderFn ? ( + headerRenderFn({ + statusId: status.id, + account, + avatarSize, + onHeaderClick, + featured, + }) + ) : ( + + ); + + return ( + +
+ {!skipPrepend && ( + + )} + + {header} + + {matchedFilters.length > 0 && ( + filter.title).join(', ')} + expanded={showDespiteFilter} + onClick={onFilterToggle} + /> + )} + + {(matchedFilters.length === 0 || showDespiteFilter) && ( + + )} + + {expanded && ( + <> + + + + + {hashtagsInBar && ( + + )} + + {children} + + )} + + {showActions && !isQuotedPost && ( + + )} + +
+
+ ); +}; + +const StatusHotkeys: React.FC< + { + muted?: boolean; + unfocusable?: boolean; + children: React.ReactNode; + } & Omit< + StatusHandlers, + | 'showDespiteFilter' + | 'onOpenClick' + | 'onHeaderClick' + | 'onExpandedToggle' + | 'onFilterToggle' + > +> = ({ muted, unfocusable, children, ...handlers }) => { + const onOpen = useCallback(() => { + handlers.onOpen(); + }, [handlers]); + + if (muted) { + return children; + } + + return ( + + {children} + + ); +}; + +const StatusContentWrapper: React.FC< + Pick & { + statusId: string; + inReplyToId?: string; + className?: string; + } +> = ({ + statusId, + inReplyToId, + rootId, + previousId, + nextId, + className, + children, +}) => { + const nextInReplyToId = useAppSelector((state) => + nextId ? state.statuses.getIn([nextId, 'in_reply_to_id']) : null, + ); + const connectUp = !!previousId && previousId === inReplyToId; + const connectToRoot = !!rootId && rootId === inReplyToId; + const connectReply = !!nextInReplyToId && nextInReplyToId === statusId; + return ( +
+ {(connectReply || connectUp || connectToRoot) && ( +
+ )} + + {children} +
+ ); +}; diff --git a/app/javascript/mastodon/components/status/types.ts b/app/javascript/mastodon/components/status/types.ts index 5d440773b9d..acb8ecc6799 100644 --- a/app/javascript/mastodon/components/status/types.ts +++ b/app/javascript/mastodon/components/status/types.ts @@ -25,8 +25,9 @@ export interface StatusContainerProps { id?: string | null; account?: TAccount; children?: ReactNode; - previousId?: string; rootId?: string; + previousId?: string; + nextId?: string; onClick?: MouseEventHandler; muted?: boolean; hidden?: boolean; @@ -54,7 +55,7 @@ export const TypedStatusContainer = StatusContainer as ComponentType; // Taken from the Status component. -export interface StatusProps extends StatusContainerProps { +export interface StatusProps extends Omit { status: TStatus; nextInReplyToId?: string; onReply: (status: TStatus) => void; diff --git a/app/javascript/mastodon/hooks/useStatus.ts b/app/javascript/mastodon/hooks/useStatus.ts index faa4ec6695c..9bf799ed5c7 100644 --- a/app/javascript/mastodon/hooks/useStatus.ts +++ b/app/javascript/mastodon/hooks/useStatus.ts @@ -1,10 +1,13 @@ import { selectExpandedStatus, selectPlainStatus } from '../selectors/statuses'; import { useAppSelector } from '../store'; -export function useStatus(id: string) { +export function useStatus(id?: string | null) { return useAppSelector((state) => selectPlainStatus(state, id)); } -export function useExpandedStatus(id: string) { - return useAppSelector((state) => selectExpandedStatus(state, id)); +/** Adds reblog status and account information to standard Status */ +export function useExpandedStatus(id?: string | null) { + return useAppSelector((state) => + selectExpandedStatus(state, id ?? undefined), + ); } diff --git a/app/javascript/mastodon/models/status.ts b/app/javascript/mastodon/models/status.ts index 9b54844e672..3cafde1d0d7 100644 --- a/app/javascript/mastodon/models/status.ts +++ b/app/javascript/mastodon/models/status.ts @@ -50,6 +50,7 @@ export interface StatusShape { collapsed: boolean | null; uri: string; url: string | null; + isLoading?: boolean; // Content content: string; diff --git a/app/javascript/mastodon/selectors/filters.ts b/app/javascript/mastodon/selectors/filters.ts index 0520c768c3f..1436e358a27 100644 --- a/app/javascript/mastodon/selectors/filters.ts +++ b/app/javascript/mastodon/selectors/filters.ts @@ -1,8 +1,10 @@ -import { createSelector } from '@reduxjs/toolkit'; - -import type { RootState } from 'mastodon/store'; +import { createAppSelector } from 'mastodon/store'; import { toServerSideType } from 'mastodon/utils/filters'; +import type { StatusContextType } from '../components/status/types'; + +import { selectExpandedStatus } from './statuses'; + export interface FilterShape { id: string; title: string; @@ -17,9 +19,9 @@ type Filter = Immutable.Map; // TODO: move to `app/javascript/mastodon/models` and use more globally type FilterResult = Immutable.Map; -export const getFilters = createSelector( +export const getFilters = createAppSelector( [ - (state: RootState) => state.filters as Immutable.Map, + (state) => state.filters as Immutable.Map, (_, { contextType }: { contextType?: string }) => contextType, ], (filters, contextType) => { @@ -41,18 +43,93 @@ export const getFilters = createSelector( }, ); -export const getStatusHidden = ( - state: RootState, - { id, contextType }: { id: string; contextType: string }, -) => { - const filters = getFilters(state, { contextType }); - if (filters === null) return false; +export const selectPlainFilters = createAppSelector([getFilters], (filters) => { + if (!filters) { + return null; + } + return filters.toJS() as unknown as Record; +}); - const filtered = state.statuses.getIn([id, 'filtered']) as - | Immutable.List - | undefined; - return filtered?.some( - (result) => - filters.getIn([result.get('filter'), 'filter_action']) === 'hide', - ); -}; +export const selectStatusFilters = createAppSelector( + [ + (state, { statusId }: { statusId?: string | null }) => + selectExpandedStatus(state, statusId ?? undefined), + selectPlainFilters, + (_, { warnInsteadOfHide }: { warnInsteadOfHide?: boolean }) => + warnInsteadOfHide, + ], + (status, filters) => { + const results: FilterShape[] = []; + if (!status || !filters) { + return results; + } + const filtered = status.reblog?.filtered ?? status.filtered; + for (const result of filtered) { + const filter = filters[result.filter]; + if (!filter) { + continue; + } + + results.push(filter); + } + + return results; + }, +); + +export const selectStatusLoadingState = createAppSelector( + [ + (state, { statusId }: { statusId?: string | null }) => + selectExpandedStatus(state, statusId ?? undefined), + selectStatusFilters, + (_, { warnInsteadOfHide }: { warnInsteadOfHide?: boolean }) => + warnInsteadOfHide, + ], + (status, filters, warnInsteadOfHide) => { + if (!status) { + return { state: 'not-found', status: null }; + } + + if (status.isLoading) { + return { state: 'loading', status: null }; + } + + if ( + !warnInsteadOfHide && + filters.some((filter) => filter.filter_action === 'hide') + ) { + return { state: 'filtered', status: null }; + } + + return { state: 'loaded', status }; + }, +); + +export const selectMediaFilters = createAppSelector( + [selectStatusFilters], + (filters) => + filters + .filter((filter) => filter.filter_action === 'blur') + .map((filter) => filter.title), +); + +export const getStatusHidden = createAppSelector( + [ + (state, { contextType }: { contextType: StatusContextType }) => + getFilters(state, { contextType }), + (state, { id }: { id: string }) => + state.statuses.getIn([id, 'filtered']) as + | Immutable.List + | undefined, + ], + (filters, filtered) => { + if (!filters) { + return false; + } + + return filtered?.some( + (result) => + filters.getIn([result.get('filter'), 'filter_action']) === 'hide', + ); + }, +); diff --git a/app/javascript/mastodon/selectors/statuses.ts b/app/javascript/mastodon/selectors/statuses.ts index 544e9d110fa..00ac0f3dd98 100644 --- a/app/javascript/mastodon/selectors/statuses.ts +++ b/app/javascript/mastodon/selectors/statuses.ts @@ -8,8 +8,6 @@ import type { import { createAppSelector } from '@/mastodon/store/typed_functions'; import { selectIsAccountLocal, selectPlainAccount } from './accounts'; -import type { FilterShape } from './filters'; -import { getFilters } from './filters'; export const getStatusList = createAppSelector( [ @@ -20,7 +18,7 @@ export const getStatusList = createAppSelector( ); export const selectPlainStatus = createAppSelector( - [(state, statusId: string) => state.statuses.get(statusId)], + [(state, statusId?: string | null) => state.statuses.get(statusId ?? '')], (status) => { if (!status) { return null; @@ -159,6 +157,7 @@ export const selectStatusInteractions = createAppSelector( reply: addAllowed({ isLoggedIn }), report: addAllowed({ isLoggedIn, isNotMine }), revokeQuote: addAllowed({ isQuoted, isNotMine }), + translate: addAllowed({ isLoggedIn }), }; return { @@ -202,26 +201,3 @@ export const selectPictureInPicture = createAppSelector( available, }), ); - -export const selectMediaMatchFilters = createAppSelector( - [ - (state, { statusId }: { statusId: string }) => - selectPlainStatus(state, statusId), - getFilters, - ], - (status, immutableFilters) => { - const filters = immutableFilters - ? (immutableFilters.toJS() as unknown as Record) - : null; - const mediaFilters: string[] = []; - if (status?.filtered && filters) { - for (const { filter } of status.filtered) { - if (filters[filter]?.filter_action === 'blur') { - mediaFilters.push(filters[filter].title); - } - } - } - - return mediaFilters; - }, -);