Merge commit 'dc923c6425559266f0665fe2e294f6e412783810' into glitch-soc/merge-upstream
This commit is contained in:
commit
cb62beca08
@ -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
|
||||
|
||||
|
||||
1
app/javascript/images/elephant_ui_dark.svg
Normal file
1
app/javascript/images/elephant_ui_dark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
1
app/javascript/images/elephant_ui_light.svg
Normal file
1
app/javascript/images/elephant_ui_light.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -10,6 +10,13 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
svg,
|
||||
img {
|
||||
width: 240px;
|
||||
max-width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 friend’s 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 friend’s 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} hasn’t created any collections yet.'
|
||||
values={{ acct }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_unknown.no_collections_desc'
|
||||
defaultMessage='This account hasn’t created any collections yet.'
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (acct) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured.other'
|
||||
defaultMessage='{acct} hasn’t featured anything yet.'
|
||||
values={{ acct }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_unknown.other'
|
||||
defaultMessage='This account hasn’t featured anything yet.'
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='empty-column-indicator'>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
return <EmptyState title={title} message={message} image={image} />;
|
||||
};
|
||||
|
||||
@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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'>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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' />
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,7 +182,7 @@ export const CollectionDetails: React.FC = () => {
|
||||
/>
|
||||
|
||||
<TextAreaField
|
||||
required
|
||||
required={false}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.collection_description'
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
23
app/javascript/mastodon/hooks/useTheme.ts
Normal file
23
app/javascript/mastodon/hooks/useTheme.ts
Normal 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';
|
||||
}
|
||||
@ -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 friend’s 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 friend’s 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} hasn’t 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 hasn’t created any collections yet.",
|
||||
"empty_column.account_featured_unknown.other": "This account hasn’t 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!",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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" }],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user