Merge pull request #3466 from glitch-soc/glitch-soc/merge-upstream
Merge upstream changes up to dc923c6425559266f0665fe2e294f6e412783810
This commit is contained in:
commit
3c0f43b5a7
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
@ -66,7 +68,7 @@ export const Avatar: React.FC<Props> = ({
|
|||||||
data-avatar-of={account && `@${account.acct}`}
|
data-avatar-of={account && `@${account.acct}`}
|
||||||
>
|
>
|
||||||
{src && !error && (
|
{src && !error && (
|
||||||
<img src={src} alt='' onLoad={handleLoad} onError={handleError} />
|
<img src={src} alt={alt} onLoad={handleLoad} onError={handleError} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{counter && (
|
{counter && (
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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}>
|
||||||
<div className={classes.content}>
|
{(title || message || image) && (
|
||||||
<h3>{title}</h3>
|
<div className={classes.content}>
|
||||||
{!!message && <p>{message}</p>}
|
{image}
|
||||||
</div>
|
{!!title && <h3>{title}</h3>}
|
||||||
|
{!!message && <p>{message}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 'flavours/glitch/features/account_timeline/components/limited_account_hint';
|
import { EmptyState } from '@/flavours/glitch/components/empty_state';
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
import { LimitedAccountHint } from '@/flavours/glitch/features/account_timeline/components/limited_account_hint';
|
||||||
|
import { areCollectionsEnabled } from '@/flavours/glitch/features/collections/utils';
|
||||||
|
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||||
|
import { useTheme } from '@/flavours/glitch/hooks/useTheme';
|
||||||
|
import ElephantDarkImage from '@/images/elephant_ui_dark.svg?react';
|
||||||
|
import ElephantLightImage from '@/images/elephant_ui_light.svg?react';
|
||||||
|
|
||||||
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) {
|
||||||
<FormattedMessage
|
// Return only here to insert the "Create a collection" button as the action for the empty state.
|
||||||
id='empty_column.account_featured.me'
|
return (
|
||||||
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?'
|
<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) {
|
} else if (suspended) {
|
||||||
message = (
|
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) {
|
|
||||||
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 {
|
} else {
|
||||||
message = (
|
// Standard other account empty state.
|
||||||
|
title = (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='empty_column.account_featured_other.unknown'
|
id='empty_column.account_featured_other.title'
|
||||||
defaultMessage='This account has not featured anything yet.'
|
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 (
|
return <EmptyState title={title} message={message} image={image} />;
|
||||||
<div className='empty-column-indicator'>
|
|
||||||
<span>{message}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import type { Map as ImmutableMap } from 'immutable';
|
|
||||||
|
|
||||||
import { Hashtag } from 'flavours/glitch/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 '@/flavours/glitch/hooks/useAccount';
|
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||||
import { fetchEndorsedAccounts } from 'flavours/glitch/actions/accounts';
|
import { fetchEndorsedAccounts } from 'flavours/glitch/actions/accounts';
|
||||||
import { fetchFeaturedTags } from 'flavours/glitch/actions/featured_tags';
|
|
||||||
import { Account } from 'flavours/glitch/components/account';
|
import { Account } from 'flavours/glitch/components/account';
|
||||||
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from 'flavours/glitch/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'>
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export const AccountHeader: React.FC<{
|
|||||||
modalType: 'IMAGE',
|
modalType: 'IMAGE',
|
||||||
modalProps: {
|
modalProps: {
|
||||||
src: account.avatar,
|
src: account.avatar,
|
||||||
alt: '',
|
alt: account.avatar_description,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -125,7 +125,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'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -152,6 +152,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>
|
||||||
@ -174,6 +175,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} />
|
||||||
)}
|
)}
|
||||||
@ -200,8 +203,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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' />
|
||||||
|
|||||||
@ -9,11 +9,7 @@
|
|||||||
|
|
||||||
.barWrapper {
|
.barWrapper {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
padding-inline: 24px;
|
padding-inline: 16px;
|
||||||
|
|
||||||
@container (width < 500px) {
|
|
||||||
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;
|
padding: 0 16px;
|
||||||
|
|
||||||
@container (width < 500px) {
|
@container (width < 500px) {
|
||||||
padding: 0 16px;
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
flex: 1 1 0px;
|
flex: 1 1 0px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { NavLink } from 'react-router-dom';
|
|||||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||||
import { useAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
import { useAccountId } from '@/flavours/glitch/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`}>
|
||||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
{areCollectionsEnabled() ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.featured.collections'
|
||||||
|
defaultMessage='Collections'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 '@/flavours/glitch/components/callout';
|
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
|
||||||
import { FollowButton } from '@/flavours/glitch/components/follow_button';
|
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
|
||||||
import type {
|
|
||||||
ApiCollectionJSON,
|
|
||||||
CollectionAccountItem,
|
|
||||||
} from 'flavours/glitch/api_types/collections';
|
|
||||||
import { Account } from 'flavours/glitch/components/account';
|
import { Account } from 'flavours/glitch/components/account';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
import { Callout } from 'flavours/glitch/components/callout';
|
||||||
|
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||||
|
import {
|
||||||
|
NumberFields,
|
||||||
|
NumberFieldsItem,
|
||||||
|
} from 'flavours/glitch/components/number_fields';
|
||||||
|
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
||||||
import {
|
import {
|
||||||
Article,
|
Article,
|
||||||
ItemList,
|
ItemList,
|
||||||
} from 'flavours/glitch/components/scrollable_list/components';
|
} from 'flavours/glitch/components/scrollable_list/components';
|
||||||
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
import { useAccount } from 'flavours/glitch/hooks/useAccount';
|
import { useAccount } from 'flavours/glitch/hooks/useAccount';
|
||||||
import { useDismissible } from 'flavours/glitch/hooks/useDismissible';
|
|
||||||
import { useRelationship } from 'flavours/glitch/hooks/useRelationship';
|
import { useRelationship } from 'flavours/glitch/hooks/useRelationship';
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
import { useAppDispatch } from 'flavours/glitch/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={
|
||||||
|
<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} />}
|
{!withoutButton && <FollowButton compact accountId={accountId} />}
|
||||||
</div>
|
{isOwnAccount && (
|
||||||
);
|
<Button secondary compact onClick={onRevoke}>
|
||||||
};
|
<FormattedMessage
|
||||||
|
id='collections.detail.revoke_inclusion'
|
||||||
const RevokeControls: React.FC<{
|
defaultMessage='Remove me'
|
||||||
collectionId: string;
|
/>
|
||||||
collectionItem: CollectionAccountItem;
|
</Button>
|
||||||
}> = ({ 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>
|
|
||||||
</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,80 +184,40 @@ 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}
|
||||||
<h3 className={classes.columnSubheading}>
|
tabIndex={-1}
|
||||||
<FormattedMessage
|
ref={listHeadingRef}
|
||||||
id='collections.detail.you_were_added_to_this_collection'
|
>
|
||||||
defaultMessage='You were added to this collection'
|
{collection ? (
|
||||||
values={{
|
<FormattedMessage
|
||||||
author: <SimpleAuthorName id={collection.account_id} />,
|
id='collections.account_count'
|
||||||
}}
|
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
||||||
/>
|
values={{ count: collection.item_count }}
|
||||||
</h3>
|
/>
|
||||||
<Article
|
) : (
|
||||||
key={currentUserInCollection.account_id}
|
<FormattedMessage
|
||||||
aria-posinset={1}
|
id='collections.detail.accounts_heading'
|
||||||
aria-setsize={items.length}
|
defaultMessage='Accounts'
|
||||||
className={classes.youWereAddedWrapper}
|
/>
|
||||||
>
|
)}
|
||||||
<AccountItem
|
</h3>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -11,17 +11,20 @@ import { useAccountHandle } from '@/flavours/glitch/components/display_name/defa
|
|||||||
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 'flavours/glitch/api_types/collections';
|
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
|
||||||
|
import { Callout } from 'flavours/glitch/components/callout';
|
||||||
import { Column } from 'flavours/glitch/components/column';
|
import { Column } from 'flavours/glitch/components/column';
|
||||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||||
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
import { Scrollable } from 'flavours/glitch/components/scrollable_list/components';
|
import { Scrollable } from 'flavours/glitch/components/scrollable_list/components';
|
||||||
import { useAccount } from 'flavours/glitch/hooks/useAccount';
|
import { useAccount } from 'flavours/glitch/hooks/useAccount';
|
||||||
import { domain } from 'flavours/glitch/initial_state';
|
import { domain, me } from 'flavours/glitch/initial_state';
|
||||||
import { fetchCollection } from 'flavours/glitch/reducers/slices/collections';
|
import { fetchCollection } from 'flavours/glitch/reducers/slices/collections';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,8 +3,11 @@ import { useCallback } from 'react';
|
|||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { showAlert } from 'flavours/glitch/actions/alerts';
|
import { showAlert } from 'flavours/glitch/actions/alerts';
|
||||||
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
|
||||||
import type { BaseConfirmationModalProps } from 'flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal';
|
import type { BaseConfirmationModalProps } from 'flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal';
|
||||||
import { ConfirmationModal } from 'flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal';
|
import { ConfirmationModal } from 'flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal';
|
||||||
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
import { revokeCollectionInclusion } from 'flavours/glitch/reducers/slices/collections';
|
import { revokeCollectionInclusion } from 'flavours/glitch/reducers/slices/collections';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
import {
|
import { isServerFeatureEnabled } from '@/flavours/glitch/utils/environment';
|
||||||
isClientFeatureEnabled,
|
|
||||||
isServerFeatureEnabled,
|
|
||||||
} from '@/flavours/glitch/utils/environment';
|
|
||||||
|
|
||||||
export function areCollectionsEnabled() {
|
export function areCollectionsEnabled() {
|
||||||
return (
|
return isServerFeatureEnabled('collections');
|
||||||
isClientFeatureEnabled('collections') &&
|
|
||||||
isServerFeatureEnabled('collections')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,7 +109,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
|
||||||
|
|||||||
23
app/javascript/flavours/glitch/hooks/useTheme.ts
Normal file
23
app/javascript/flavours/glitch/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';
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
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) {
|
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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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}>
|
||||||
<div className={classes.content}>
|
{(title || message || image) && (
|
||||||
<h3>{title}</h3>
|
<div className={classes.content}>
|
||||||
{!!message && <p>{message}</p>}
|
{image}
|
||||||
</div>
|
{!!title && <h3>{title}</h3>}
|
||||||
|
{!!message && <p>{message}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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) {
|
||||||
<FormattedMessage
|
// Return only here to insert the "Create a collection" button as the action for the empty state.
|
||||||
id='empty_column.account_featured.me'
|
return (
|
||||||
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?'
|
<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) {
|
} else if (suspended) {
|
||||||
message = (
|
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) {
|
|
||||||
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 {
|
} else {
|
||||||
message = (
|
// Standard other account empty state.
|
||||||
|
title = (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='empty_column.account_featured_other.unknown'
|
id='empty_column.account_featured_other.title'
|
||||||
defaultMessage='This account has not featured anything yet.'
|
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 (
|
return <EmptyState title={title} message={message} image={image} />;
|
||||||
<div className='empty-column-indicator'>
|
|
||||||
<span>{message}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 { 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'>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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' />
|
||||||
|
|||||||
@ -9,11 +9,7 @@
|
|||||||
|
|
||||||
.barWrapper {
|
.barWrapper {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
padding-inline: 24px;
|
padding-inline: 16px;
|
||||||
|
|
||||||
@container (width < 500px) {
|
|
||||||
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;
|
padding: 0 16px;
|
||||||
|
|
||||||
@container (width < 500px) {
|
@container (width < 500px) {
|
||||||
padding: 0 16px;
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
flex: 1 1 0px;
|
flex: 1 1 0px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@ -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`}>
|
||||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
{areCollectionsEnabled() ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.featured.collections'
|
||||||
|
defaultMessage='Collections'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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={
|
||||||
|
<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} />}
|
{!withoutButton && <FollowButton compact accountId={accountId} />}
|
||||||
</div>
|
{isOwnAccount && (
|
||||||
);
|
<Button secondary compact onClick={onRevoke}>
|
||||||
};
|
<FormattedMessage
|
||||||
|
id='collections.detail.revoke_inclusion'
|
||||||
const RevokeControls: React.FC<{
|
defaultMessage='Remove me'
|
||||||
collectionId: string;
|
/>
|
||||||
collectionItem: CollectionAccountItem;
|
</Button>
|
||||||
}> = ({ 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>
|
|
||||||
</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,80 +184,40 @@ 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}
|
||||||
<h3 className={classes.columnSubheading}>
|
tabIndex={-1}
|
||||||
<FormattedMessage
|
ref={listHeadingRef}
|
||||||
id='collections.detail.you_were_added_to_this_collection'
|
>
|
||||||
defaultMessage='You were added to this collection'
|
{collection ? (
|
||||||
values={{
|
<FormattedMessage
|
||||||
author: <SimpleAuthorName id={collection.account_id} />,
|
id='collections.account_count'
|
||||||
}}
|
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
||||||
/>
|
values={{ count: collection.item_count }}
|
||||||
</h3>
|
/>
|
||||||
<Article
|
) : (
|
||||||
key={currentUserInCollection.account_id}
|
<FormattedMessage
|
||||||
aria-posinset={1}
|
id='collections.detail.accounts_heading'
|
||||||
aria-setsize={items.length}
|
defaultMessage='Accounts'
|
||||||
className={classes.youWereAddedWrapper}
|
/>
|
||||||
>
|
)}
|
||||||
<AccountItem
|
</h3>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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": "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 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": "{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_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!",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) }
|
||||||
|
|||||||
@ -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" }],
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user