Merge commit 'dc923c6425559266f0665fe2e294f6e412783810' into glitch-soc/merge-upstream

This commit is contained in:
Claire 2026-04-02 18:20:01 +02:00
commit cb62beca08
38 changed files with 404 additions and 384 deletions

View File

@ -34,11 +34,11 @@ module ApplicationHelper
Setting.registrations_mode == 'none'
end
def available_sign_up_path
def available_sign_up_url
if closed_registrations? || omniauth_only?
'https://joinmastodon.org/#getting-started'
'https://joinmastodon.org/'
else
ENV.fetch('SSO_ACCOUNT_SIGN_UP', new_user_registration_path)
ENV.fetch('SSO_ACCOUNT_SIGN_UP', new_user_registration_url)
end
end

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -26,8 +26,10 @@ defineMessages({
export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => {
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
const filterType = notification.type === 'quoted_update' ? 'update' : notification.type;
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', filterType], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', filterType], true);
let filtered = false;

View File

@ -37,6 +37,7 @@ export interface BaseApiAccountJSON {
acct: string;
avatar: string;
avatar_static: string;
avatar_description: string;
bot: boolean;
created_at: string;
discoverable?: boolean;
@ -50,6 +51,7 @@ export interface BaseApiAccountJSON {
group: boolean;
header: string;
header_static: string;
header_description: string;
id: string;
last_status_at: string;
locked: boolean;

View File

@ -16,7 +16,7 @@ export interface ApiCollectionJSON {
item_count: number;
name: string;
description: string;
description: string | null;
tag: ApiTagJSON | null;
language: string | null;
sensitive: boolean;

View File

@ -13,6 +13,7 @@ interface Props {
account:
| Pick<Account, 'id' | 'acct' | 'avatar' | 'avatar_static'>
| undefined; // FIXME: remove `undefined` once we know for sure its always there
alt?: string;
size?: number;
style?: React.CSSProperties;
inline?: boolean;
@ -25,6 +26,7 @@ interface Props {
export const Avatar: React.FC<Props> = ({
account,
alt = '',
animate = autoPlayGif,
size = 20,
inline = false,
@ -65,7 +67,7 @@ export const Avatar: React.FC<Props> = ({
style={style}
>
{src && !error && (
<img src={src} alt='' onLoad={handleLoad} onError={handleError} />
<img src={src} alt={alt} onLoad={handleLoad} onError={handleError} />
)}
{counter && (

View File

@ -10,6 +10,13 @@
}
.content {
svg,
img {
width: 240px;
max-width: 100%;
margin-bottom: 16px;
}
h3 {
font-size: 17px;
font-weight: 500;

View File

@ -9,10 +9,12 @@ import classes from './empty_state.module.scss';
*/
export const EmptyState: React.FC<{
title?: string | React.ReactElement;
message?: string | React.ReactElement;
image?: React.ReactNode;
title?: React.ReactNode;
message?: React.ReactNode;
children?: React.ReactNode;
}> = ({
image,
title = (
<FormattedMessage id='empty_state.no_results' defaultMessage='No results' />
),
@ -21,10 +23,13 @@ export const EmptyState: React.FC<{
}) => {
return (
<div className={classes.wrapper}>
<div className={classes.content}>
<h3>{title}</h3>
{!!message && <p>{message}</p>}
</div>
{(title || message || image) && (
<div className={classes.content}>
{image}
{!!title && <h3>{title}</h3>}
{!!message && <p>{message}</p>}
</div>
)}
{children}
</div>

View File

@ -1,9 +1,15 @@
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
import { me } from 'mastodon/initial_state';
import ElephantDarkImage from '@/images/elephant_ui_dark.svg?react';
import ElephantLightImage from '@/images/elephant_ui_light.svg?react';
import { EmptyState } from '@/mastodon/components/empty_state';
import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint';
import { areCollectionsEnabled } from '@/mastodon/features/collections/utils';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import { useTheme } from '@/mastodon/hooks/useTheme';
interface EmptyMessageProps {
suspended: boolean;
@ -19,21 +25,59 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
blockedBy,
}) => {
const { acct } = useParams<{ acct?: string }>();
const me = useCurrentAccountId();
const theme = useTheme();
const ElephantImage =
theme === 'dark' ? ElephantDarkImage : ElephantLightImage;
if (!accountId) {
return null;
}
let title: React.ReactNode = null;
let message: React.ReactNode = null;
const hasCollections = areCollectionsEnabled();
const image = <ElephantImage />;
if (me === accountId) {
message = (
<FormattedMessage
id='empty_column.account_featured.me'
defaultMessage='You have not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?'
/>
);
if (hasCollections) {
// Return only here to insert the "Create a collection" button as the action for the empty state.
return (
<EmptyState
image={image}
title={
<FormattedMessage
id='empty_column.account_featured_self.no_collections'
defaultMessage='No collections yet'
/>
}
>
<Link to='/collections/new' className='button'>
<FormattedMessage
id='empty_column.account_featured_self.no_collections_button'
defaultMessage='Create a collection'
/>
</Link>
</EmptyState>
);
} else {
title = (
<FormattedMessage
id='empty_column.account_featured_self.pre_collections'
defaultMessage='Stay tuned for Collections'
/>
);
message = (
<FormattedMessage
id='empty_column.account_featured_self.pre_collections_desc'
defaultMessage='Collections (coming in Mastodon 4.6) allows you to create your own curated lists of accounts to recommend to others.'
/>
);
}
} else if (suspended) {
message = (
title = (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
@ -42,32 +86,56 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
} else if (hidden) {
message = <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
message = (
title = (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
} else if (acct) {
message = (
<FormattedMessage
id='empty_column.account_featured.other'
defaultMessage='{acct} has not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?'
values={{ acct }}
/>
);
} else {
message = (
// Standard other account empty state.
title = (
<FormattedMessage
id='empty_column.account_featured_other.unknown'
defaultMessage='This account has not featured anything yet.'
id='empty_column.account_featured_other.title'
defaultMessage='Nothing to see here'
/>
);
if (hasCollections) {
if (acct) {
message = (
<FormattedMessage
id='empty_column.account_featured_other.no_collections_desc'
defaultMessage='{acct} hasnt created any collections yet.'
values={{ acct }}
/>
);
} else {
message = (
<FormattedMessage
id='empty_column.account_featured_unknown.no_collections_desc'
defaultMessage='This account hasnt created any collections yet.'
/>
);
}
} else {
if (acct) {
message = (
<FormattedMessage
id='empty_column.account_featured.other'
defaultMessage='{acct} hasnt featured anything yet.'
values={{ acct }}
/>
);
} else {
message = (
<FormattedMessage
id='empty_column.account_featured_unknown.other'
defaultMessage='This account hasnt featured anything yet.'
/>
);
}
}
}
return (
<div className='empty-column-indicator'>
<span>{message}</span>
</div>
);
return <EmptyState title={title} message={message} image={image} />;
};

View File

@ -1,52 +0,0 @@
import { defineMessages, useIntl } from 'react-intl';
import type { Map as ImmutableMap } from 'immutable';
import { Hashtag } from 'mastodon/components/hashtag';
export type TagMap = ImmutableMap<
'id' | 'name' | 'url' | 'statuses_count' | 'last_status_at' | 'accountId',
string | null
>;
interface FeaturedTagProps {
tag: TagMap;
account: string;
}
const messages = defineMessages({
lastStatusAt: {
id: 'account.featured_tags.last_status_at',
defaultMessage: 'Last post on {date}',
},
empty: {
id: 'account.featured_tags.last_status_never',
defaultMessage: 'No posts',
},
});
export const FeaturedTag: React.FC<FeaturedTagProps> = ({ tag, account }) => {
const intl = useIntl();
const name = tag.get('name') ?? '';
const count = Number.parseInt(tag.get('statuses_count') ?? '');
return (
<Hashtag
key={name}
name={name}
to={`/@${account}/tagged/${name}`}
uses={count}
withGraph={false}
description={
count > 0
? intl.formatMessage(messages.lastStatusAt, {
date: intl.formatDate(tag.get('last_status_at') ?? '', {
month: 'short',
day: '2-digit',
year: 'numeric',
}),
})
: intl.formatMessage(messages.empty)
}
/>
);
};

View File

@ -8,7 +8,6 @@ import { List as ImmutableList } from 'immutable';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { fetchEndorsedAccounts } from 'mastodon/actions/accounts';
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
import { Account } from 'mastodon/components/account';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
@ -33,8 +32,6 @@ import { CollectionListItem } from '../collections/detail/collection_list_item';
import { areCollectionsEnabled } from '../collections/utils';
import { EmptyMessage } from './components/empty_message';
import { FeaturedTag } from './components/featured_tag';
import type { TagMap } from './components/featured_tag';
const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
multiColumn,
@ -55,26 +52,15 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
useEffect(() => {
if (accountId) {
void dispatch(fetchFeaturedTags({ accountId }));
void dispatch(fetchEndorsedAccounts({ accountId }));
if (areCollectionsEnabled()) {
void dispatch(fetchAccountCollections({ accountId }));
}
}
}, [accountId, dispatch]);
const isLoading = useAppSelector(
(state) =>
!accountId ||
!!state.user_lists.getIn(['featured_tags', accountId, 'isLoading']),
);
const featuredTags = useAppSelector(
(state) =>
state.user_lists.getIn(
['featured_tags', accountId, 'items'],
ImmutableList(),
) as ImmutableList<TagMap>,
);
const isLoading = !accountId;
const featuredAccountIds = useAppSelector(
(state) =>
state.user_lists.getIn(
@ -106,13 +92,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
);
}
const noTags = featuredTags.isEmpty();
if (
noTags &&
featuredAccountIds.isEmpty() &&
listedCollections.length === 0
) {
if (featuredAccountIds.isEmpty() && listedCollections.length === 0) {
return (
<AccountFeaturedWrapper accountId={accountId}>
<EmptyMessage
@ -156,28 +136,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
</ItemList>
</>
)}
{!noTags && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.hashtags'
defaultMessage='Hashtags'
/>
</h4>
<ItemList>
{featuredTags.map((tag, index) => (
<Article
focusable
key={tag.get('id')}
aria-posinset={index + 1}
aria-setsize={featuredTags.size}
>
<FeaturedTag tag={tag} account={account?.acct ?? ''} />
</Article>
))}
</ItemList>
</>
)}
{!featuredAccountIds.isEmpty() && (
<>
<h4 className='column-subheading'>

View File

@ -72,7 +72,7 @@ export const AccountHeader: React.FC<{
modalType: 'IMAGE',
modalProps: {
src: account.avatar,
alt: '',
alt: account.avatar_description,
},
}),
);
@ -120,7 +120,7 @@ export const AccountHeader: React.FC<{
{!suspendedOrHidden && (
<img
src={autoPlayGif ? account.header : account.header_static}
alt=''
alt={account.header_description}
className='parallax'
/>
)}
@ -147,6 +147,7 @@ export const AccountHeader: React.FC<{
>
<Avatar
account={suspendedOrHidden ? undefined : account}
alt={account.avatar_description}
size={80}
/>
</a>
@ -169,6 +170,8 @@ export const AccountHeader: React.FC<{
<AccountBadges accountId={accountId} />
<AccountNumberFields accountId={accountId} />
{!isMe && !suspendedOrHidden && (
<FamiliarFollowers accountId={accountId} />
)}
@ -195,8 +198,6 @@ export const AccountHeader: React.FC<{
{!me && account.email_subscriptions && (
<AccountSubscriptionForm accountId={accountId} />
)}
<AccountNumberFields accountId={accountId} />
</div>
)}

View File

@ -235,6 +235,7 @@ function useColumnWrap() {
const columnCount = parseInt(styles.getPropertyValue('--cols')) || 2;
const listWidth = listEle.offsetWidth;
const colWidth = (listWidth - gap * (columnCount - 1)) / columnCount;
const halfColSpan = columnCount / 2;
// Matrix to hold the grid layout.
const itemGrid: { ele: HTMLElement; span: number }[][] = [];
@ -288,6 +289,16 @@ function useColumnWrap() {
if (i < row.length - 1) {
ele.dataset.cols = span.toString();
remainingRowSpan -= span;
} else if (
row.length === halfColSpan &&
span === 1 &&
remainingRowSpan > 1
) {
// Special case for 2 items in a row where both items only need 1 column.
ele.dataset.cols = halfColSpan.toString();
if (row[0]) {
row[0].ele.dataset.cols = halfColSpan.toString();
}
} else {
// Last item in the row takes up remaining space to fill the row.
ele.dataset.cols = remainingRowSpan.toString();

View File

@ -27,13 +27,6 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
return (
<NumberFields>
<NumberFieldsItem
label={<FormattedMessage id='account.posts' defaultMessage='Posts' />}
hint={intl.formatNumber(account.statuses_count)}
>
<ShortNumber value={account.statuses_count} />
</NumberFieldsItem>
<NumberFieldsItem
label={
<FormattedMessage id='account.followers' defaultMessage='Followers' />
@ -54,6 +47,13 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
<ShortNumber value={account.following_count} />
</NumberFieldsItem>
<NumberFieldsItem
label={<FormattedMessage id='account.posts' defaultMessage='Posts' />}
hint={intl.formatNumber(account.statuses_count)}
>
<ShortNumber value={account.statuses_count} />
</NumberFieldsItem>
<NumberFieldsItem
label={
<FormattedMessage id='account.joined_short' defaultMessage='Joined' />

View File

@ -9,11 +9,7 @@
.barWrapper {
border-bottom: none;
padding-inline: 24px;
@container (width < 500px) {
padding-inline: 16px;
}
padding-inline: 16px;
}
.avatarWrapper {
@ -324,7 +320,7 @@ svg.badgeIcon {
}
.modalFieldsList {
padding: 24px;
padding: 16px;
}
.modalFieldItem {
@ -363,11 +359,9 @@ svg.badgeIcon {
.tabs {
display: flex;
gap: 12px;
padding: 0 24px;
padding: 0 16px;
@container (width < 500px) {
padding: 0 16px;
a {
flex: 1 1 0px;
text-align: center;

View File

@ -8,6 +8,8 @@ import { NavLink } from 'react-router-dom';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import { areCollectionsEnabled } from '../../collections/utils';
import classes from './redesign.module.scss';
const isActive: Required<NavLinkProps>['isActive'] = (match, location) =>
@ -39,7 +41,14 @@ export const AccountTabs: FC = () => {
)}
{show_featured && (
<NavLink exact to={`/@${acct}/featured`}>
<FormattedMessage id='account.featured' defaultMessage='Featured' />
{areCollectionsEnabled() ? (
<FormattedMessage
id='account.featured.collections'
defaultMessage='Collections'
/>
) : (
<FormattedMessage id='account.featured' defaultMessage='Featured' />
)}
</NavLink>
)}
</div>

View File

@ -1,5 +1,5 @@
.filtersWrapper {
padding: 16px 24px 8px;
padding: 16px 16px 8px;
}
.filterSelectButton {
@ -51,7 +51,7 @@
.tagsWrapper,
.tagSuggestions {
margin: 0 24px 8px;
margin: 0 16px 8px;
}
.tagsWrapper {
@ -76,11 +76,6 @@
}
.statusWrapper {
:global(.status) {
padding-left: 24px;
padding-right: 24px;
}
&:has(.pinnedViewAllButton) :global(.status):has(.pinnedStatusHeader) {
border-bottom: none;
}
@ -97,7 +92,7 @@
box-sizing: border-box;
color: var(--color-text-primary);
line-height: normal;
margin: 12px 24px;
margin: 12px 16px;
padding: 8px;
transition: border-color 0.2s ease-in-out;
width: calc(100% - 48px);

View File

@ -2,26 +2,26 @@ import { useCallback, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Callout } from '@/mastodon/components/callout';
import { FollowButton } from '@/mastodon/components/follow_button';
import { openModal } from 'mastodon/actions/modal';
import type {
ApiCollectionJSON,
CollectionAccountItem,
} from 'mastodon/api_types/collections';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Account } from 'mastodon/components/account';
import { Button } from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name';
import { Callout } from 'mastodon/components/callout';
import { FollowButton } from 'mastodon/components/follow_button';
import {
NumberFields,
NumberFieldsItem,
} from 'mastodon/components/number_fields';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import {
Article,
ItemList,
} from 'mastodon/components/scrollable_list/components';
import { ShortNumber } from 'mastodon/components/short_number';
import { useAccount } from 'mastodon/hooks/useAccount';
import { useDismissible } from 'mastodon/hooks/useDismissible';
import { useRelationship } from 'mastodon/hooks/useRelationship';
import { me } from 'mastodon/initial_state';
import { useAppDispatch } from 'mastodon/store';
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
import classes from './styles.module.scss';
const messages = defineMessages({
@ -31,28 +31,33 @@ const messages = defineMessages({
},
});
const SimpleAuthorName: React.FC<{ id: string }> = ({ id }) => {
const account = useAccount(id);
return <DisplayName account={account} variant='simple' />;
};
const AccountItem: React.FC<{
accountId: string | undefined;
collectionOwnerId: string;
onRevoke: () => void;
withBio?: boolean;
withBorder?: boolean;
}> = ({ accountId, withBio = true, withBorder = true, collectionOwnerId }) => {
}> = ({
accountId,
collectionOwnerId,
onRevoke,
withBio = true,
withBorder = true,
}) => {
const intl = useIntl();
const account = useAccount(accountId);
const relationship = useRelationship(accountId);
if (!accountId) {
if (!accountId || !account) {
return null;
}
// When viewing your own collection, only show the Follow button
// for accounts you're not following (anymore).
// Otherwise, always show the follow button in its various states.
const isOwnAccount = accountId === me;
const withoutButton =
accountId === me ||
isOwnAccount ||
!relationship ||
(collectionOwnerId === me &&
(relationship.following || relationship.requested));
@ -66,52 +71,55 @@ const AccountItem: React.FC<{
withBorder={false}
withMenu={false}
className={classes.accountItem}
extraAccountInfo={
<NumberFields>
<NumberFieldsItem
label={
<FormattedMessage
id='account.followers'
defaultMessage='Followers'
/>
}
hint={intl.formatNumber(account.followers_count)}
>
<ShortNumber value={account.followers_count} />
</NumberFieldsItem>
<NumberFieldsItem
label={
<FormattedMessage id='account.posts' defaultMessage='Posts' />
}
hint={intl.formatNumber(account.statuses_count)}
>
<ShortNumber value={account.statuses_count} />
</NumberFieldsItem>
<NumberFieldsItem
label={
<FormattedMessage
id='account.last_active'
defaultMessage='Last active'
/>
}
>
<RelativeTimestamp
long
timestamp={account.last_status_at}
noFuture
/>
</NumberFieldsItem>
</NumberFields>
}
/>
{!withoutButton && <FollowButton accountId={accountId} />}
</div>
);
};
const RevokeControls: React.FC<{
collectionId: string;
collectionItem: CollectionAccountItem;
}> = ({ collectionId, collectionItem }) => {
const dispatch = useAppDispatch();
const confirmRevoke = useCallback(() => {
void dispatch(
openModal({
modalType: 'REVOKE_COLLECTION_INCLUSION',
modalProps: {
collectionId,
collectionItemId: collectionItem.id,
},
}),
);
}, [collectionId, collectionItem.id, dispatch]);
const { wasDismissed, dismiss } = useDismissible(
`collection-revoke-hint-${collectionItem.id}`,
);
if (wasDismissed) {
return null;
}
return (
<div className={classes.revokeControlWrapper}>
<Button secondary onClick={dismiss}>
<FormattedMessage
id='collections.detail.accept_inclusion'
defaultMessage='Okay'
/>
</Button>
<Button secondary onClick={confirmRevoke}>
<FormattedMessage
id='collections.detail.revoke_inclusion'
defaultMessage='Remove me'
/>
</Button>
{!withoutButton && <FollowButton compact accountId={accountId} />}
{isOwnAccount && (
<Button secondary compact onClick={onRevoke}>
<FormattedMessage
id='collections.detail.revoke_inclusion'
defaultMessage='Remove me'
/>
</Button>
)}
</div>
);
};
@ -159,46 +167,16 @@ const SensitiveScreen: React.FC<{
);
};
/**
* Returns the collection's account items. If the current user's account
* is part of the collection, it will be returned separately.
*/
function getCollectionItems(collection: ApiCollectionJSON | undefined) {
if (!collection)
return {
currentUserInCollection: null,
items: [],
};
const { account_id, items } = collection;
const isOwnCollection = account_id === me;
const currentUserIndex = items.findIndex(
(account) => account.account_id === me,
);
if (isOwnCollection || currentUserIndex === -1) {
return {
currentUserInCollection: null,
items,
};
} else {
return {
currentUserInCollection: items.at(currentUserIndex) ?? null,
items: items.toSpliced(currentUserIndex, 1),
};
}
}
export const CollectionAccountsList: React.FC<{
collection?: ApiCollectionJSON;
isLoading: boolean;
}> = ({ collection, isLoading }) => {
const intl = useIntl();
const confirmRevoke = useConfirmRevoke(collection);
const listHeadingRef = useRef<HTMLHeadingElement>(null);
const isOwnCollection = collection?.account_id === me;
const { items, currentUserInCollection } = getCollectionItems(collection);
const { items = [] } = collection ?? {};
return (
<ItemList
@ -206,80 +184,40 @@ export const CollectionAccountsList: React.FC<{
emptyMessage={intl.formatMessage(messages.empty)}
className={classes.itemList}
>
{collection && currentUserInCollection ? (
<>
<h3 className={classes.columnSubheading}>
<FormattedMessage
id='collections.detail.you_were_added_to_this_collection'
defaultMessage='You were added to this collection'
values={{
author: <SimpleAuthorName id={collection.account_id} />,
}}
/>
</h3>
<Article
key={currentUserInCollection.account_id}
aria-posinset={1}
aria-setsize={items.length}
className={classes.youWereAddedWrapper}
>
<AccountItem
withBorder={false}
withBio={false}
accountId={currentUserInCollection.account_id}
collectionOwnerId={collection.account_id}
/>
<RevokeControls
collectionId={collection.id}
collectionItem={currentUserInCollection}
/>
</Article>
<h3
className={classes.columnSubheading}
tabIndex={-1}
ref={listHeadingRef}
>
<FormattedMessage
id='collections.detail.other_accounts_count'
defaultMessage='{count, plural, one {# other account} other {# other accounts}}'
values={{ count: collection.item_count - 1 }}
/>
</h3>
</>
) : (
<h3
className={classes.columnSubheading}
tabIndex={-1}
ref={listHeadingRef}
>
{collection ? (
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
/>
) : (
<FormattedMessage
id='collections.detail.accounts_heading'
defaultMessage='Accounts'
/>
)}
</h3>
)}
<h3
className={classes.columnSubheading}
tabIndex={-1}
ref={listHeadingRef}
>
{collection ? (
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
/>
) : (
<FormattedMessage
id='collections.detail.accounts_heading'
defaultMessage='Accounts'
/>
)}
</h3>
{collection && (
<SensitiveScreen
sensitive={!isOwnCollection && collection.sensitive}
focusTargetRef={listHeadingRef}
>
{items.map(({ account_id }, index, items) => (
{items.map(({ account_id }, index) => (
<Article
key={account_id}
aria-posinset={index + (currentUserInCollection ? 2 : 1)}
aria-posinset={index + 1}
aria-setsize={items.length}
>
<AccountItem
withBorder={index !== items.length - 1}
accountId={account_id}
collectionOwnerId={collection.account_id}
onRevoke={confirmRevoke}
/>
</Article>
))}

View File

@ -11,17 +11,20 @@ import { useAccountHandle } from '@/mastodon/components/display_name/default';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Callout } from 'mastodon/components/callout';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { DisplayName } from 'mastodon/components/display_name';
import { IconButton } from 'mastodon/components/icon_button';
import { Scrollable } from 'mastodon/components/scrollable_list/components';
import { useAccount } from 'mastodon/hooks/useAccount';
import { domain } from 'mastodon/initial_state';
import { domain, me } from 'mastodon/initial_state';
import { fetchCollection } from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollectionAccountsList } from './accounts_list';
import { CollectionMenu } from './collection_menu';
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
import classes from './styles.module.scss';
const messages = defineMessages({
@ -62,14 +65,54 @@ export const AuthorNote: React.FC<{ id: string }> = ({ id }) => {
);
};
export const RevokeControls: React.FC<{
collection: ApiCollectionJSON;
}> = ({ collection }) => {
const authorAccount = useAccount(collection.account_id);
const confirmRevoke = useConfirmRevoke(collection);
return (
<Callout
title={
<FormattedMessage
id='collections.detail.you_are_in_this_collection'
defaultMessage="You're featured in this collection"
/>
}
primaryLabel={
<FormattedMessage
id='collections.detail.revoke_inclusion'
defaultMessage='Remove me'
/>
}
onPrimary={confirmRevoke}
>
<FormattedMessage
id='collections.detail.author_added_you_on_date'
defaultMessage='{author} added you on {date}'
values={{
author: <DisplayName account={authorAccount} variant='simple' />,
date: '{date}', // TODO: Data not yet provided by API
}}
/>
</Callout>
);
};
const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection,
}) => {
const intl = useIntl();
const { name, description, tag, account_id } = collection;
const { name, description, tag, account_id, items } = collection;
const dispatch = useAppDispatch();
const history = useHistory();
const isOwnCollection = account_id === me;
const currentUserIndex = items.findIndex(
(account) => account.account_id === me,
);
const isCurrentUserInCollection = !isOwnCollection && currentUserIndex > -1;
const handleShare = useCallback(() => {
dispatch(
openModal({
@ -115,6 +158,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
</div>
</div>
{description && <p className={classes.description}>{description}</p>}
{isCurrentUserInCollection && <RevokeControls collection={collection} />}
</header>
);
};

View File

@ -3,8 +3,11 @@ import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { showAlert } from 'mastodon/actions/alerts';
import { openModal } from 'mastodon/actions/modal';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import type { BaseConfirmationModalProps } from 'mastodon/features/ui/components/confirmation_modals/confirmation_modal';
import { ConfirmationModal } from 'mastodon/features/ui/components/confirmation_modals/confirmation_modal';
import { me } from 'mastodon/initial_state';
import { revokeCollectionInclusion } from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
@ -24,6 +27,24 @@ const messages = defineMessages({
},
});
export function useConfirmRevoke(collection?: ApiCollectionJSON) {
const dispatch = useAppDispatch();
const { id, items = [] } = collection ?? {};
const ownCollectionItemId = items.find((item) => item.account_id === me)?.id;
return useCallback(() => {
void dispatch(
openModal({
modalType: 'REVOKE_COLLECTION_INCLUSION',
modalProps: {
collectionId: id,
collectionItemId: ownCollectionItemId,
},
}),
);
}, [dispatch, id, ownCollectionItemId]);
}
export const RevokeCollectionInclusionModal: React.FC<
{
collectionId: string;

View File

@ -1,4 +1,7 @@
.header {
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px;
}
@ -49,7 +52,6 @@
.description {
font-size: 15px;
margin-top: 12px;
}
.headerButtonWrapper {
@ -59,8 +61,14 @@
.iconButton {
box-sizing: content-box;
padding: 5px;
padding: 7px;
border-radius: 4px;
border: 1px solid var(--color-border-primary);
svg {
width: 20px;
height: 20px;
}
}
.itemList {
@ -81,6 +89,18 @@
&[data-with-border='true'] {
border-bottom: 1px solid var(--color-border-primary);
}
:global(.account__note) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
}
// Hide 'No description provided' message added by `Account` component
:global(.account__note--missing) {
display: none;
}
}
.accountItem {
@ -93,26 +113,3 @@
flex-grow: 1;
}
.youWereAddedWrapper {
padding-bottom: 16px;
}
.revokeControlWrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 8px;
:global(.button) {
min-width: 30%;
white-space: normal;
}
--avatar-width: 46px;
@container (width < 360px) {
--avatar-width: 35px;
}
}

View File

@ -182,7 +182,7 @@ export const CollectionDetails: React.FC = () => {
/>
<TextAreaField
required
required={false}
label={
<FormattedMessage
id='collections.collection_description'

View File

@ -1,11 +1,5 @@
import {
isClientFeatureEnabled,
isServerFeatureEnabled,
} from '@/mastodon/utils/environment';
import { isServerFeatureEnabled } from '@/mastodon/utils/environment';
export function areCollectionsEnabled() {
return (
isClientFeatureEnabled('collections') &&
isServerFeatureEnabled('collections')
);
return isServerFeatureEnabled('collections');
}

View File

@ -105,7 +105,7 @@ export const NotificationWithStatus: React.FC<{
<div className='notification-ungrouped__header__icon'>
<Icon icon={icon} id={iconId} />
</div>
{label}
<span>{label}</span>
</div>
<StatusQuoteManager

View File

@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { isDarkMode } from '../utils/theme';
export function useTheme() {
const [darkMode, setDarkMode] = useState(() => isDarkMode());
useEffect(() => {
const mutationObserver = new MutationObserver(() => {
setDarkMode(isDarkMode());
});
mutationObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-color-scheme'],
});
return () => {
mutationObserver.disconnect();
};
}, []);
return darkMode ? 'dark' : 'light';
}

View File

@ -45,9 +45,6 @@
"account.featured": "Featured",
"account.featured.accounts": "Profiles",
"account.featured.collections": "Collections",
"account.featured.hashtags": "Hashtags",
"account.featured_tags.last_status_at": "Last post on {date}",
"account.featured_tags.last_status_never": "No posts",
"account.field_overflow": "Show full content",
"account.filters.all": "All activity",
"account.filters.boosts_toggle": "Show boosts",
@ -75,6 +72,7 @@
"account.in_memoriam": "In Memoriam.",
"account.joined_short": "Joined",
"account.languages": "Change subscribed languages",
"account.last_active": "Last active",
"account.link_verified_on": "Ownership of this link was checked on {date}",
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
"account.media": "Media",
@ -375,15 +373,14 @@
"collections.create_collection": "Create collection",
"collections.delete_collection": "Delete collection",
"collections.description_length_hint": "100 characters limit",
"collections.detail.accept_inclusion": "Okay",
"collections.detail.accounts_heading": "Accounts",
"collections.detail.author_added_you_on_date": "{author} added you on {date}",
"collections.detail.loading": "Loading collection…",
"collections.detail.other_accounts_count": "{count, plural, one {# other account} other {# other accounts}}",
"collections.detail.revoke_inclusion": "Remove me",
"collections.detail.sensitive_content": "Sensitive content",
"collections.detail.sensitive_note": "This collection contains accounts and content that may be sensitive to some users.",
"collections.detail.share": "Share this collection",
"collections.detail.you_were_added_to_this_collection": "You were added to this collection",
"collections.detail.you_are_in_this_collection": "You're featured in this collection",
"collections.edit_details": "Edit details",
"collections.error_loading_collections": "There was an error when trying to load your collections.",
"collections.hints.accounts_counter": "{count} / {max} accounts",
@ -606,9 +603,15 @@
"emoji_button.search_results": "Search results",
"emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places",
"empty_column.account_featured.me": "You have not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?",
"empty_column.account_featured.other": "{acct} has not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?",
"empty_column.account_featured_other.unknown": "This account has not featured anything yet.",
"empty_column.account_featured_other.no_collections_desc": "{acct} hasnt created any collections yet.",
"empty_column.account_featured_other.title": "Nothing to see here",
"empty_column.account_featured_self.no_collections": "No collections yet",
"empty_column.account_featured_self.no_collections_button": "Create a collection",
"empty_column.account_featured_self.pre_collections": "Stay tuned for Collections",
"empty_column.account_featured_self.pre_collections_desc": "Collections (coming in Mastodon 4.6) allows you to create your own curated lists of accounts to recommend to others.",
"empty_column.account_featured_unknown.no_collections_desc": "This account hasnt created any collections yet.",
"empty_column.account_featured_unknown.other": "This account hasnt featured anything yet.",
"empty_column.account_hides_collections": "This user has chosen to not make this information available",
"empty_column.account_suspended": "Account suspended",
"empty_column.account_timeline": "No posts here!",

View File

@ -62,6 +62,7 @@ export const accountDefaultValues: AccountShape = {
acct: '',
avatar: '',
avatar_static: '',
avatar_description: '',
bot: false,
created_at: '',
discoverable: false,
@ -78,6 +79,7 @@ export const accountDefaultValues: AccountShape = {
group: false,
header: '',
header_static: '',
header_description: '',
id: '',
last_status_at: '',
locked: false,

View File

@ -18,7 +18,7 @@ export function isServerFeatureEnabled(feature: ServerFeatures) {
return initialState?.features.includes(feature) ?? false;
}
type ClientFeatures = 'collections';
type ClientFeatures = never;
export function isClientFeatureEnabled(feature: ClientFeatures) {
try {

View File

@ -25,6 +25,7 @@ export const accountFactory: FactoryFunction<ApiAccountJSON> = ({
acct: 'testuser',
avatar: '/avatars/original/missing.png',
avatar_static: '/avatars/original/missing.png',
avatar_description: '',
username: 'testuser',
display_name: 'Test User',
bot: false,
@ -42,6 +43,7 @@ export const accountFactory: FactoryFunction<ApiAccountJSON> = ({
group: false,
header: '/header.png',
header_static: '/header_static.png',
header_description: '',
indexable: true,
last_status_at: '2023-01-01',
locked: false,

View File

@ -36,11 +36,9 @@ class Collection < ApplicationRecord
validates :name, length: { maximum: 40 }, if: :local?
validates :name, length: { maximum: NAME_LENGTH_HARD_LIMIT }, if: :remote?
validates :description,
presence: true,
length: { maximum: 100 },
if: :local?
validates :description_html,
presence: true,
length: { maximum: DESCRIPTION_LENGTH_HARD_LIMIT },
if: :remote?
validates :local, inclusion: [true, false]

View File

@ -12,7 +12,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
context_extensions :interaction_policies if Mastodon::Feature.collections_enabled?
attributes :id, :type, :following, :followers,
attributes :id, :webfinger, :type, :following, :followers,
:inbox, :outbox, :featured, :featured_tags,
:preferred_username, :name, :summary,
:url, :manually_approves_followers,

View File

@ -15,6 +15,7 @@ class REST::CollectionSerializer < ActiveModel::Serializer
def description
return object.description if object.local?
return if object.description_html.nil?
Sanitize.fragment(object.description_html, Sanitize::Config::MASTODON_STRICT)
end

View File

@ -6,7 +6,7 @@
%li= link_to_login t('auth.login')
- if controller_name != 'registrations'
%li= link_to t('auth.register'), available_sign_up_path
%li= link_to t('auth.register'), available_sign_up_url
- if controller_name != 'passwords' && controller_name != 'registrations'
%li= link_to t('auth.forgot_password'), new_user_password_path

View File

@ -27,7 +27,7 @@
/[if mso]
</td><td style="vertical-align:top;">
%div
= render 'application/mailer/button', text: t('.create_account'), url: available_sign_up_path, has_arrow: false
= render 'application/mailer/button', text: t('.create_account'), url: available_sign_up_url, has_arrow: false
/[if mso]
</td></tr></table>

View File

@ -94,14 +94,14 @@ RSpec.describe ApplicationHelper do
end
end
describe 'available_sign_up_path' do
describe 'available_sign_up_url' do
context 'when registrations are closed' do
before do
allow(Setting).to receive(:[]).with('registrations_mode').and_return 'none'
end
it 'redirects to joinmastodon site' do
expect(helper.available_sign_up_path).to match(/joinmastodon.org/)
expect(helper.available_sign_up_url).to match(/joinmastodon.org/)
end
end
@ -113,13 +113,13 @@ RSpec.describe ApplicationHelper do
end
it 'redirects to joinmastodon site' do
expect(helper.available_sign_up_path).to match(/joinmastodon.org/)
expect(helper.available_sign_up_url).to match(/joinmastodon.org/)
end
end
context 'when registrations are allowed' do
it 'returns a link to the registration page' do
expect(helper.available_sign_up_path).to eq(new_user_registration_path)
expect(helper.available_sign_up_url).to eq(new_user_registration_url)
end
end
end

View File

@ -10,8 +10,6 @@ RSpec.describe Collection do
it { is_expected.to validate_length_of(:name).is_at_most(40) }
it { is_expected.to validate_presence_of(:description) }
it { is_expected.to validate_length_of(:description).is_at_most(100) }
it { is_expected.to_not allow_value(nil).for(:local) }
@ -29,10 +27,6 @@ RSpec.describe Collection do
it { is_expected.to validate_length_of(:name).is_at_most(Collection::NAME_LENGTH_HARD_LIMIT) }
it { is_expected.to_not validate_presence_of(:description) }
it { is_expected.to validate_presence_of(:description_html) }
it { is_expected.to validate_length_of(:description_html).is_at_most(Collection::DESCRIPTION_LENGTH_HARD_LIMIT) }
it { is_expected.to validate_presence_of(:uri) }

View File

@ -180,7 +180,6 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
'error' => a_hash_including({
'details' => a_hash_including({
'name' => [{ 'error' => 'ERR_BLANK', 'description' => "can't be blank" }],
'description' => [{ 'error' => 'ERR_BLANK', 'description' => "can't be blank" }],
}),
}),
})