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' Setting.registrations_mode == 'none'
end end
def available_sign_up_path def available_sign_up_url
if closed_registrations? || omniauth_only? if closed_registrations? || omniauth_only?
'https://joinmastodon.org/#getting-started' 'https://joinmastodon.org/'
else else
ENV.fetch('SSO_ACCOUNT_SIGN_UP', new_user_registration_path) ENV.fetch('SSO_ACCOUNT_SIGN_UP', new_user_registration_url)
end end
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) { export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => { return (dispatch, getState) => {
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); const filterType = notification.type === 'quoted_update' ? 'update' : notification.type;
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', filterType], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', filterType], true);
let filtered = false; let filtered = false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,15 @@
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint'; import ElephantDarkImage from '@/images/elephant_ui_dark.svg?react';
import { me } from 'mastodon/initial_state'; 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 { interface EmptyMessageProps {
suspended: boolean; suspended: boolean;
@ -19,21 +25,59 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
blockedBy, blockedBy,
}) => { }) => {
const { acct } = useParams<{ acct?: string }>(); const { acct } = useParams<{ acct?: string }>();
const me = useCurrentAccountId();
const theme = useTheme();
const ElephantImage =
theme === 'dark' ? ElephantDarkImage : ElephantLightImage;
if (!accountId) { if (!accountId) {
return null; return null;
} }
let title: React.ReactNode = null;
let message: React.ReactNode = null; let message: React.ReactNode = null;
const hasCollections = areCollectionsEnabled();
const image = <ElephantImage />;
if (me === accountId) { if (me === accountId) {
message = ( 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 <FormattedMessage
id='empty_column.account_featured.me' id='empty_column.account_featured_self.no_collections'
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?' 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'
/> />
); );
} else if (suspended) {
message = ( 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) {
title = (
<FormattedMessage <FormattedMessage
id='empty_column.account_suspended' id='empty_column.account_suspended'
defaultMessage='Account suspended' defaultMessage='Account suspended'
@ -42,32 +86,56 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
} else if (hidden) { } else if (hidden) {
message = <LimitedAccountHint accountId={accountId} />; message = <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) { } else if (blockedBy) {
message = ( title = (
<FormattedMessage <FormattedMessage
id='empty_column.account_unavailable' id='empty_column.account_unavailable'
defaultMessage='Profile unavailable' defaultMessage='Profile unavailable'
/> />
); );
} else if (acct) { } else {
// Standard other account empty state.
title = (
<FormattedMessage
id='empty_column.account_featured_other.title'
defaultMessage='Nothing to see here'
/>
);
if (hasCollections) {
if (acct) {
message = ( message = (
<FormattedMessage <FormattedMessage
id='empty_column.account_featured.other' id='empty_column.account_featured_other.no_collections_desc'
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?' defaultMessage='{acct} hasnt created any collections yet.'
values={{ acct }} values={{ acct }}
/> />
); );
} else { } else {
message = ( message = (
<FormattedMessage <FormattedMessage
id='empty_column.account_featured_other.unknown' id='empty_column.account_featured_unknown.no_collections_desc'
defaultMessage='This account has not featured anything yet.' defaultMessage='This account hasnt created any collections yet.'
/> />
); );
} }
} else {
return ( if (acct) {
<div className='empty-column-indicator'> message = (
<span>{message}</span> <FormattedMessage
</div> 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 <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 { useAccount } from '@/mastodon/hooks/useAccount';
import { fetchEndorsedAccounts } from 'mastodon/actions/accounts'; import { fetchEndorsedAccounts } from 'mastodon/actions/accounts';
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
import { Account } from 'mastodon/components/account'; import { Account } from 'mastodon/components/account';
import { ColumnBackButton } from 'mastodon/components/column_back_button'; import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; 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 { areCollectionsEnabled } from '../collections/utils';
import { EmptyMessage } from './components/empty_message'; 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 }> = ({ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
multiColumn, multiColumn,
@ -55,26 +52,15 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
useEffect(() => { useEffect(() => {
if (accountId) { if (accountId) {
void dispatch(fetchFeaturedTags({ accountId }));
void dispatch(fetchEndorsedAccounts({ accountId })); void dispatch(fetchEndorsedAccounts({ accountId }));
if (areCollectionsEnabled()) { if (areCollectionsEnabled()) {
void dispatch(fetchAccountCollections({ accountId })); void dispatch(fetchAccountCollections({ accountId }));
} }
} }
}, [accountId, dispatch]); }, [accountId, dispatch]);
const isLoading = useAppSelector( const isLoading = !accountId;
(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 featuredAccountIds = useAppSelector( const featuredAccountIds = useAppSelector(
(state) => (state) =>
state.user_lists.getIn( state.user_lists.getIn(
@ -106,13 +92,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
); );
} }
const noTags = featuredTags.isEmpty(); if (featuredAccountIds.isEmpty() && listedCollections.length === 0) {
if (
noTags &&
featuredAccountIds.isEmpty() &&
listedCollections.length === 0
) {
return ( return (
<AccountFeaturedWrapper accountId={accountId}> <AccountFeaturedWrapper accountId={accountId}>
<EmptyMessage <EmptyMessage
@ -156,28 +136,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
</ItemList> </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() && ( {!featuredAccountIds.isEmpty() && (
<> <>
<h4 className='column-subheading'> <h4 className='column-subheading'>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,26 +2,26 @@ import { useCallback, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Callout } from '@/mastodon/components/callout'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { FollowButton } from '@/mastodon/components/follow_button';
import { openModal } from 'mastodon/actions/modal';
import type {
ApiCollectionJSON,
CollectionAccountItem,
} from 'mastodon/api_types/collections';
import { Account } from 'mastodon/components/account'; import { Account } from 'mastodon/components/account';
import { Button } from 'mastodon/components/button'; 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 { import {
Article, Article,
ItemList, ItemList,
} from 'mastodon/components/scrollable_list/components'; } from 'mastodon/components/scrollable_list/components';
import { ShortNumber } from 'mastodon/components/short_number';
import { useAccount } from 'mastodon/hooks/useAccount'; import { useAccount } from 'mastodon/hooks/useAccount';
import { useDismissible } from 'mastodon/hooks/useDismissible';
import { useRelationship } from 'mastodon/hooks/useRelationship'; import { useRelationship } from 'mastodon/hooks/useRelationship';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
import { useAppDispatch } from 'mastodon/store';
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
const messages = defineMessages({ 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<{ const AccountItem: React.FC<{
accountId: string | undefined; accountId: string | undefined;
collectionOwnerId: string; collectionOwnerId: string;
onRevoke: () => void;
withBio?: boolean; withBio?: boolean;
withBorder?: 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); const relationship = useRelationship(accountId);
if (!accountId) { if (!accountId || !account) {
return null; return null;
} }
// When viewing your own collection, only show the Follow button // When viewing your own collection, only show the Follow button
// for accounts you're not following (anymore). // for accounts you're not following (anymore).
// Otherwise, always show the follow button in its various states. // Otherwise, always show the follow button in its various states.
const isOwnAccount = accountId === me;
const withoutButton = const withoutButton =
accountId === me || isOwnAccount ||
!relationship || !relationship ||
(collectionOwnerId === me && (collectionOwnerId === me &&
(relationship.following || relationship.requested)); (relationship.following || relationship.requested));
@ -66,52 +71,55 @@ const AccountItem: React.FC<{
withBorder={false} withBorder={false}
withMenu={false} withMenu={false}
className={classes.accountItem} className={classes.accountItem}
/> extraAccountInfo={
{!withoutButton && <FollowButton accountId={accountId} />} <NumberFields>
</div> <NumberFieldsItem
); label={
};
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 <FormattedMessage
id='collections.detail.accept_inclusion' id='account.followers'
defaultMessage='Okay' defaultMessage='Followers'
/> />
</Button> }
<Button secondary onClick={confirmRevoke}> 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 compact accountId={accountId} />}
{isOwnAccount && (
<Button secondary compact onClick={onRevoke}>
<FormattedMessage <FormattedMessage
id='collections.detail.revoke_inclusion' id='collections.detail.revoke_inclusion'
defaultMessage='Remove me' defaultMessage='Remove me'
/> />
</Button> </Button>
)}
</div> </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<{ export const CollectionAccountsList: React.FC<{
collection?: ApiCollectionJSON; collection?: ApiCollectionJSON;
isLoading: boolean; isLoading: boolean;
}> = ({ collection, isLoading }) => { }> = ({ collection, isLoading }) => {
const intl = useIntl(); const intl = useIntl();
const confirmRevoke = useConfirmRevoke(collection);
const listHeadingRef = useRef<HTMLHeadingElement>(null); const listHeadingRef = useRef<HTMLHeadingElement>(null);
const isOwnCollection = collection?.account_id === me; const isOwnCollection = collection?.account_id === me;
const { items, currentUserInCollection } = getCollectionItems(collection); const { items = [] } = collection ?? {};
return ( return (
<ItemList <ItemList
@ -206,47 +184,6 @@ export const CollectionAccountsList: React.FC<{
emptyMessage={intl.formatMessage(messages.empty)} emptyMessage={intl.formatMessage(messages.empty)}
className={classes.itemList} 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 <h3
className={classes.columnSubheading} className={classes.columnSubheading}
tabIndex={-1} tabIndex={-1}
@ -265,21 +202,22 @@ export const CollectionAccountsList: React.FC<{
/> />
)} )}
</h3> </h3>
)}
{collection && ( {collection && (
<SensitiveScreen <SensitiveScreen
sensitive={!isOwnCollection && collection.sensitive} sensitive={!isOwnCollection && collection.sensitive}
focusTargetRef={listHeadingRef} focusTargetRef={listHeadingRef}
> >
{items.map(({ account_id }, index, items) => ( {items.map(({ account_id }, index) => (
<Article <Article
key={account_id} key={account_id}
aria-posinset={index + (currentUserInCollection ? 2 : 1)} aria-posinset={index + 1}
aria-setsize={items.length} aria-setsize={items.length}
> >
<AccountItem <AccountItem
withBorder={index !== items.length - 1}
accountId={account_id} accountId={account_id}
collectionOwnerId={collection.account_id} collectionOwnerId={collection.account_id}
onRevoke={confirmRevoke}
/> />
</Article> </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 ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react'; import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Callout } from 'mastodon/components/callout';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { DisplayName } from 'mastodon/components/display_name';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { Scrollable } from 'mastodon/components/scrollable_list/components'; import { Scrollable } from 'mastodon/components/scrollable_list/components';
import { useAccount } from 'mastodon/hooks/useAccount'; 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 { fetchCollection } from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollectionAccountsList } from './accounts_list'; import { CollectionAccountsList } from './accounts_list';
import { CollectionMenu } from './collection_menu'; import { CollectionMenu } from './collection_menu';
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
const messages = defineMessages({ 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 }> = ({ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection, collection,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { name, description, tag, account_id } = collection; const { name, description, tag, account_id, items } = collection;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const history = useHistory(); 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(() => { const handleShare = useCallback(() => {
dispatch( dispatch(
openModal({ openModal({
@ -115,6 +158,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
</div> </div>
</div> </div>
{description && <p className={classes.description}>{description}</p>} {description && <p className={classes.description}>{description}</p>}
{isCurrentUserInCollection && <RevokeControls collection={collection} />}
</header> </header>
); );
}; };

View File

@ -3,8 +3,11 @@ import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { showAlert } from 'mastodon/actions/alerts'; 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 type { BaseConfirmationModalProps } from 'mastodon/features/ui/components/confirmation_modals/confirmation_modal';
import { ConfirmationModal } 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 { revokeCollectionInclusion } from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; 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< export const RevokeCollectionInclusionModal: React.FC<
{ {
collectionId: string; collectionId: string;

View File

@ -1,4 +1,7 @@
.header { .header {
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px; padding: 24px;
} }
@ -49,7 +52,6 @@
.description { .description {
font-size: 15px; font-size: 15px;
margin-top: 12px;
} }
.headerButtonWrapper { .headerButtonWrapper {
@ -59,8 +61,14 @@
.iconButton { .iconButton {
box-sizing: content-box; box-sizing: content-box;
padding: 5px; padding: 7px;
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--color-border-primary);
svg {
width: 20px;
height: 20px;
}
} }
.itemList { .itemList {
@ -81,6 +89,18 @@
&[data-with-border='true'] { &[data-with-border='true'] {
border-bottom: 1px solid var(--color-border-primary); 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 { .accountItem {
@ -93,26 +113,3 @@
flex-grow: 1; 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 <TextAreaField
required required={false}
label={ label={
<FormattedMessage <FormattedMessage
id='collections.collection_description' id='collections.collection_description'

View File

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

View File

@ -105,7 +105,7 @@ export const NotificationWithStatus: React.FC<{
<div className='notification-ungrouped__header__icon'> <div className='notification-ungrouped__header__icon'>
<Icon icon={icon} id={iconId} /> <Icon icon={icon} id={iconId} />
</div> </div>
{label} <span>{label}</span>
</div> </div>
<StatusQuoteManager <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": "Featured",
"account.featured.accounts": "Profiles", "account.featured.accounts": "Profiles",
"account.featured.collections": "Collections", "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.field_overflow": "Show full content",
"account.filters.all": "All activity", "account.filters.all": "All activity",
"account.filters.boosts_toggle": "Show boosts", "account.filters.boosts_toggle": "Show boosts",
@ -75,6 +72,7 @@
"account.in_memoriam": "In Memoriam.", "account.in_memoriam": "In Memoriam.",
"account.joined_short": "Joined", "account.joined_short": "Joined",
"account.languages": "Change subscribed languages", "account.languages": "Change subscribed languages",
"account.last_active": "Last active",
"account.link_verified_on": "Ownership of this link was checked on {date}", "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.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
"account.media": "Media", "account.media": "Media",
@ -375,15 +373,14 @@
"collections.create_collection": "Create collection", "collections.create_collection": "Create collection",
"collections.delete_collection": "Delete collection", "collections.delete_collection": "Delete collection",
"collections.description_length_hint": "100 characters limit", "collections.description_length_hint": "100 characters limit",
"collections.detail.accept_inclusion": "Okay",
"collections.detail.accounts_heading": "Accounts", "collections.detail.accounts_heading": "Accounts",
"collections.detail.author_added_you_on_date": "{author} added you on {date}",
"collections.detail.loading": "Loading collection…", "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.revoke_inclusion": "Remove me",
"collections.detail.sensitive_content": "Sensitive content", "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.sensitive_note": "This collection contains accounts and content that may be sensitive to some users.",
"collections.detail.share": "Share this collection", "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.edit_details": "Edit details",
"collections.error_loading_collections": "There was an error when trying to load your collections.", "collections.error_loading_collections": "There was an error when trying to load your collections.",
"collections.hints.accounts_counter": "{count} / {max} accounts", "collections.hints.accounts_counter": "{count} / {max} accounts",
@ -606,9 +603,15 @@
"emoji_button.search_results": "Search results", "emoji_button.search_results": "Search results",
"emoji_button.symbols": "Symbols", "emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places", "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": "{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_hides_collections": "This user has chosen to not make this information available",
"empty_column.account_suspended": "Account suspended", "empty_column.account_suspended": "Account suspended",
"empty_column.account_timeline": "No posts here!", "empty_column.account_timeline": "No posts here!",

View File

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

View File

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

View File

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

View File

@ -36,11 +36,9 @@ class Collection < ApplicationRecord
validates :name, length: { maximum: 40 }, if: :local? validates :name, length: { maximum: 40 }, if: :local?
validates :name, length: { maximum: NAME_LENGTH_HARD_LIMIT }, if: :remote? validates :name, length: { maximum: NAME_LENGTH_HARD_LIMIT }, if: :remote?
validates :description, validates :description,
presence: true,
length: { maximum: 100 }, length: { maximum: 100 },
if: :local? if: :local?
validates :description_html, validates :description_html,
presence: true,
length: { maximum: DESCRIPTION_LENGTH_HARD_LIMIT }, length: { maximum: DESCRIPTION_LENGTH_HARD_LIMIT },
if: :remote? if: :remote?
validates :local, inclusion: [true, false] 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? 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, :inbox, :outbox, :featured, :featured_tags,
:preferred_username, :name, :summary, :preferred_username, :name, :summary,
:url, :manually_approves_followers, :url, :manually_approves_followers,

View File

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

View File

@ -6,7 +6,7 @@
%li= link_to_login t('auth.login') %li= link_to_login t('auth.login')
- if controller_name != 'registrations' - 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' - if controller_name != 'passwords' && controller_name != 'registrations'
%li= link_to t('auth.forgot_password'), new_user_password_path %li= link_to t('auth.forgot_password'), new_user_password_path

View File

@ -27,7 +27,7 @@
/[if mso] /[if mso]
</td><td style="vertical-align:top;"> </td><td style="vertical-align:top;">
%div %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] /[if mso]
</td></tr></table> </td></tr></table>

View File

@ -94,14 +94,14 @@ RSpec.describe ApplicationHelper do
end end
end end
describe 'available_sign_up_path' do describe 'available_sign_up_url' do
context 'when registrations are closed' do context 'when registrations are closed' do
before do before do
allow(Setting).to receive(:[]).with('registrations_mode').and_return 'none' allow(Setting).to receive(:[]).with('registrations_mode').and_return 'none'
end end
it 'redirects to joinmastodon site' do 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
end end
@ -113,13 +113,13 @@ RSpec.describe ApplicationHelper do
end end
it 'redirects to joinmastodon site' do 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
end end
context 'when registrations are allowed' do context 'when registrations are allowed' do
it 'returns a link to the registration page' 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 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_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 validate_length_of(:description).is_at_most(100) }
it { is_expected.to_not allow_value(nil).for(:local) } 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 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_length_of(:description_html).is_at_most(Collection::DESCRIPTION_LENGTH_HARD_LIMIT) }
it { is_expected.to validate_presence_of(:uri) } 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({ 'error' => a_hash_including({
'details' => a_hash_including({ 'details' => a_hash_including({
'name' => [{ 'error' => 'ERR_BLANK', 'description' => "can't be blank" }], 'name' => [{ 'error' => 'ERR_BLANK', 'description' => "can't be blank" }],
'description' => [{ 'error' => 'ERR_BLANK', 'description' => "can't be blank" }],
}), }),
}), }),
}) })