Merge commit '2e30044a374811bc94fd62a8159cb2c9ffe18a4d' into glitch-soc/merge-upstream

This commit is contained in:
Claire committed 2026-02-06 22:45:55 +01:00
commit 8f58c42ca3
65 files changed
+1545 -146

No files matched your search

@@ -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';
@@ -106,6 +108,7 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
<MutedBadge
key='muted-badge'
className={classNames(className, classes.badgeMuted)}
expiresAt={relationship.muting_expires_at}
/>,
);
}
@@ -118,6 +121,16 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
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) {
const name = role.name.toLowerCase();
return isRedesignEnabled() && (name === 'admin' || name === 'owner');
@@ -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) {
@@ -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<string>();
@@ -50,11 +56,13 @@ const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
// Add this key to remount the timeline when accountId changes.
return (
<InnerTimeline
accountId={accountId}
key={accountId}
multiColumn={multiColumn}
/>
<PinnedStatusProvider>
<InnerTimeline
accountId={accountId}
key={accountId}
multiColumn={multiColumn}
/>
</PinnedStatusProvider>
);
};
@@ -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 (
<Column bindToDocument={!multiColumn}>
@@ -99,25 +113,22 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
<StatusList
alwaysPrepend
prepend={
<Prepend
accountId={accountId}
tagged={tagged}
forceEmpty={forceEmptyState}
/>
}
prepend={<Prepend accountId={accountId} forceEmpty={forceEmptyState} />}
append={<RemoteHint accountId={accountId} />}
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={<EmptyMessage accountId={accountId} />}
bindToDocument={!multiColumn}
timelineId='account'
withCounters
className={classNames(classes.statusWrapper)}
statusProps={{ headerRenderFn: renderPinnedStatusHeader }}
/>
</Column>
);
@@ -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 <AccountHeader accountId={accountId} hideTabs />;
}
@@ -137,7 +147,6 @@ const Prepend: FC<{
<AccountHeader accountId={accountId} hideTabs />
<AccountFilters />
<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;
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;
}
}
@@ -1,12 +1,10 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, Link } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchRelationships } from 'mastodon/actions/accounts';
@@ -14,14 +12,12 @@ import { showAlertForError } from 'mastodon/actions/alerts';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { fetchList } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
import { apiRequest } from 'mastodon/api';
import { apiFollowAccount } from 'mastodon/api/accounts';
import {
apiGetAccounts,
apiAddAccountToList,
apiRemoveAccountFromList,
} from 'mastodon/api/lists';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column';
@@ -35,6 +31,8 @@ import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { useSearchAccounts } from './use_search_accounts';
export const messages = defineMessages({
manageMembers: {
id: 'column.list_members',
@@ -163,10 +161,23 @@ const ListMembers: React.FC<{
const [searching, setSearching] = useState(false);
const [accountIds, setAccountIds] = useState<string[]>([]);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
const [loading, setLoading] = useState(!!id);
const [mode, setMode] = useState<Mode>('remove');
const {
accountIds: searchAccountIds = [],
isLoading: loadingSearchResults,
searchAccounts: handleSearch,
} = useSearchAccounts({
onSettled: (value) => {
if (value.trim().length === 0) {
setSearching(false);
} else {
setSearching(true);
}
},
});
useEffect(() => {
if (id) {
dispatch(fetchList(id));
@@ -206,46 +217,6 @@ const ListMembers: React.FC<{
[accountIds, setAccountIds],
);
const searchRequestRef = useRef<AbortController | null>(null);
const handleSearch = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
setSearching(false);
return;
}
setLoading(true);
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: true,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
setSearchAccountIds(data.map((a) => a.id));
setLoading(false);
setSearching(true);
return '';
})
.catch(() => {
setSearching(true);
setLoading(false);
});
},
500,
{ leading: true, trailing: true },
);
let displayedAccountIds: string[];
if (mode === 'add' && searching) {
@@ -279,7 +250,7 @@ const ListMembers: React.FC<{
scrollKey='list_members'
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={loading}
isLoading={loading || loadingSearchResults}
showLoading={loading && displayedAccountIds.length === 0}
hasMore={false}
footer={
@@ -0,0 +1,67 @@
import { useRef, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { apiRequest } from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { useAppDispatch } from 'mastodon/store';
export function useSearchAccounts({
onSettled,
}: {
onSettled?: (value: string) => void;
} = {}) {
const dispatch = useAppDispatch();
const [accountIds, setAccountIds] = useState<string[]>();
const [loadingState, setLoadingState] = useState<
'idle' | 'loading' | 'error'
>('idle');
const searchRequestRef = useRef<AbortController | null>(null);
const searchAccounts = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
onSettled?.('');
return;
}
setLoadingState('loading');
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: true,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoadingState('idle');
onSettled?.(value);
})
.catch(() => {
setLoadingState('error');
onSettled?.(value);
});
},
500,
{ leading: true, trailing: true },
);
return {
searchAccounts,
accountIds,
isLoading: loadingState === 'loading',
isError: loadingState === 'error',
};
}