diff --git a/app/javascript/mastodon/components/display_name/default.tsx b/app/javascript/mastodon/components/display_name/default.tsx index 552b5303f00..092cddca74b 100644 --- a/app/javascript/mastodon/components/display_name/default.tsx +++ b/app/javascript/mastodon/components/display_name/default.tsx @@ -17,7 +17,7 @@ const messages = defineMessages({ export function useAccountHandle( account: DisplayNameProps['account'], - localDomain: DisplayNameProps['localDomain'], + localDomain?: DisplayNameProps['localDomain'], ) { const intl = useIntl(); diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 3112d352fb7..d97c207a933 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -294,8 +294,8 @@ class Status extends ImmutablePureComponent { }; _openStatus = (newTab = false) => { - if (this.props.onClick) { - this.props.onClick(); + if (this.props.onOpen) { + this.props.onOpen(); return; } diff --git a/app/javascript/mastodon/components/status/action_bar.tsx b/app/javascript/mastodon/components/status/action_bar.tsx index e0060628d16..d92a3775f67 100644 --- a/app/javascript/mastodon/components/status/action_bar.tsx +++ b/app/javascript/mastodon/components/status/action_bar.tsx @@ -1,7 +1,16 @@ import type React from 'react'; import { useCallback, useMemo } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { + ArrowsClockwiseIcon, + BookmarkSimpleIcon, + ChatCircleTextIcon, + DotsThreeIcon, + HeartIcon, + ShareFatIcon, +} from '@phosphor-icons/react'; import { muteAccount, @@ -40,20 +49,13 @@ import { } from '@/mastodon/selectors/statuses'; import type { AppDispatch } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; -import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react'; -import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react'; -import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; -import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; -import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; -import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; -import StarBorderIcon from '@/material-icons/400-24px/star.svg?react'; +import { Button, IconButton } from '../button/redesign'; import { Dropdown } from '../dropdown_menu'; -import { IconButton } from '../icon_button'; import { RemoveQuoteHint } from '../status_action_bar/remove_quote_hint'; -import { BoostButton } from './boost_button'; import { quoteItemState } from './boost_button_utils'; +import classes from './styles.module.scss'; import type { StatusContextType } from './types'; interface StatusActionBarProps { @@ -74,18 +76,12 @@ const messages = defineMessages({ block: { id: 'account.block', defaultMessage: 'Block @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, share: { id: 'status.share', defaultMessage: 'Share' }, - more: { id: 'status.more', defaultMessage: 'More' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites', }, - bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, - removeBookmark: { - id: 'status.remove_bookmark', - defaultMessage: 'Remove bookmark', - }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' }, muteConversation: { @@ -150,11 +146,32 @@ export const StatusActionBar: React.FC = ({ state.statuses.getIn([status?.quote?.quoted_status, 'account']) ?? null, ); const currentAccountId = useCurrentAccountId(); + const statusUrl = status?.url ?? status?.uri; + // Actions const dispatch = useAppDispatch(); const handleReplyClick = useCallback(() => { dispatch(statusInteraction({ statusId, intent: 'reply' })); }, [dispatch, statusId]); + const handleBoostClick = useCallback(() => { + dispatch(statusInteraction({ statusId, intent: 'reblog' })); + }, [dispatch, statusId]); + const handleShareClick = useCallback(() => { + if (!statusUrl) { + return; + } + + // We need to make this partial as by default share always is set, despite not being supported in FF. + const nav = navigator as Partial> & + Pick; + if (nav.share) { + void nav.share({ + url: statusUrl, + }); + } else { + void nav.clipboard.writeText(statusUrl); + } + }, [statusUrl]); const handleFavouriteClick = useCallback(() => { dispatch(statusInteraction({ statusId, intent: 'favourite' })); }, [dispatch, statusId]); @@ -168,17 +185,14 @@ export const StatusActionBar: React.FC = ({ return null; } + const isPublic = + status.visibility === 'public' || status.visibility === 'unlisted'; const isReply = !status.in_reply_to_id || status.in_reply_to_account_id === status.account; const replyTitle = isReply ? intl.formatMessage(messages.reply) : intl.formatMessage(messages.replyAll); - const replyIcon = isReply ? 'reply' : 'reply-all'; - const replyIconComponent = isReply ? ReplyIcon : ReplyAllIcon; - const bookmarkTitle = intl.formatMessage( - status.bookmarked ? messages.removeBookmark : messages.bookmark, - ); const favouriteTitle = intl.formatMessage( status.favourited ? messages.removeFavourite : messages.favourite, ); @@ -188,43 +202,64 @@ export const StatusActionBar: React.FC = ({ isQuotingMe && contextType === 'notifications'; return ( -
-
+
+ + + + + + + {isPublic && ( -
-
- -
-
- -
-
- -
+ size='sm' + variant='ghost' + icon={ShareFatIcon} + onClick={handleShareClick} + > + + + )} + + + {!status.bookmarked ? ( + + ) : ( + + )} + + - + + + ); }; diff --git a/app/javascript/mastodon/components/status/hooks.ts b/app/javascript/mastodon/components/status/hooks.ts index 4e6fa32e52f..2270e144d49 100644 --- a/app/javascript/mastodon/components/status/hooks.ts +++ b/app/javascript/mastodon/components/status/hooks.ts @@ -39,11 +39,11 @@ const messages = defineMessages({ export function useStatusHandlers({ status, contextType, - onClick, + onOpen, }: { status?: ExpandedStatusShape; contextType?: StatusContextType; - onClick?: () => void; + onOpen?: () => void; }) { const matchedFilters = useAppSelector((state) => selectStatusFilters(state, { contextType, statusId: status?.id }), @@ -95,10 +95,10 @@ export function useStatusHandlers({ // Navigation handlers const history = useHistory(); - const onOpen = useCallback( + const onOpenCallback = useCallback( (newTab = false) => { - if (onClick || !status) { - onClick?.(); + if (onOpen || !status) { + onOpen?.(); return; } @@ -112,7 +112,7 @@ export function useStatusHandlers({ history.push(path, { focusTarget: FOCUS_TARGET.POST }); } }, - [history, onClick, status], + [history, onOpen, status], ); const onOpenClick: React.MouseEventHandler = useCallback( @@ -120,15 +120,15 @@ export function useStatusHandlers({ event.preventDefault(); if (event.button === 0 && !(event.ctrlKey || event.metaKey)) { - onOpen(); + onOpenCallback(); } else if ( event.button === 1 || (event.button === 0 && (event.ctrlKey || event.metaKey)) ) { - onOpen(true); + onOpenCallback(true); } }, - [onOpen], + [onOpenCallback], ); const onHeaderClick: React.MouseEventHandler = useCallback( @@ -192,7 +192,9 @@ export function useStatusHandlers({ onFilterToggle, onHeaderClick, onMention, - onOpen, + onOpen: () => { + onOpenCallback(); + }, onOpenMedia, onOpenProfile, onToggleHidden, @@ -208,7 +210,7 @@ export function useStatusHandlers({ onFilterToggle, onHeaderClick, onMention, - onOpen, + onOpenCallback, onOpenClick, onOpenMedia, onOpenProfile, diff --git a/app/javascript/mastodon/components/status/redesign/header.tsx b/app/javascript/mastodon/components/status/redesign/header.tsx new file mode 100644 index 00000000000..da7f0021766 --- /dev/null +++ b/app/javascript/mastodon/components/status/redesign/header.tsx @@ -0,0 +1,86 @@ +import { useId } from 'react'; + +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; + +import type { AccountStatusShape } from '@/mastodon/models/status'; + +import { Avatar } from '../../avatar'; +import { DisplayName } from '../../display_name'; +import { useAccountHandle } from '../../display_name/default'; +import { RelativeTimestamp } from '../../relative_timestamp'; +import { Skeleton } from '../../skeleton'; +import { statusLink } from '../utils'; + +import classes from './styles.module.scss'; + +interface StatusRedesignHeaderProps { + status: Pick; + children?: React.ReactNode; + className?: string; + avatarSize?: number; +} + +export const StatusRedesignHeader: React.FC = ({ + status, + children, + className, + avatarSize = 40, +}) => { + const account = status.account; + const handle = useAccountHandle(account); + + const handleId = useId(); + const accountLinkProps = { + to: { + pathname: `/@${account.acct}`, + state: { reference: 'status' }, + }, + title: `@${account.acct}`, + 'data-id': account.id, + 'data-hover-card-account': account.id, + 'data-hover-card-reference': 'status', + }; + + return ( +
+ + + + +
+

+ + + + • + + + +

+ +

+ + {handle ?? } + +

+
+ + {children} +
+ ); +}; diff --git a/app/javascript/mastodon/components/status/redesign/styles.module.scss b/app/javascript/mastodon/components/status/redesign/styles.module.scss new file mode 100644 index 00000000000..9e644e5079d --- /dev/null +++ b/app/javascript/mastodon/components/status/redesign/styles.module.scss @@ -0,0 +1,50 @@ +@use '@/styles/mastodon/mixins'; + +// Header + +.header { + display: flex; + align-items: center; + gap: var(--space-xs); + color: var(--color-text-secondary); + + a { + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + // When hovering over any link with the account ID, underline them all. + &:has(a[data-id]:hover) a[data-id] { + text-decoration: underline; + } +} + +.headerNameWrapper { + flex-grow: 1; +} + +.headerName { + @include mixins.type-body; + + display: flex; + gap: var(--space-2xs); + + > .headerNameLink { + @include mixins.type-body-strong; + + color: var(--color-text-primary); + + // Overrides .display-name__html + strong { + font-weight: inherit; + } + } +} + +.headerHandle { + @include mixins.type-label-md; +} diff --git a/app/javascript/mastodon/components/status/status.stories.tsx b/app/javascript/mastodon/components/status/status.stories.tsx index 197bb5668fe..ce30f9e42b8 100644 --- a/app/javascript/mastodon/components/status/status.stories.tsx +++ b/app/javascript/mastodon/components/status/status.stories.tsx @@ -6,7 +6,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import type { StatusVisibility } from '@/mastodon/api_types/statuses'; -import { useAppSelector } from '@/mastodon/store'; import { accountFactoryImmutable, pollFactoryImmutable, @@ -14,10 +13,10 @@ import { statusFactoryImmutable, } from '@/testing/factories'; +import { StatusRedesign } from './status'; import type { AttachmentArgs } from './testing'; import { attachmentArgTypes, attachmentFactory } from './testing'; import type { StatusContextType } from './types'; -import { TypedStatus } from './types'; interface StatusStoryProps extends AttachmentArgs { // Contents @@ -60,12 +59,10 @@ const StatusStoryComponent: FC = (props) => { const { isReblog, isReply, - isPoll, isQuote, contentWarning, hasFilter, - hasVoted, disableActions = false, contextType, @@ -75,53 +72,25 @@ const StatusStoryComponent: FC = (props) => { muted, showPrepend = true, } = props; - const account = useAppSelector((state) => state.accounts.get('1')); - const status = useAppSelector((state) => - state.statuses.get('1')?.withMutations((status) => { - status.set('account', account); - status.set('matched_filters', hasFilter ? ['test'] : false); - status.set('matched_media_filters', hasFilter ? ['test'] : false); - status.set('hidden', hidden); - - // StatusActionBar checks specifically for null so undefined doesn't work. - if (!status.get('in_reply_to_id')) { - status.set('in_reply_to_id', null); - } - - if (isReblog) { - status.set( - 'reblog', - statusFactoryImmutable({ id: '2' }).set('account', otherAccount), - ); - } - if (isPoll) { - status.set('poll', hasVoted ? '2' : '1'); - } - }), - ); - return ( -
-
+