Profile redesign: Pinned posts (#37761)
Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
parent
1310628a60
commit
2e30044a37
3
app/javascript/images/icons/icon_pinned.svg
Normal file
3
app/javascript/images/icons/icon_pinned.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.128 7.586a.666.666 0 0 0 0-.943l-3.771-3.77a.667.667 0 0 0-.943.942 1.333 1.333 0 0 1 0 1.885L6.641 7.473a2 2 0 0 1-2.046.48v.002l-1.263-.414-.004-.002a.668.668 0 0 0-.684.16l-.358.358 5.657 5.657.358-.357a.67.67 0 0 0 .16-.684l-.001-.004-.415-1.265a2.002 2.002 0 0 1 .482-2.045L10.3 7.586a1.333 1.333 0 0 1 1.885 0 .667.667 0 0 0 .943 0Zm.943.942a2 2 0 0 1-2.829 0L9.47 10.301l-.06.07a.666.666 0 0 0-.124.524l.024.09.001.004.416 1.263a1.999 1.999 0 0 1-.483 2.046l-.358.359a1.335 1.335 0 0 1-1.886 0L4.642 12.3l-1.885 1.885a.667.667 0 1 1-.942-.943L3.7 11.357 1.343 9.001a1.335 1.335 0 0 1 0-1.887l.358-.358a2 2 0 0 1 2.051-.481l1.26.414.003.001a.67.67 0 0 0 .614-.1l.07-.06L7.47 4.757A2 2 0 0 1 10.3 1.93l3.77 3.77a2 2 0 0 1 0 2.83Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 833 B |
@ -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
|
// 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_SUGGESTIONS = 'inline-follow-suggestions';
|
||||||
export const TIMELINE_GAP = null;
|
export const TIMELINE_GAP = null;
|
||||||
|
export const TIMELINE_PINNED_VIEW_ALL = 'pinned-view-all';
|
||||||
|
|
||||||
export const TIMELINE_NON_STATUS_MARKERS = [
|
export const TIMELINE_NON_STATUS_MARKERS = [
|
||||||
TIMELINE_GAP,
|
TIMELINE_GAP,
|
||||||
TIMELINE_SUGGESTIONS,
|
TIMELINE_SUGGESTIONS,
|
||||||
|
TIMELINE_PINNED_VIEW_ALL,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const loadPending = timeline => ({
|
export const loadPending = timeline => ({
|
||||||
|
|||||||
@ -109,6 +109,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
muted: PropTypes.bool,
|
muted: PropTypes.bool,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
unread: PropTypes.bool,
|
unread: PropTypes.bool,
|
||||||
|
featured: PropTypes.bool,
|
||||||
showThread: PropTypes.bool,
|
showThread: PropTypes.bool,
|
||||||
showActions: PropTypes.bool,
|
showActions: PropTypes.bool,
|
||||||
isQuotedPost: PropTypes.bool,
|
isQuotedPost: PropTypes.bool,
|
||||||
@ -557,7 +558,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||||
|
|
||||||
const header = this.props.headerRenderFn
|
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 })
|
||||||
: (
|
: (
|
||||||
<StatusHeader
|
<StatusHeader
|
||||||
status={status}
|
status={status}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { FC, HTMLAttributes, MouseEventHandler, ReactNode } from 'react';
|
|||||||
|
|
||||||
import { defineMessage, useIntl } from 'react-intl';
|
import { defineMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { isStatusVisibility } from '@/mastodon/api_types/statuses';
|
import { isStatusVisibility } from '@/mastodon/api_types/statuses';
|
||||||
@ -15,8 +16,6 @@ import { LinkedDisplayName } from '../display_name';
|
|||||||
import { RelativeTimestamp } from '../relative_timestamp';
|
import { RelativeTimestamp } from '../relative_timestamp';
|
||||||
import { VisibilityIcon } from '../visibility_icon';
|
import { VisibilityIcon } from '../visibility_icon';
|
||||||
|
|
||||||
import type { StatusProps } from './types';
|
|
||||||
|
|
||||||
export interface StatusHeaderProps {
|
export interface StatusHeaderProps {
|
||||||
status: Status;
|
status: Status;
|
||||||
account?: Account;
|
account?: Account;
|
||||||
@ -25,17 +24,17 @@ export interface StatusHeaderProps {
|
|||||||
wrapperProps?: HTMLAttributes<HTMLDivElement>;
|
wrapperProps?: HTMLAttributes<HTMLDivElement>;
|
||||||
displayNameProps?: DisplayNameProps;
|
displayNameProps?: DisplayNameProps;
|
||||||
onHeaderClick?: MouseEventHandler<HTMLDivElement>;
|
onHeaderClick?: MouseEventHandler<HTMLDivElement>;
|
||||||
|
className?: string;
|
||||||
|
featured?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StatusHeaderRenderFn = (
|
export type StatusHeaderRenderFn = (args: StatusHeaderProps) => ReactNode;
|
||||||
args: StatusHeaderProps,
|
|
||||||
statusProps?: StatusProps,
|
|
||||||
) => ReactNode;
|
|
||||||
|
|
||||||
export const StatusHeader: FC<StatusHeaderProps> = ({
|
export const StatusHeader: FC<StatusHeaderProps> = ({
|
||||||
status,
|
status,
|
||||||
account,
|
account,
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
avatarSize = 48,
|
avatarSize = 48,
|
||||||
wrapperProps,
|
wrapperProps,
|
||||||
onHeaderClick,
|
onHeaderClick,
|
||||||
@ -49,7 +48,7 @@ export const StatusHeader: FC<StatusHeaderProps> = ({
|
|||||||
onClick={onHeaderClick}
|
onClick={onHeaderClick}
|
||||||
onAuxClick={onHeaderClick}
|
onAuxClick={onHeaderClick}
|
||||||
{...wrapperProps}
|
{...wrapperProps}
|
||||||
className='status__info'
|
className={classNames('status__info', className)}
|
||||||
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
|
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export interface StatusProps {
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
unread?: boolean;
|
unread?: boolean;
|
||||||
|
featured?: boolean;
|
||||||
showThread?: boolean;
|
showThread?: boolean;
|
||||||
showActions?: boolean;
|
showActions?: boolean;
|
||||||
isQuotedPost?: boolean;
|
isQuotedPost?: boolean;
|
||||||
|
|||||||
@ -5,9 +5,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||||||
|
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
|
import { TIMELINE_GAP, TIMELINE_PINNED_VIEW_ALL, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
|
||||||
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
|
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
|
||||||
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
|
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
|
||||||
|
import { PinnedShowAllButton } from '@/mastodon/features/account_timeline/v2/pinned_statuses';
|
||||||
|
|
||||||
import { StatusQuoteManager } from '../components/status_quoted';
|
import { StatusQuoteManager } from '../components/status_quoted';
|
||||||
|
|
||||||
@ -35,6 +36,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||||||
timelineId: PropTypes.string,
|
timelineId: PropTypes.string,
|
||||||
lastId: PropTypes.string,
|
lastId: PropTypes.string,
|
||||||
bindToDocument: PropTypes.bool,
|
bindToDocument: PropTypes.bool,
|
||||||
|
statusProps: PropTypes.object,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@ -51,7 +53,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props;
|
const { statusIds, featuredStatusIds, onLoadMore, timelineId, statusProps, ...other } = this.props;
|
||||||
const { isLoading, isPartial } = other;
|
const { isLoading, isPartial } = other;
|
||||||
|
|
||||||
if (isPartial) {
|
if (isPartial) {
|
||||||
@ -83,6 +85,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||||||
scrollKey={this.props.scrollKey}
|
scrollKey={this.props.scrollKey}
|
||||||
showThread
|
showThread
|
||||||
withCounters={this.props.withCounters}
|
withCounters={this.props.withCounters}
|
||||||
|
{...statusProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -90,16 +93,21 @@ export default class StatusList extends ImmutablePureComponent {
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (scrollableContent && featuredStatusIds) {
|
if (scrollableContent && featuredStatusIds) {
|
||||||
scrollableContent = featuredStatusIds.map(statusId => (
|
scrollableContent = featuredStatusIds.map(statusId => {
|
||||||
<StatusQuoteManager
|
if (statusId === TIMELINE_PINNED_VIEW_ALL) {
|
||||||
key={`f-${statusId}`}
|
return <PinnedShowAllButton key={TIMELINE_PINNED_VIEW_ALL} />
|
||||||
id={statusId}
|
}
|
||||||
featured
|
return (
|
||||||
contextType={timelineId}
|
<StatusQuoteManager
|
||||||
showThread
|
key={`f-${statusId}`}
|
||||||
withCounters={this.props.withCounters}
|
id={statusId}
|
||||||
/>
|
featured
|
||||||
)).concat(scrollableContent);
|
contextType={timelineId}
|
||||||
|
showThread
|
||||||
|
withCounters={this.props.withCounters}
|
||||||
|
{...statusProps} />
|
||||||
|
);
|
||||||
|
}).concat(scrollableContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import IconPinned from '@/images/icons/icon_pinned.svg?react';
|
||||||
import { fetchRelationships } from '@/mastodon/actions/accounts';
|
import { fetchRelationships } from '@/mastodon/actions/accounts';
|
||||||
import {
|
import {
|
||||||
AdminBadge,
|
AdminBadge,
|
||||||
@ -14,6 +15,7 @@ import {
|
|||||||
GroupBadge,
|
GroupBadge,
|
||||||
MutedBadge,
|
MutedBadge,
|
||||||
} from '@/mastodon/components/badge';
|
} from '@/mastodon/components/badge';
|
||||||
|
import { Icon } from '@/mastodon/components/icon';
|
||||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||||
import type { AccountRole } from '@/mastodon/models/account';
|
import type { AccountRole } from '@/mastodon/models/account';
|
||||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
@ -119,6 +121,16 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
|
|||||||
return <div className={'account__header__badges'}>{badges}</div>;
|
return <div className={'account__header__badges'}>{badges}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PinnedBadge: FC = () => (
|
||||||
|
<Badge
|
||||||
|
className={classes.badge}
|
||||||
|
icon={<Icon id='pinned' icon={IconPinned} />}
|
||||||
|
label={
|
||||||
|
<FormattedMessage id='account.timeline.pinned' defaultMessage='Pinned' />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
function isAdminBadge(role: AccountRole) {
|
function isAdminBadge(role: AccountRole) {
|
||||||
const name = role.name.toLowerCase();
|
const name = role.name.toLowerCase();
|
||||||
return isRedesignEnabled() && (name === 'admin' || name === 'owner');
|
return isRedesignEnabled() && (name === 'admin' || name === 'owner');
|
||||||
|
|||||||
@ -296,6 +296,11 @@ svg.badgeIcon {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
border-radius: 0;
|
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) {
|
:global(.active) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { FC } from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
@ -13,7 +14,6 @@ import {
|
|||||||
} from '@/mastodon/actions/timelines_typed';
|
} from '@/mastodon/actions/timelines_typed';
|
||||||
import { Column } from '@/mastodon/components/column';
|
import { Column } from '@/mastodon/components/column';
|
||||||
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
|
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
|
||||||
import { FeaturedCarousel } from '@/mastodon/components/featured_carousel';
|
|
||||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||||
import { RemoteHint } from '@/mastodon/components/remote_hint';
|
import { RemoteHint } from '@/mastodon/components/remote_hint';
|
||||||
import StatusList from '@/mastodon/components/status_list';
|
import StatusList from '@/mastodon/components/status_list';
|
||||||
@ -29,6 +29,12 @@ import { useFilters } from '../hooks/useFilters';
|
|||||||
|
|
||||||
import { FeaturedTags } from './featured_tags';
|
import { FeaturedTags } from './featured_tags';
|
||||||
import { AccountFilters } from './filters';
|
import { AccountFilters } from './filters';
|
||||||
|
import {
|
||||||
|
PinnedStatusProvider,
|
||||||
|
renderPinnedStatusHeader,
|
||||||
|
usePinnedStatusIds,
|
||||||
|
} from './pinned_statuses';
|
||||||
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
const emptyList = ImmutableList<string>();
|
const emptyList = ImmutableList<string>();
|
||||||
|
|
||||||
@ -50,11 +56,13 @@ const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||||||
|
|
||||||
// Add this key to remount the timeline when accountId changes.
|
// Add this key to remount the timeline when accountId changes.
|
||||||
return (
|
return (
|
||||||
<InnerTimeline
|
<PinnedStatusProvider>
|
||||||
accountId={accountId}
|
<InnerTimeline
|
||||||
key={accountId}
|
accountId={accountId}
|
||||||
multiColumn={multiColumn}
|
key={accountId}
|
||||||
/>
|
multiColumn={multiColumn}
|
||||||
|
/>
|
||||||
|
</PinnedStatusProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -74,11 +82,14 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
|
|||||||
|
|
||||||
const timeline = useAppSelector((state) => selectTimelineByKey(state, key));
|
const timeline = useAppSelector((state) => selectTimelineByKey(state, key));
|
||||||
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
|
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
|
||||||
|
const forceEmptyState = blockedBy || hidden || suspended;
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!timeline && !!accountId) {
|
if (accountId) {
|
||||||
dispatch(expandTimelineByKey({ key }));
|
if (!timeline) {
|
||||||
|
dispatch(expandTimelineByKey({ key }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [accountId, dispatch, key, timeline]);
|
}, [accountId, dispatch, key, timeline]);
|
||||||
|
|
||||||
@ -91,7 +102,10 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
|
|||||||
[accountId, dispatch, key],
|
[accountId, dispatch, key],
|
||||||
);
|
);
|
||||||
|
|
||||||
const forceEmptyState = blockedBy || hidden || suspended;
|
const { isLoading: isPinnedLoading, statusIds: pinnedStatusIds } =
|
||||||
|
usePinnedStatusIds({ accountId, tagged, forceEmptyState });
|
||||||
|
|
||||||
|
const isLoading = !!timeline?.isLoading || isPinnedLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column bindToDocument={!multiColumn}>
|
<Column bindToDocument={!multiColumn}>
|
||||||
@ -99,25 +113,22 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
|
|||||||
|
|
||||||
<StatusList
|
<StatusList
|
||||||
alwaysPrepend
|
alwaysPrepend
|
||||||
prepend={
|
prepend={<Prepend accountId={accountId} forceEmpty={forceEmptyState} />}
|
||||||
<Prepend
|
|
||||||
accountId={accountId}
|
|
||||||
tagged={tagged}
|
|
||||||
forceEmpty={forceEmptyState}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
append={<RemoteHint accountId={accountId} />}
|
append={<RemoteHint accountId={accountId} />}
|
||||||
scrollKey='account_timeline'
|
scrollKey='account_timeline'
|
||||||
// We want to have this component when timeline is undefined (loading),
|
// 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.
|
// because if we don't the prepended component will re-render with every filter change.
|
||||||
statusIds={forceEmptyState ? emptyList : (timeline?.items ?? emptyList)}
|
statusIds={forceEmptyState ? emptyList : (timeline?.items ?? emptyList)}
|
||||||
isLoading={!!timeline?.isLoading}
|
featuredStatusIds={pinnedStatusIds}
|
||||||
|
isLoading={isLoading}
|
||||||
hasMore={!forceEmptyState && !!timeline?.hasMore}
|
hasMore={!forceEmptyState && !!timeline?.hasMore}
|
||||||
onLoadMore={handleLoadMore}
|
onLoadMore={handleLoadMore}
|
||||||
emptyMessage={<EmptyMessage accountId={accountId} />}
|
emptyMessage={<EmptyMessage accountId={accountId} />}
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
timelineId='account'
|
timelineId='account'
|
||||||
withCounters
|
withCounters
|
||||||
|
className={classNames(classes.statusWrapper)}
|
||||||
|
statusProps={{ headerRenderFn: renderPinnedStatusHeader }}
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
@ -125,9 +136,8 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
|
|||||||
|
|
||||||
const Prepend: FC<{
|
const Prepend: FC<{
|
||||||
accountId: string;
|
accountId: string;
|
||||||
tagged?: string;
|
|
||||||
forceEmpty: boolean;
|
forceEmpty: boolean;
|
||||||
}> = ({ forceEmpty, accountId, tagged }) => {
|
}> = ({ forceEmpty, accountId }) => {
|
||||||
if (forceEmpty) {
|
if (forceEmpty) {
|
||||||
return <AccountHeader accountId={accountId} hideTabs />;
|
return <AccountHeader accountId={accountId} hideTabs />;
|
||||||
}
|
}
|
||||||
@ -137,7 +147,6 @@ const Prepend: FC<{
|
|||||||
<AccountHeader accountId={accountId} hideTabs />
|
<AccountHeader accountId={accountId} hideTabs />
|
||||||
<AccountFilters />
|
<AccountFilters />
|
||||||
<FeaturedTags accountId={accountId} />
|
<FeaturedTags accountId={accountId} />
|
||||||
<FeaturedCarousel accountId={accountId} tagged={tagged} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 (
|
||||||
|
<PinnedStatusContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</PinnedStatusContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 <StatusHeader {...args} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<StatusHeader {...args} className={classes.pinnedStatusHeader}>
|
||||||
|
<PinnedBadge />
|
||||||
|
</StatusHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PinnedShowAllButton: FC = () => {
|
||||||
|
const { onShowAllPinned } = useContext(PinnedStatusContext);
|
||||||
|
|
||||||
|
if (!isRedesignEnabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={onShowAllPinned}
|
||||||
|
className={classNames(classes.pinnedViewAllButton, 'focusable')}
|
||||||
|
>
|
||||||
|
<Icon id='pinned' icon={IconPinned} />
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.timeline.pinned.view_all'
|
||||||
|
defaultMessage='View all pinned posts'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<StatusHeaderProps> = ({
|
||||||
|
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 */
|
||||||
|
<div
|
||||||
|
onClick={onHeaderClick}
|
||||||
|
onAuxClick={onHeaderClick}
|
||||||
|
{...wrapperProps}
|
||||||
|
className='status__info'
|
||||||
|
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={`/@${statusAccount?.acct}/${status.get('id') as string}`}
|
||||||
|
className='status__relative-time'
|
||||||
|
>
|
||||||
|
<StatusVisibility visibility={status.get('visibility')} />
|
||||||
|
<RelativeTimestamp timestamp={status.get('created_at') as string} />
|
||||||
|
{editedAt && <StatusEditedAt editedAt={editedAt} />}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<StatusDisplayName
|
||||||
|
statusAccount={statusAccount}
|
||||||
|
friendAccount={account}
|
||||||
|
avatarSize={avatarSize}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -10,6 +10,12 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
transition: color 0.2s ease-in-out;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: var(--color-text-brand-soft);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterSelectIcon {
|
.filterSelectIcon {
|
||||||
@ -57,3 +63,57 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
max-width: none !important;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -123,6 +123,8 @@
|
|||||||
"account.share": "Share @{name}'s profile",
|
"account.share": "Share @{name}'s profile",
|
||||||
"account.show_reblogs": "Show boosts from @{name}",
|
"account.show_reblogs": "Show boosts from @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}",
|
"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": "Unblock @{name}",
|
||||||
"account.unblock_domain": "Unblock domain {domain}",
|
"account.unblock_domain": "Unblock domain {domain}",
|
||||||
"account.unblock_domain_short": "Unblock",
|
"account.unblock_domain_short": "Unblock",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user