diff --git a/app/javascript/images/icons/icon_pinned.svg b/app/javascript/images/icons/icon_pinned.svg new file mode 100644 index 00000000000..90eaa6c933f --- /dev/null +++ b/app/javascript/images/icons/icon_pinned.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index d97885bad12..c6c2a864a8f 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -27,10 +27,12 @@ export const TIMELINE_INSERT = 'TIMELINE_INSERT'; // When adding new special markers here, make sure to update TIMELINE_NON_STATUS_MARKERS in actions/timelines_typed.js export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions'; export const TIMELINE_GAP = null; +export const TIMELINE_PINNED_VIEW_ALL = 'pinned-view-all'; export const TIMELINE_NON_STATUS_MARKERS = [ TIMELINE_GAP, TIMELINE_SUGGESTIONS, + TIMELINE_PINNED_VIEW_ALL, ]; export const loadPending = timeline => ({ diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 88b61682293..fd2054b066f 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -109,6 +109,7 @@ class Status extends ImmutablePureComponent { muted: PropTypes.bool, hidden: PropTypes.bool, unread: PropTypes.bool, + featured: PropTypes.bool, showThread: PropTypes.bool, showActions: PropTypes.bool, isQuotedPost: PropTypes.bool, @@ -557,7 +558,7 @@ class Status extends ImmutablePureComponent { const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); const header = this.props.headerRenderFn - ? this.props.headerRenderFn({ status, account, avatarSize, messages, onHeaderClick: this.handleHeaderClick, statusProps: this.props }) + ? this.props.headerRenderFn({ status, account, avatarSize, messages, onHeaderClick: this.handleHeaderClick, featured }) : ( ; displayNameProps?: DisplayNameProps; onHeaderClick?: MouseEventHandler; + className?: string; + featured?: boolean; } -export type StatusHeaderRenderFn = ( - args: StatusHeaderProps, - statusProps?: StatusProps, -) => ReactNode; +export type StatusHeaderRenderFn = (args: StatusHeaderProps) => ReactNode; export const StatusHeader: FC = ({ status, account, children, + className, avatarSize = 48, wrapperProps, onHeaderClick, @@ -49,7 +48,7 @@ export const StatusHeader: FC = ({ onClick={onHeaderClick} onAuxClick={onHeaderClick} {...wrapperProps} - className='status__info' + className={classNames('status__info', className)} /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ > ); } @@ -90,16 +93,21 @@ export default class StatusList extends ImmutablePureComponent { ) : null; if (scrollableContent && featuredStatusIds) { - scrollableContent = featuredStatusIds.map(statusId => ( - - )).concat(scrollableContent); + scrollableContent = featuredStatusIds.map(statusId => { + if (statusId === TIMELINE_PINNED_VIEW_ALL) { + return + } + return ( + + ); + }).concat(scrollableContent); } return ( diff --git a/app/javascript/mastodon/features/account_timeline/components/badges.tsx b/app/javascript/mastodon/features/account_timeline/components/badges.tsx index 9e6d020c9a3..09335cee88f 100644 --- a/app/javascript/mastodon/features/account_timeline/components/badges.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/badges.tsx @@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import IconPinned from '@/images/icons/icon_pinned.svg?react'; import { fetchRelationships } from '@/mastodon/actions/accounts'; import { AdminBadge, @@ -14,6 +15,7 @@ import { GroupBadge, MutedBadge, } from '@/mastodon/components/badge'; +import { Icon } from '@/mastodon/components/icon'; import { useAccount } from '@/mastodon/hooks/useAccount'; import type { AccountRole } from '@/mastodon/models/account'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; @@ -119,6 +121,16 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => { return
{badges}
; }; +export const PinnedBadge: FC = () => ( + } + label={ + + } + /> +); + function isAdminBadge(role: AccountRole) { const name = role.name.toLowerCase(); return isRedesignEnabled() && (name === 'admin' || name === 'owner'); diff --git a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss index 944f6f7a7a0..6f71a5ae89c 100644 --- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss @@ -296,6 +296,11 @@ svg.badgeIcon { text-decoration: none; color: var(--color-text-primary); border-radius: 0; + transition: color 0.2s ease-in-out; + + &:not([aria-current='page']):is(:hover, :focus) { + color: var(--color-text-brand-soft); + } } :global(.active) { diff --git a/app/javascript/mastodon/features/account_timeline/v2/index.tsx b/app/javascript/mastodon/features/account_timeline/v2/index.tsx index c0a4cf57354..ff5783903cf 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/index.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/index.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react'; import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; import { useParams } from 'react-router'; import { List as ImmutableList } from 'immutable'; @@ -13,7 +14,6 @@ import { } from '@/mastodon/actions/timelines_typed'; import { Column } from '@/mastodon/components/column'; import { ColumnBackButton } from '@/mastodon/components/column_back_button'; -import { FeaturedCarousel } from '@/mastodon/components/featured_carousel'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { RemoteHint } from '@/mastodon/components/remote_hint'; import StatusList from '@/mastodon/components/status_list'; @@ -29,6 +29,12 @@ import { useFilters } from '../hooks/useFilters'; import { FeaturedTags } from './featured_tags'; import { AccountFilters } from './filters'; +import { + PinnedStatusProvider, + renderPinnedStatusHeader, + usePinnedStatusIds, +} from './pinned_statuses'; +import classes from './styles.module.scss'; const emptyList = ImmutableList(); @@ -50,11 +56,13 @@ const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { // Add this key to remount the timeline when accountId changes. return ( - + + + ); }; @@ -74,11 +82,14 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({ const timeline = useAppSelector((state) => selectTimelineByKey(state, key)); const { blockedBy, hidden, suspended } = useAccountVisibility(accountId); + const forceEmptyState = blockedBy || hidden || suspended; const dispatch = useAppDispatch(); useEffect(() => { - if (!timeline && !!accountId) { - dispatch(expandTimelineByKey({ key })); + if (accountId) { + if (!timeline) { + dispatch(expandTimelineByKey({ key })); + } } }, [accountId, dispatch, key, timeline]); @@ -91,7 +102,10 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({ [accountId, dispatch, key], ); - const forceEmptyState = blockedBy || hidden || suspended; + const { isLoading: isPinnedLoading, statusIds: pinnedStatusIds } = + usePinnedStatusIds({ accountId, tagged, forceEmptyState }); + + const isLoading = !!timeline?.isLoading || isPinnedLoading; return ( @@ -99,25 +113,22 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({ - } + prepend={} append={} scrollKey='account_timeline' // We want to have this component when timeline is undefined (loading), // because if we don't the prepended component will re-render with every filter change. statusIds={forceEmptyState ? emptyList : (timeline?.items ?? emptyList)} - isLoading={!!timeline?.isLoading} + featuredStatusIds={pinnedStatusIds} + isLoading={isLoading} hasMore={!forceEmptyState && !!timeline?.hasMore} onLoadMore={handleLoadMore} emptyMessage={} bindToDocument={!multiColumn} timelineId='account' withCounters + className={classNames(classes.statusWrapper)} + statusProps={{ headerRenderFn: renderPinnedStatusHeader }} /> ); @@ -125,9 +136,8 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({ const Prepend: FC<{ accountId: string; - tagged?: string; forceEmpty: boolean; -}> = ({ forceEmpty, accountId, tagged }) => { +}> = ({ forceEmpty, accountId }) => { if (forceEmpty) { return ; } @@ -137,7 +147,6 @@ const Prepend: FC<{ - ); }; diff --git a/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx b/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx new file mode 100644 index 00000000000..eec92cdc380 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx @@ -0,0 +1,146 @@ +import type { FC, ReactNode } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import IconPinned from '@/images/icons/icon_pinned.svg?react'; +import { TIMELINE_PINNED_VIEW_ALL } from '@/mastodon/actions/timelines'; +import { + expandTimelineByKey, + timelineKey, +} from '@/mastodon/actions/timelines_typed'; +import { Button } from '@/mastodon/components/button'; +import { Icon } from '@/mastodon/components/icon'; +import { StatusHeader } from '@/mastodon/components/status/header'; +import type { StatusHeaderRenderFn } from '@/mastodon/components/status/header'; +import { selectTimelineByKey } from '@/mastodon/selectors/timelines'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; + +import { isRedesignEnabled } from '../common'; +import { PinnedBadge } from '../components/badges'; + +import classes from './styles.module.scss'; + +const PinnedStatusContext = createContext<{ + showAllPinned: boolean; + onShowAllPinned: () => void; +}>({ + showAllPinned: false, + onShowAllPinned: () => { + throw new Error('No onShowAllPinned provided'); + }, +}); + +export const PinnedStatusProvider: FC<{ children: ReactNode }> = ({ + children, +}) => { + const [showAllPinned, setShowAllPinned] = useState(false); + const handleShowAllPinned = useCallback(() => { + setShowAllPinned(true); + }, []); + + // Memoize so the context doesn't change every render. + const value = useMemo( + () => ({ + showAllPinned, + onShowAllPinned: handleShowAllPinned, + }), + [handleShowAllPinned, showAllPinned], + ); + + return ( + + {children} + + ); +}; + +export function usePinnedStatusIds({ + accountId, + tagged, + forceEmptyState = false, +}: { + accountId: string; + tagged?: string; + forceEmptyState?: boolean; +}) { + const pinnedKey = timelineKey({ + type: 'account', + userId: accountId, + tagged, + pinned: true, + }); + + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch(expandTimelineByKey({ key: pinnedKey })); + }, [dispatch, pinnedKey]); + + const pinnedTimeline = useAppSelector((state) => + selectTimelineByKey(state, pinnedKey), + ); + + const { showAllPinned } = useContext(PinnedStatusContext); + + const pinnedTimelineItems = pinnedTimeline?.items; // Make a const to avoid the React Compiler complaining. + const pinnedStatusIds = useMemo(() => { + if (!pinnedTimelineItems || forceEmptyState) { + return undefined; + } + + if (pinnedTimelineItems.size <= 1 || showAllPinned) { + return pinnedTimelineItems; + } + return pinnedTimelineItems.slice(0, 1).push(TIMELINE_PINNED_VIEW_ALL); + }, [forceEmptyState, pinnedTimelineItems, showAllPinned]); + + return { + statusIds: pinnedStatusIds, + isLoading: !!pinnedTimeline?.isLoading, + showAllPinned, + }; +} + +export const renderPinnedStatusHeader: StatusHeaderRenderFn = ({ + featured, + ...args +}) => { + if (!featured) { + return ; + } + return ( + + + + ); +}; + +export const PinnedShowAllButton: FC = () => { + const { onShowAllPinned } = useContext(PinnedStatusContext); + + if (!isRedesignEnabled()) { + return null; + } + + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx b/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx new file mode 100644 index 00000000000..5f0ff886857 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx @@ -0,0 +1,52 @@ +import type { FC } from 'react'; + +import { Link } from 'react-router-dom'; + +import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp'; +import type { StatusHeaderProps } from '@/mastodon/components/status/header'; +import { + StatusDisplayName, + StatusEditedAt, + StatusVisibility, +} from '@/mastodon/components/status/header'; +import type { Account } from '@/mastodon/models/account'; + +export const AccountStatusHeader: FC = ({ + status, + account, + children, + avatarSize = 48, + wrapperProps, + onHeaderClick, +}) => { + const statusAccount = status.get('account') as Account | undefined; + const editedAt = status.get('edited_at') as string; + + return ( + /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ +
+ + + + {editedAt && } + + + + + {children} +
+ ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss index c35b46524e1..35bf3301661 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss +++ b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss @@ -10,6 +10,12 @@ font-weight: 500; display: flex; align-items: center; + transition: color 0.2s ease-in-out; + + &:hover, + &:focus { + color: var(--color-text-brand-soft); + } } .filterSelectIcon { @@ -57,3 +63,57 @@ overflow: visible; max-width: none !important; } + +.statusWrapper { + :global(.status) { + padding-left: 24px; + padding-right: 24px; + } + + &:has(.pinnedViewAllButton) :global(.status):has(.pinnedStatusHeader) { + border-bottom: none; + } + + article:has(.pinnedViewAllButton) { + border-bottom: 1px solid var(--color-border-primary); + } +} + +.pinnedViewAllButton { + background-color: var(--color-bg-primary); + border-radius: 8px; + border: 1px solid var(--color-border-primary); + box-sizing: border-box; + color: var(--color-text-primary); + line-height: normal; + margin: 12px 24px; + padding: 8px; + transition: border-color 0.2s ease-in-out; + width: calc(100% - 48px); + + &:hover, + &:focus { + background-color: inherit; + border-color: var(--color-bg-brand-base-hover); + } +} + +.pinnedStatusHeader { + display: grid; + grid-template-columns: max-content auto; + grid-template-rows: 1fr 1fr; + gap: 4px; + + > :global(.status__relative-time) { + grid-column: 2; + height: auto; + } + + > :global(.status__display-name) { + grid-row: span 2; + } + + > :global(.account-role) { + justify-self: end; + } +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 89ff879147d..a646d479d74 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -123,6 +123,8 @@ "account.share": "Share @{name}'s profile", "account.show_reblogs": "Show boosts from @{name}", "account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}", + "account.timeline.pinned": "Pinned", + "account.timeline.pinned.view_all": "View all pinned posts", "account.unblock": "Unblock @{name}", "account.unblock_domain": "Unblock domain {domain}", "account.unblock_domain_short": "Unblock",