Merge commit 'c26003af215da7696e2b0dcece7fa695b1be8871' into glitch-soc/merge-upstream
This commit is contained in:
commit
42ec9a607b
157 files changed
+1064
-1429
No files matched your search
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { PendingBadge } from '@/mastodon/components/badge';
|
||||
import { SelectField } from '@/mastodon/components/form_fields';
|
||||
import { useSearchParam } from '@/mastodon/hooks/useSearchParam';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import type {
|
||||
ApiCollectionJSON,
|
||||
@@ -14,7 +16,6 @@ import {
|
||||
AccountListItemFollowButton,
|
||||
} from 'mastodon/components/account_list_item';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Callout } from 'mastodon/components/callout';
|
||||
import {
|
||||
Article,
|
||||
ItemList,
|
||||
@@ -35,50 +36,6 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
const SensitiveScreen: React.FC<{
|
||||
sensitive: boolean | undefined;
|
||||
focusTargetRef: React.RefObject<HTMLHeadingElement>;
|
||||
children: React.ReactNode;
|
||||
}> = ({ sensitive, focusTargetRef, children }) => {
|
||||
const [isVisible, setIsVisible] = useState(!sensitive);
|
||||
|
||||
const showAnyway = useCallback(() => {
|
||||
setIsVisible(true);
|
||||
setTimeout(() => {
|
||||
focusTargetRef.current?.focus();
|
||||
}, 0);
|
||||
}, [focusTargetRef]);
|
||||
|
||||
if (isVisible) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Callout
|
||||
variant='warning'
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_content'
|
||||
defaultMessage='Sensitive content'
|
||||
/>
|
||||
}
|
||||
primaryLabel={
|
||||
<FormattedMessage
|
||||
id='content_warning.show_short'
|
||||
defaultMessage='Show'
|
||||
/>
|
||||
}
|
||||
onPrimary={showAnyway}
|
||||
className={classes.sensitiveScreen}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_note'
|
||||
defaultMessage='The description and accounts may not be suitable for all viewers.'
|
||||
/>
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
|
||||
type CollectionItemWithAccount = CollectionAccountItem & {
|
||||
account?: Account | null;
|
||||
};
|
||||
@@ -98,6 +55,43 @@ const getCollectionItems = createAppSelector(
|
||||
),
|
||||
);
|
||||
|
||||
function sortAccounts(
|
||||
accounts: CollectionItemWithAccount[],
|
||||
sortBy?: string,
|
||||
): CollectionItemWithAccount[] {
|
||||
if (!sortBy || sortBy === 'date_added') {
|
||||
return accounts;
|
||||
}
|
||||
|
||||
const sorted = [...accounts];
|
||||
|
||||
switch (sortBy) {
|
||||
case 'alphabetical':
|
||||
return sorted.sort((a, b) => {
|
||||
const nameA = a.account?.display_name ?? '';
|
||||
const nameB = b.account?.display_name ?? '';
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
case 'last_active':
|
||||
return sorted.sort((a, b) => {
|
||||
const dateA = a.account?.last_status_at ?? '';
|
||||
const dateB = b.account?.last_status_at ?? '';
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
});
|
||||
|
||||
case 'most_followers':
|
||||
return sorted.sort((a, b) => {
|
||||
const followersA = a.account?.followers_count ?? 0;
|
||||
const followersB = b.account?.followers_count ?? 0;
|
||||
return followersB - followersA;
|
||||
});
|
||||
|
||||
default:
|
||||
return accounts;
|
||||
}
|
||||
}
|
||||
|
||||
export const CollectionAccountsList: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
}> = ({ collection }) => {
|
||||
@@ -113,11 +107,20 @@ export const CollectionAccountsList: React.FC<{
|
||||
getCollectionItems(state, id),
|
||||
);
|
||||
|
||||
const [sortBy, setSortBy] = useSearchParam('sort', 'date_added');
|
||||
const changeSortBy = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSortBy(event.target.value);
|
||||
},
|
||||
[setSortBy],
|
||||
);
|
||||
const sortedAccounts = sortAccounts(collectionAccounts, sortBy);
|
||||
|
||||
const { visibleAccounts, hiddenAccounts } = useMemo(() => {
|
||||
const visibleAccounts: CollectionItemWithAccount[] = [];
|
||||
const hiddenAccounts: CollectionItemWithAccount[] = [];
|
||||
|
||||
collectionAccounts.forEach((item) => {
|
||||
sortedAccounts.forEach((item) => {
|
||||
const { account, account_id } = item;
|
||||
|
||||
if (!isOwnCollection && !account) {
|
||||
@@ -134,7 +137,7 @@ export const CollectionAccountsList: React.FC<{
|
||||
});
|
||||
|
||||
return { visibleAccounts, hiddenAccounts };
|
||||
}, [collectionAccounts, isOwnCollection, relationships]);
|
||||
}, [sortedAccounts, isOwnCollection, relationships]);
|
||||
|
||||
const renderAccountItemButton = useCallback(
|
||||
({ relationship, accountId }: RenderButtonOptions) => {
|
||||
@@ -192,46 +195,81 @@ export const CollectionAccountsList: React.FC<{
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3
|
||||
className={classes.columnSubheading}
|
||||
tabIndex={-1}
|
||||
ref={listHeadingRef}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.account_count'
|
||||
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
||||
values={{ count: collection.item_count }}
|
||||
/>
|
||||
</h3>
|
||||
<SensitiveScreen
|
||||
sensitive={!isOwnCollection && collection.sensitive}
|
||||
focusTargetRef={listHeadingRef}
|
||||
>
|
||||
<ItemList emptyMessage={intl.formatMessage(messages.empty)}>
|
||||
<TruncatedListItems
|
||||
visibleItems={visibleAccounts}
|
||||
truncatedItems={hiddenAccounts}
|
||||
toggleButton={{
|
||||
icon: VisibilityOffIcon,
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_link'
|
||||
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
subtitle: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_description'
|
||||
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
renderListItem={renderListItem}
|
||||
<div className={classes.subheadingWithSelect}>
|
||||
<h3
|
||||
className={classes.columnSubheading}
|
||||
tabIndex={-1}
|
||||
ref={listHeadingRef}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.account_count'
|
||||
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
||||
values={{ count: collection.item_count }}
|
||||
/>
|
||||
</ItemList>
|
||||
</SensitiveScreen>
|
||||
</h3>
|
||||
<SelectField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.sort_by'
|
||||
defaultMessage='Sort by:'
|
||||
/>
|
||||
}
|
||||
value={sortBy}
|
||||
onChange={changeSortBy}
|
||||
inputPlacement='inline-end'
|
||||
className={classes.select}
|
||||
wrapperClassName={classes.selectWrapper}
|
||||
>
|
||||
<option value='alphabetical'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_alphabetical'
|
||||
defaultMessage='Alphabetical'
|
||||
/>
|
||||
</option>
|
||||
<option value='last_active'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_last_active'
|
||||
defaultMessage='Last active'
|
||||
/>
|
||||
</option>
|
||||
<option value='most_followers'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_most_followers'
|
||||
defaultMessage='Most followers'
|
||||
/>
|
||||
</option>
|
||||
<option value='date_added'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_date_added'
|
||||
defaultMessage='Date added'
|
||||
/>
|
||||
</option>
|
||||
</SelectField>
|
||||
</div>
|
||||
<ItemList emptyMessage={intl.formatMessage(messages.empty)}>
|
||||
<TruncatedListItems
|
||||
visibleItems={visibleAccounts}
|
||||
truncatedItems={hiddenAccounts}
|
||||
toggleButton={{
|
||||
icon: VisibilityOffIcon,
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_link'
|
||||
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
subtitle: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_description'
|
||||
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
renderListItem={renderListItem}
|
||||
/>
|
||||
</ItemList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
@@ -138,9 +138,40 @@ export const PendingNote: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
collection,
|
||||
const SensitiveContentNote: React.FC<{ onReveal: () => void }> = ({
|
||||
onReveal,
|
||||
}) => {
|
||||
return (
|
||||
<Callout
|
||||
variant='warning'
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_content'
|
||||
defaultMessage='Sensitive content'
|
||||
/>
|
||||
}
|
||||
primaryLabel={
|
||||
<FormattedMessage
|
||||
id='content_warning.show_short'
|
||||
defaultMessage='Show'
|
||||
/>
|
||||
}
|
||||
onPrimary={onReveal}
|
||||
className={classes.sensitiveScreen}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_note'
|
||||
defaultMessage='The description and accounts may not be suitable for all viewers.'
|
||||
/>
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionHeader: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
withDescription: boolean;
|
||||
headingRef: React.RefObject<HTMLHeadingElement>;
|
||||
}> = ({ collection, withDescription, headingRef }) => {
|
||||
const intl = useIntl();
|
||||
const { name, description, tag, account_id, items } = collection;
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -181,7 +212,9 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
<div className={classes.titleWithMenu}>
|
||||
<div className={classes.titleWrapper}>
|
||||
{tag && <Badge label={`#${tag.name}`} icon={null} />}
|
||||
<h2 className={classes.name}>{name}</h2>
|
||||
<h2 className={classes.name} ref={headingRef} tabIndex={-1}>
|
||||
{name}
|
||||
</h2>
|
||||
<AuthorNote id={account_id} />
|
||||
</div>
|
||||
<div className={classes.headerButtonWrapper}>
|
||||
@@ -199,7 +232,9 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{description && <p className={classes.description}>{description}</p>}
|
||||
{withDescription && description && (
|
||||
<p className={classes.description}>{description}</p>
|
||||
)}
|
||||
{hasPendingAccounts && <PendingNote />}
|
||||
{isCurrentUserInCollection && (
|
||||
<RevokeControls
|
||||
@@ -211,6 +246,52 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
function useRevealSensitiveContent({
|
||||
sensitive,
|
||||
}: {
|
||||
sensitive: boolean | undefined;
|
||||
}) {
|
||||
const postRevealFocusTargetRef = useRef<HTMLHeadingElement>(null);
|
||||
const [isContentVisible, setIsContentVisible] = useState(!sensitive);
|
||||
|
||||
const revealContent = useCallback(() => {
|
||||
setIsContentVisible(true);
|
||||
setTimeout(() => {
|
||||
postRevealFocusTargetRef.current?.focus();
|
||||
}, 0);
|
||||
}, [postRevealFocusTargetRef]);
|
||||
|
||||
return {
|
||||
isContentVisible,
|
||||
revealContent,
|
||||
postRevealFocusTargetRef,
|
||||
};
|
||||
}
|
||||
|
||||
const ColumnContent: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
}> = ({ collection }) => {
|
||||
const { isContentVisible, revealContent, postRevealFocusTargetRef } =
|
||||
useRevealSensitiveContent({
|
||||
sensitive: collection.sensitive && collection.account_id !== me,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollectionHeader
|
||||
collection={collection}
|
||||
headingRef={postRevealFocusTargetRef}
|
||||
withDescription={isContentVisible}
|
||||
/>
|
||||
{isContentVisible ? (
|
||||
<CollectionAccountsList collection={collection} />
|
||||
) : (
|
||||
<SensitiveContentNote onReveal={revealContent} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionDetailPage: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
@@ -241,10 +322,7 @@ export const CollectionDetailPage: React.FC<{
|
||||
|
||||
<Scrollable>
|
||||
{collection ? (
|
||||
<>
|
||||
<CollectionHeader collection={collection} />
|
||||
<CollectionAccountsList collection={collection} />
|
||||
</>
|
||||
<ColumnContent collection={collection} />
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
|
||||
@@ -64,11 +64,30 @@
|
||||
}
|
||||
|
||||
.sensitiveScreen {
|
||||
margin: 16px;
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
.subheadingWithSelect {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.selectWrapper {
|
||||
// Align to right edge even when wrapped to new line
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.select {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
background-color: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.columnSubheading {
|
||||
padding-inline: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { EmptyState } from 'mastodon/components/empty_state';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { ItemList } from 'mastodon/components/scrollable_list/components';
|
||||
@@ -34,6 +35,7 @@ function useCollectionsFeaturing(accountId: string | null | undefined) {
|
||||
|
||||
export const CollectionsFeaturingYou: React.FC = () => {
|
||||
const accountId = useAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const { collections, status } = useCollectionsFeaturing(accountId);
|
||||
|
||||
@@ -46,16 +48,43 @@ export const CollectionsFeaturingYou: React.FC = () => {
|
||||
}
|
||||
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in'
|
||||
defaultMessage='You have not been added to any collections yet.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (account?.discoverable) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in'
|
||||
defaultMessage='You have not been added to any collections yet.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
<>
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in'
|
||||
defaultMessage='You have not been added to any collections yet.'
|
||||
/>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in_undiscoverable'
|
||||
defaultMessage='In order for people to add you to collections, you need to allow featuring in discovery experiences from <link>Preferences > Privacy and reach</link>'
|
||||
values={{
|
||||
link: (chunks) => (
|
||||
<a href='/settings/privacy#account_discoverable'>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user