diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9e510bccab..df20525a96 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -34,11 +34,11 @@ module ApplicationHelper Setting.registrations_mode == 'none' end - def available_sign_up_path + def available_sign_up_url if closed_registrations? || omniauth_only? - 'https://joinmastodon.org/#getting-started' + 'https://joinmastodon.org/' else - ENV.fetch('SSO_ACCOUNT_SIGN_UP', new_user_registration_path) + ENV.fetch('SSO_ACCOUNT_SIGN_UP', new_user_registration_url) end end diff --git a/app/javascript/images/elephant_ui_dark.svg b/app/javascript/images/elephant_ui_dark.svg new file mode 100644 index 0000000000..aa91b704c7 --- /dev/null +++ b/app/javascript/images/elephant_ui_dark.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/images/elephant_ui_light.svg b/app/javascript/images/elephant_ui_light.svg new file mode 100644 index 0000000000..1eeab31cf4 --- /dev/null +++ b/app/javascript/images/elephant_ui_light.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 558390b9cf..da0c5f1102 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -26,8 +26,10 @@ defineMessages({ export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { - const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); - const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); + const filterType = notification.type === 'quoted_update' ? 'update' : notification.type; + + const showAlert = getState().getIn(['settings', 'notifications', 'alerts', filterType], true); + const playSound = getState().getIn(['settings', 'notifications', 'sounds', filterType], true); let filtered = false; diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index 0a5e847e8e..1c340c387a 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -37,6 +37,7 @@ export interface BaseApiAccountJSON { acct: string; avatar: string; avatar_static: string; + avatar_description: string; bot: boolean; created_at: string; discoverable?: boolean; @@ -50,6 +51,7 @@ export interface BaseApiAccountJSON { group: boolean; header: string; header_static: string; + header_description: string; id: string; last_status_at: string; locked: boolean; diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts index fae95875d1..3edaa64c95 100644 --- a/app/javascript/mastodon/api_types/collections.ts +++ b/app/javascript/mastodon/api_types/collections.ts @@ -16,7 +16,7 @@ export interface ApiCollectionJSON { item_count: number; name: string; - description: string; + description: string | null; tag: ApiTagJSON | null; language: string | null; sensitive: boolean; diff --git a/app/javascript/mastodon/components/avatar.tsx b/app/javascript/mastodon/components/avatar.tsx index ced733b5d7..e2da16703e 100644 --- a/app/javascript/mastodon/components/avatar.tsx +++ b/app/javascript/mastodon/components/avatar.tsx @@ -13,6 +13,7 @@ interface Props { account: | Pick | undefined; // FIXME: remove `undefined` once we know for sure its always there + alt?: string; size?: number; style?: React.CSSProperties; inline?: boolean; @@ -25,6 +26,7 @@ interface Props { export const Avatar: React.FC = ({ account, + alt = '', animate = autoPlayGif, size = 20, inline = false, @@ -65,7 +67,7 @@ export const Avatar: React.FC = ({ style={style} > {src && !error && ( - + {alt} )} {counter && ( diff --git a/app/javascript/mastodon/components/empty_state/empty_state.module.scss b/app/javascript/mastodon/components/empty_state/empty_state.module.scss index 1707b3bc08..bdab2f2732 100644 --- a/app/javascript/mastodon/components/empty_state/empty_state.module.scss +++ b/app/javascript/mastodon/components/empty_state/empty_state.module.scss @@ -10,6 +10,13 @@ } .content { + svg, + img { + width: 240px; + max-width: 100%; + margin-bottom: 16px; + } + h3 { font-size: 17px; font-weight: 500; diff --git a/app/javascript/mastodon/components/empty_state/index.tsx b/app/javascript/mastodon/components/empty_state/index.tsx index 93f034f3e9..59210bbc87 100644 --- a/app/javascript/mastodon/components/empty_state/index.tsx +++ b/app/javascript/mastodon/components/empty_state/index.tsx @@ -9,10 +9,12 @@ import classes from './empty_state.module.scss'; */ export const EmptyState: React.FC<{ - title?: string | React.ReactElement; - message?: string | React.ReactElement; + image?: React.ReactNode; + title?: React.ReactNode; + message?: React.ReactNode; children?: React.ReactNode; }> = ({ + image, title = ( ), @@ -21,10 +23,13 @@ export const EmptyState: React.FC<{ }) => { return (
-
-

{title}

- {!!message &&

{message}

} -
+ {(title || message || image) && ( +
+ {image} + {!!title &&

{title}

} + {!!message &&

{message}

} +
+ )} {children}
diff --git a/app/javascript/mastodon/features/account_featured/components/empty_message.tsx b/app/javascript/mastodon/features/account_featured/components/empty_message.tsx index 5a60780a09..a5bb5878c9 100644 --- a/app/javascript/mastodon/features/account_featured/components/empty_message.tsx +++ b/app/javascript/mastodon/features/account_featured/components/empty_message.tsx @@ -1,9 +1,15 @@ import { FormattedMessage } from 'react-intl'; import { useParams } from 'react-router'; +import { Link } from 'react-router-dom'; -import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint'; -import { me } from 'mastodon/initial_state'; +import ElephantDarkImage from '@/images/elephant_ui_dark.svg?react'; +import ElephantLightImage from '@/images/elephant_ui_light.svg?react'; +import { EmptyState } from '@/mastodon/components/empty_state'; +import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint'; +import { areCollectionsEnabled } from '@/mastodon/features/collections/utils'; +import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; +import { useTheme } from '@/mastodon/hooks/useTheme'; interface EmptyMessageProps { suspended: boolean; @@ -19,21 +25,59 @@ export const EmptyMessage: React.FC = ({ blockedBy, }) => { const { acct } = useParams<{ acct?: string }>(); + const me = useCurrentAccountId(); + const theme = useTheme(); + const ElephantImage = + theme === 'dark' ? ElephantDarkImage : ElephantLightImage; + if (!accountId) { return null; } + let title: React.ReactNode = null; let message: React.ReactNode = null; + const hasCollections = areCollectionsEnabled(); + + const image = ; + if (me === accountId) { - message = ( - - ); + if (hasCollections) { + // Return only here to insert the "Create a collection" button as the action for the empty state. + return ( + + } + > + + + + + ); + } else { + title = ( + + ); + message = ( + + ); + } } else if (suspended) { - message = ( + title = ( = ({ } else if (hidden) { message = ; } else if (blockedBy) { - message = ( + title = ( ); - } else if (acct) { - message = ( - - ); } else { - message = ( + // Standard other account empty state. + title = ( ); + if (hasCollections) { + if (acct) { + message = ( + + ); + } else { + message = ( + + ); + } + } else { + if (acct) { + message = ( + + ); + } else { + message = ( + + ); + } + } } - return ( -
- {message} -
- ); + return ; }; diff --git a/app/javascript/mastodon/features/account_featured/components/featured_tag.tsx b/app/javascript/mastodon/features/account_featured/components/featured_tag.tsx deleted file mode 100644 index b9a79ce25e..0000000000 --- a/app/javascript/mastodon/features/account_featured/components/featured_tag.tsx +++ /dev/null @@ -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 = ({ tag, account }) => { - const intl = useIntl(); - const name = tag.get('name') ?? ''; - const count = Number.parseInt(tag.get('statuses_count') ?? ''); - return ( - 0 - ? intl.formatMessage(messages.lastStatusAt, { - date: intl.formatDate(tag.get('last_status_at') ?? '', { - month: 'short', - day: '2-digit', - year: 'numeric', - }), - }) - : intl.formatMessage(messages.empty) - } - /> - ); -}; diff --git a/app/javascript/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index 937a200e1c..244302f209 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/features/account_featured/index.tsx @@ -8,7 +8,6 @@ import { List as ImmutableList } from 'immutable'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { fetchEndorsedAccounts } from 'mastodon/actions/accounts'; -import { fetchFeaturedTags } from 'mastodon/actions/featured_tags'; import { Account } from 'mastodon/components/account'; import { ColumnBackButton } from 'mastodon/components/column_back_button'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; @@ -33,8 +32,6 @@ import { CollectionListItem } from '../collections/detail/collection_list_item'; import { areCollectionsEnabled } from '../collections/utils'; import { EmptyMessage } from './components/empty_message'; -import { FeaturedTag } from './components/featured_tag'; -import type { TagMap } from './components/featured_tag'; const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ multiColumn, @@ -55,26 +52,15 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ useEffect(() => { if (accountId) { - void dispatch(fetchFeaturedTags({ accountId })); void dispatch(fetchEndorsedAccounts({ accountId })); + if (areCollectionsEnabled()) { void dispatch(fetchAccountCollections({ accountId })); } } }, [accountId, dispatch]); - const isLoading = useAppSelector( - (state) => - !accountId || - !!state.user_lists.getIn(['featured_tags', accountId, 'isLoading']), - ); - const featuredTags = useAppSelector( - (state) => - state.user_lists.getIn( - ['featured_tags', accountId, 'items'], - ImmutableList(), - ) as ImmutableList, - ); + const isLoading = !accountId; const featuredAccountIds = useAppSelector( (state) => state.user_lists.getIn( @@ -106,13 +92,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ ); } - const noTags = featuredTags.isEmpty(); - - if ( - noTags && - featuredAccountIds.isEmpty() && - listedCollections.length === 0 - ) { + if (featuredAccountIds.isEmpty() && listedCollections.length === 0) { return ( = ({ )} - {!noTags && ( - <> -

- -

- - {featuredTags.map((tag, index) => ( -
- -
- ))} -
- - )} {!featuredAccountIds.isEmpty() && ( <>

diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index 90b0127e15..e717a20c80 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -72,7 +72,7 @@ export const AccountHeader: React.FC<{ modalType: 'IMAGE', modalProps: { src: account.avatar, - alt: '', + alt: account.avatar_description, }, }), ); @@ -120,7 +120,7 @@ export const AccountHeader: React.FC<{ {!suspendedOrHidden && ( )} @@ -147,6 +147,7 @@ export const AccountHeader: React.FC<{ > @@ -169,6 +170,8 @@ export const AccountHeader: React.FC<{ + + {!isMe && !suspendedOrHidden && ( )} @@ -195,8 +198,6 @@ export const AccountHeader: React.FC<{ {!me && account.email_subscriptions && ( )} - - )} diff --git a/app/javascript/mastodon/features/account_timeline/components/fields.tsx b/app/javascript/mastodon/features/account_timeline/components/fields.tsx index 80d6cc7f67..aa12447304 100644 --- a/app/javascript/mastodon/features/account_timeline/components/fields.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/fields.tsx @@ -235,6 +235,7 @@ function useColumnWrap() { const columnCount = parseInt(styles.getPropertyValue('--cols')) || 2; const listWidth = listEle.offsetWidth; const colWidth = (listWidth - gap * (columnCount - 1)) / columnCount; + const halfColSpan = columnCount / 2; // Matrix to hold the grid layout. const itemGrid: { ele: HTMLElement; span: number }[][] = []; @@ -288,6 +289,16 @@ function useColumnWrap() { if (i < row.length - 1) { ele.dataset.cols = span.toString(); remainingRowSpan -= span; + } else if ( + row.length === halfColSpan && + span === 1 && + remainingRowSpan > 1 + ) { + // Special case for 2 items in a row where both items only need 1 column. + ele.dataset.cols = halfColSpan.toString(); + if (row[0]) { + row[0].ele.dataset.cols = halfColSpan.toString(); + } } else { // Last item in the row takes up remaining space to fill the row. ele.dataset.cols = remainingRowSpan.toString(); diff --git a/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx b/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx index 385ba2d28a..414fd313a4 100644 --- a/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx @@ -27,13 +27,6 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({ return ( - } - hint={intl.formatNumber(account.statuses_count)} - > - - - @@ -54,6 +47,13 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({ + } + hint={intl.formatNumber(account.statuses_count)} + > + + + diff --git a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss index 87ecbc4c54..97395bd1ed 100644 --- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss @@ -9,11 +9,7 @@ .barWrapper { border-bottom: none; - padding-inline: 24px; - - @container (width < 500px) { - padding-inline: 16px; - } + padding-inline: 16px; } .avatarWrapper { @@ -324,7 +320,7 @@ svg.badgeIcon { } .modalFieldsList { - padding: 24px; + padding: 16px; } .modalFieldItem { @@ -363,11 +359,9 @@ svg.badgeIcon { .tabs { display: flex; gap: 12px; - padding: 0 24px; + padding: 0 16px; @container (width < 500px) { - padding: 0 16px; - a { flex: 1 1 0px; text-align: center; diff --git a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx index b2ad9a6065..05dbdb5a80 100644 --- a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx @@ -8,6 +8,8 @@ import { NavLink } from 'react-router-dom'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAccountId } from '@/mastodon/hooks/useAccountId'; +import { areCollectionsEnabled } from '../../collections/utils'; + import classes from './redesign.module.scss'; const isActive: Required['isActive'] = (match, location) => @@ -39,7 +41,14 @@ export const AccountTabs: FC = () => { )} {show_featured && ( - + {areCollectionsEnabled() ? ( + + ) : ( + + )} )} diff --git a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss index 1df19feb1d..a3629bf29b 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss +++ b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss @@ -1,5 +1,5 @@ .filtersWrapper { - padding: 16px 24px 8px; + padding: 16px 16px 8px; } .filterSelectButton { @@ -51,7 +51,7 @@ .tagsWrapper, .tagSuggestions { - margin: 0 24px 8px; + margin: 0 16px 8px; } .tagsWrapper { @@ -76,11 +76,6 @@ } .statusWrapper { - :global(.status) { - padding-left: 24px; - padding-right: 24px; - } - &:has(.pinnedViewAllButton) :global(.status):has(.pinnedStatusHeader) { border-bottom: none; } @@ -97,7 +92,7 @@ box-sizing: border-box; color: var(--color-text-primary); line-height: normal; - margin: 12px 24px; + margin: 12px 16px; padding: 8px; transition: border-color 0.2s ease-in-out; width: calc(100% - 48px); diff --git a/app/javascript/mastodon/features/collections/detail/accounts_list.tsx b/app/javascript/mastodon/features/collections/detail/accounts_list.tsx index 9e9f1c2fb9..4a30f99dd6 100644 --- a/app/javascript/mastodon/features/collections/detail/accounts_list.tsx +++ b/app/javascript/mastodon/features/collections/detail/accounts_list.tsx @@ -2,26 +2,26 @@ import { useCallback, useRef, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { Callout } from '@/mastodon/components/callout'; -import { FollowButton } from '@/mastodon/components/follow_button'; -import { openModal } from 'mastodon/actions/modal'; -import type { - ApiCollectionJSON, - CollectionAccountItem, -} from 'mastodon/api_types/collections'; +import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import { Account } from 'mastodon/components/account'; import { Button } from 'mastodon/components/button'; -import { DisplayName } from 'mastodon/components/display_name'; +import { Callout } from 'mastodon/components/callout'; +import { FollowButton } from 'mastodon/components/follow_button'; +import { + NumberFields, + NumberFieldsItem, +} from 'mastodon/components/number_fields'; +import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { Article, ItemList, } from 'mastodon/components/scrollable_list/components'; +import { ShortNumber } from 'mastodon/components/short_number'; import { useAccount } from 'mastodon/hooks/useAccount'; -import { useDismissible } from 'mastodon/hooks/useDismissible'; import { useRelationship } from 'mastodon/hooks/useRelationship'; import { me } from 'mastodon/initial_state'; -import { useAppDispatch } from 'mastodon/store'; +import { useConfirmRevoke } from './revoke_collection_inclusion_modal'; import classes from './styles.module.scss'; const messages = defineMessages({ @@ -31,28 +31,33 @@ const messages = defineMessages({ }, }); -const SimpleAuthorName: React.FC<{ id: string }> = ({ id }) => { - const account = useAccount(id); - return ; -}; - const AccountItem: React.FC<{ accountId: string | undefined; collectionOwnerId: string; + onRevoke: () => void; withBio?: boolean; withBorder?: boolean; -}> = ({ accountId, withBio = true, withBorder = true, collectionOwnerId }) => { +}> = ({ + accountId, + collectionOwnerId, + onRevoke, + withBio = true, + withBorder = true, +}) => { + const intl = useIntl(); + const account = useAccount(accountId); const relationship = useRelationship(accountId); - if (!accountId) { + if (!accountId || !account) { return null; } // When viewing your own collection, only show the Follow button // for accounts you're not following (anymore). // Otherwise, always show the follow button in its various states. + const isOwnAccount = accountId === me; const withoutButton = - accountId === me || + isOwnAccount || !relationship || (collectionOwnerId === me && (relationship.following || relationship.requested)); @@ -66,52 +71,55 @@ const AccountItem: React.FC<{ withBorder={false} withMenu={false} className={classes.accountItem} + extraAccountInfo={ + + + } + hint={intl.formatNumber(account.followers_count)} + > + + + + + } + hint={intl.formatNumber(account.statuses_count)} + > + + + + + } + > + + + + } /> - {!withoutButton && } - - ); -}; - -const RevokeControls: React.FC<{ - collectionId: string; - collectionItem: CollectionAccountItem; -}> = ({ collectionId, collectionItem }) => { - const dispatch = useAppDispatch(); - - const confirmRevoke = useCallback(() => { - void dispatch( - openModal({ - modalType: 'REVOKE_COLLECTION_INCLUSION', - modalProps: { - collectionId, - collectionItemId: collectionItem.id, - }, - }), - ); - }, [collectionId, collectionItem.id, dispatch]); - - const { wasDismissed, dismiss } = useDismissible( - `collection-revoke-hint-${collectionItem.id}`, - ); - - if (wasDismissed) { - return null; - } - - return ( -
- - + {!withoutButton && } + {isOwnAccount && ( + + )}
); }; @@ -159,46 +167,16 @@ const SensitiveScreen: React.FC<{ ); }; -/** - * Returns the collection's account items. If the current user's account - * is part of the collection, it will be returned separately. - */ -function getCollectionItems(collection: ApiCollectionJSON | undefined) { - if (!collection) - return { - currentUserInCollection: null, - items: [], - }; - - const { account_id, items } = collection; - - const isOwnCollection = account_id === me; - const currentUserIndex = items.findIndex( - (account) => account.account_id === me, - ); - - if (isOwnCollection || currentUserIndex === -1) { - return { - currentUserInCollection: null, - items, - }; - } else { - return { - currentUserInCollection: items.at(currentUserIndex) ?? null, - items: items.toSpliced(currentUserIndex, 1), - }; - } -} - export const CollectionAccountsList: React.FC<{ collection?: ApiCollectionJSON; isLoading: boolean; }> = ({ collection, isLoading }) => { const intl = useIntl(); + const confirmRevoke = useConfirmRevoke(collection); const listHeadingRef = useRef(null); const isOwnCollection = collection?.account_id === me; - const { items, currentUserInCollection } = getCollectionItems(collection); + const { items = [] } = collection ?? {}; return ( - {collection && currentUserInCollection ? ( - <> -

- , - }} - /> -

-
- - -
-

- -

- - ) : ( -

- {collection ? ( - - ) : ( - - )} -

- )} +

+ {collection ? ( + + ) : ( + + )} +

{collection && ( - {items.map(({ account_id }, index, items) => ( + {items.map(({ account_id }, index) => (
))} diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index e9337eaed8..683b6e1178 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -11,17 +11,20 @@ import { useAccountHandle } from '@/mastodon/components/display_name/default'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ShareIcon from '@/material-icons/400-24px/share.svg?react'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; +import { Callout } from 'mastodon/components/callout'; import { Column } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; +import { DisplayName } from 'mastodon/components/display_name'; import { IconButton } from 'mastodon/components/icon_button'; import { Scrollable } from 'mastodon/components/scrollable_list/components'; import { useAccount } from 'mastodon/hooks/useAccount'; -import { domain } from 'mastodon/initial_state'; +import { domain, me } from 'mastodon/initial_state'; import { fetchCollection } from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { CollectionAccountsList } from './accounts_list'; import { CollectionMenu } from './collection_menu'; +import { useConfirmRevoke } from './revoke_collection_inclusion_modal'; import classes from './styles.module.scss'; const messages = defineMessages({ @@ -62,14 +65,54 @@ export const AuthorNote: React.FC<{ id: string }> = ({ id }) => { ); }; +export const RevokeControls: React.FC<{ + collection: ApiCollectionJSON; +}> = ({ collection }) => { + const authorAccount = useAccount(collection.account_id); + const confirmRevoke = useConfirmRevoke(collection); + + return ( + + } + primaryLabel={ + + } + onPrimary={confirmRevoke} + > + , + date: '{date}', // TODO: Data not yet provided by API + }} + /> + + ); +}; + const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ collection, }) => { const intl = useIntl(); - const { name, description, tag, account_id } = collection; + const { name, description, tag, account_id, items } = collection; const dispatch = useAppDispatch(); const history = useHistory(); + const isOwnCollection = account_id === me; + const currentUserIndex = items.findIndex( + (account) => account.account_id === me, + ); + const isCurrentUserInCollection = !isOwnCollection && currentUserIndex > -1; + const handleShare = useCallback(() => { dispatch( openModal({ @@ -115,6 +158,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ {description &&

{description}

} + {isCurrentUserInCollection && } ); }; diff --git a/app/javascript/mastodon/features/collections/detail/revoke_collection_inclusion_modal.tsx b/app/javascript/mastodon/features/collections/detail/revoke_collection_inclusion_modal.tsx index c2c2bafe9d..a5fc887ff6 100644 --- a/app/javascript/mastodon/features/collections/detail/revoke_collection_inclusion_modal.tsx +++ b/app/javascript/mastodon/features/collections/detail/revoke_collection_inclusion_modal.tsx @@ -3,8 +3,11 @@ import { useCallback } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { showAlert } from 'mastodon/actions/alerts'; +import { openModal } from 'mastodon/actions/modal'; +import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import type { BaseConfirmationModalProps } from 'mastodon/features/ui/components/confirmation_modals/confirmation_modal'; import { ConfirmationModal } from 'mastodon/features/ui/components/confirmation_modals/confirmation_modal'; +import { me } from 'mastodon/initial_state'; import { revokeCollectionInclusion } from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; @@ -24,6 +27,24 @@ const messages = defineMessages({ }, }); +export function useConfirmRevoke(collection?: ApiCollectionJSON) { + const dispatch = useAppDispatch(); + const { id, items = [] } = collection ?? {}; + const ownCollectionItemId = items.find((item) => item.account_id === me)?.id; + + return useCallback(() => { + void dispatch( + openModal({ + modalType: 'REVOKE_COLLECTION_INCLUSION', + modalProps: { + collectionId: id, + collectionItemId: ownCollectionItemId, + }, + }), + ); + }, [dispatch, id, ownCollectionItemId]); +} + export const RevokeCollectionInclusionModal: React.FC< { collectionId: string; diff --git a/app/javascript/mastodon/features/collections/detail/styles.module.scss b/app/javascript/mastodon/features/collections/detail/styles.module.scss index 62e2285294..54d421e037 100644 --- a/app/javascript/mastodon/features/collections/detail/styles.module.scss +++ b/app/javascript/mastodon/features/collections/detail/styles.module.scss @@ -1,4 +1,7 @@ .header { + display: flex; + flex-direction: column; + gap: 12px; padding: 24px; } @@ -49,7 +52,6 @@ .description { font-size: 15px; - margin-top: 12px; } .headerButtonWrapper { @@ -59,8 +61,14 @@ .iconButton { box-sizing: content-box; - padding: 5px; + padding: 7px; border-radius: 4px; + border: 1px solid var(--color-border-primary); + + svg { + width: 20px; + height: 20px; + } } .itemList { @@ -81,6 +89,18 @@ &[data-with-border='true'] { border-bottom: 1px solid var(--color-border-primary); } + + :global(.account__note) { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + } + + // Hide 'No description provided' message added by `Account` component + :global(.account__note--missing) { + display: none; + } } .accountItem { @@ -93,26 +113,3 @@ flex-grow: 1; } - -.youWereAddedWrapper { - padding-bottom: 16px; -} - -.revokeControlWrapper { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; - margin-bottom: 8px; - - :global(.button) { - min-width: 30%; - white-space: normal; - } - - --avatar-width: 46px; - - @container (width < 360px) { - --avatar-width: 35px; - } -} diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx index ce0019353d..04a6ad1428 100644 --- a/app/javascript/mastodon/features/collections/editor/details.tsx +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -182,7 +182,7 @@ export const CollectionDetails: React.FC = () => { /> - {label} + {label} 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'; +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index daf7d28f91..c77ffc6a91 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -45,9 +45,6 @@ "account.featured": "Featured", "account.featured.accounts": "Profiles", "account.featured.collections": "Collections", - "account.featured.hashtags": "Hashtags", - "account.featured_tags.last_status_at": "Last post on {date}", - "account.featured_tags.last_status_never": "No posts", "account.field_overflow": "Show full content", "account.filters.all": "All activity", "account.filters.boosts_toggle": "Show boosts", @@ -75,6 +72,7 @@ "account.in_memoriam": "In Memoriam.", "account.joined_short": "Joined", "account.languages": "Change subscribed languages", + "account.last_active": "Last active", "account.link_verified_on": "Ownership of this link was checked on {date}", "account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.", "account.media": "Media", @@ -375,15 +373,14 @@ "collections.create_collection": "Create collection", "collections.delete_collection": "Delete collection", "collections.description_length_hint": "100 characters limit", - "collections.detail.accept_inclusion": "Okay", "collections.detail.accounts_heading": "Accounts", + "collections.detail.author_added_you_on_date": "{author} added you on {date}", "collections.detail.loading": "Loading collection…", - "collections.detail.other_accounts_count": "{count, plural, one {# other account} other {# other accounts}}", "collections.detail.revoke_inclusion": "Remove me", "collections.detail.sensitive_content": "Sensitive content", "collections.detail.sensitive_note": "This collection contains accounts and content that may be sensitive to some users.", "collections.detail.share": "Share this collection", - "collections.detail.you_were_added_to_this_collection": "You were added to this collection", + "collections.detail.you_are_in_this_collection": "You're featured in this collection", "collections.edit_details": "Edit details", "collections.error_loading_collections": "There was an error when trying to load your collections.", "collections.hints.accounts_counter": "{count} / {max} accounts", @@ -606,9 +603,15 @@ "emoji_button.search_results": "Search results", "emoji_button.symbols": "Symbols", "emoji_button.travel": "Travel & Places", - "empty_column.account_featured.me": "You have not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friend’s accounts on your profile?", "empty_column.account_featured.other": "{acct} has not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friend’s accounts on your profile?", - "empty_column.account_featured_other.unknown": "This account has not featured anything yet.", + "empty_column.account_featured_other.no_collections_desc": "{acct} hasn’t created any collections yet.", + "empty_column.account_featured_other.title": "Nothing to see here", + "empty_column.account_featured_self.no_collections": "No collections yet", + "empty_column.account_featured_self.no_collections_button": "Create a collection", + "empty_column.account_featured_self.pre_collections": "Stay tuned for Collections", + "empty_column.account_featured_self.pre_collections_desc": "Collections (coming in Mastodon 4.6) allows you to create your own curated lists of accounts to recommend to others.", + "empty_column.account_featured_unknown.no_collections_desc": "This account hasn’t created any collections yet.", + "empty_column.account_featured_unknown.other": "This account hasn’t featured anything yet.", "empty_column.account_hides_collections": "This user has chosen to not make this information available", "empty_column.account_suspended": "Account suspended", "empty_column.account_timeline": "No posts here!", diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts index f13d1c6831..f2523cf334 100644 --- a/app/javascript/mastodon/models/account.ts +++ b/app/javascript/mastodon/models/account.ts @@ -62,6 +62,7 @@ export const accountDefaultValues: AccountShape = { acct: '', avatar: '', avatar_static: '', + avatar_description: '', bot: false, created_at: '', discoverable: false, @@ -78,6 +79,7 @@ export const accountDefaultValues: AccountShape = { group: false, header: '', header_static: '', + header_description: '', id: '', last_status_at: '', locked: false, diff --git a/app/javascript/mastodon/utils/environment.ts b/app/javascript/mastodon/utils/environment.ts index ded0fe05bb..cdcb88d68b 100644 --- a/app/javascript/mastodon/utils/environment.ts +++ b/app/javascript/mastodon/utils/environment.ts @@ -18,7 +18,7 @@ export function isServerFeatureEnabled(feature: ServerFeatures) { return initialState?.features.includes(feature) ?? false; } -type ClientFeatures = 'collections'; +type ClientFeatures = never; export function isClientFeatureEnabled(feature: ClientFeatures) { try { diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index e345e08351..8ac03ec2ad 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -25,6 +25,7 @@ export const accountFactory: FactoryFunction = ({ acct: 'testuser', avatar: '/avatars/original/missing.png', avatar_static: '/avatars/original/missing.png', + avatar_description: '', username: 'testuser', display_name: 'Test User', bot: false, @@ -42,6 +43,7 @@ export const accountFactory: FactoryFunction = ({ group: false, header: '/header.png', header_static: '/header_static.png', + header_description: '', indexable: true, last_status_at: '2023-01-01', locked: false, diff --git a/app/models/collection.rb b/app/models/collection.rb index 3be633bbf1..c8d4fb99bc 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -36,11 +36,9 @@ class Collection < ApplicationRecord validates :name, length: { maximum: 40 }, if: :local? validates :name, length: { maximum: NAME_LENGTH_HARD_LIMIT }, if: :remote? validates :description, - presence: true, length: { maximum: 100 }, if: :local? validates :description_html, - presence: true, length: { maximum: DESCRIPTION_LENGTH_HARD_LIMIT }, if: :remote? validates :local, inclusion: [true, false] diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index 664d8f88d7..a8c639fe34 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -12,7 +12,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer context_extensions :interaction_policies if Mastodon::Feature.collections_enabled? - attributes :id, :type, :following, :followers, + attributes :id, :webfinger, :type, :following, :followers, :inbox, :outbox, :featured, :featured_tags, :preferred_username, :name, :summary, :url, :manually_approves_followers, diff --git a/app/serializers/rest/collection_serializer.rb b/app/serializers/rest/collection_serializer.rb index c3f2b55a84..c668ec37dc 100644 --- a/app/serializers/rest/collection_serializer.rb +++ b/app/serializers/rest/collection_serializer.rb @@ -15,6 +15,7 @@ class REST::CollectionSerializer < ActiveModel::Serializer def description return object.description if object.local? + return if object.description_html.nil? Sanitize.fragment(object.description_html, Sanitize::Config::MASTODON_STRICT) end diff --git a/app/views/auth/shared/_links.html.haml b/app/views/auth/shared/_links.html.haml index 757ef0a090..f328fd91ac 100644 --- a/app/views/auth/shared/_links.html.haml +++ b/app/views/auth/shared/_links.html.haml @@ -6,7 +6,7 @@ %li= link_to_login t('auth.login') - if controller_name != 'registrations' - %li= link_to t('auth.register'), available_sign_up_path + %li= link_to t('auth.register'), available_sign_up_url - if controller_name != 'passwords' && controller_name != 'registrations' %li= link_to t('auth.forgot_password'), new_user_password_path diff --git a/app/views/email_subscription_mailer/notification.html.haml b/app/views/email_subscription_mailer/notification.html.haml index ad816ba460..12848c59b3 100644 --- a/app/views/email_subscription_mailer/notification.html.haml +++ b/app/views/email_subscription_mailer/notification.html.haml @@ -27,7 +27,7 @@ /[if mso] %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] diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 46dbd80de0..1b95be1d33 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -94,14 +94,14 @@ RSpec.describe ApplicationHelper do end end - describe 'available_sign_up_path' do + describe 'available_sign_up_url' do context 'when registrations are closed' do before do allow(Setting).to receive(:[]).with('registrations_mode').and_return 'none' end it 'redirects to joinmastodon site' do - expect(helper.available_sign_up_path).to match(/joinmastodon.org/) + expect(helper.available_sign_up_url).to match(/joinmastodon.org/) end end @@ -113,13 +113,13 @@ RSpec.describe ApplicationHelper do end it 'redirects to joinmastodon site' do - expect(helper.available_sign_up_path).to match(/joinmastodon.org/) + expect(helper.available_sign_up_url).to match(/joinmastodon.org/) end end context 'when registrations are allowed' do it 'returns a link to the registration page' do - expect(helper.available_sign_up_path).to eq(new_user_registration_path) + expect(helper.available_sign_up_url).to eq(new_user_registration_url) end end end diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb index 6937829ebb..64e93b6797 100644 --- a/spec/models/collection_spec.rb +++ b/spec/models/collection_spec.rb @@ -10,8 +10,6 @@ RSpec.describe Collection do it { is_expected.to validate_length_of(:name).is_at_most(40) } - it { is_expected.to validate_presence_of(:description) } - it { is_expected.to validate_length_of(:description).is_at_most(100) } it { is_expected.to_not allow_value(nil).for(:local) } @@ -29,10 +27,6 @@ RSpec.describe Collection do it { is_expected.to validate_length_of(:name).is_at_most(Collection::NAME_LENGTH_HARD_LIMIT) } - it { is_expected.to_not validate_presence_of(:description) } - - it { is_expected.to validate_presence_of(:description_html) } - it { is_expected.to validate_length_of(:description_html).is_at_most(Collection::DESCRIPTION_LENGTH_HARD_LIMIT) } it { is_expected.to validate_presence_of(:uri) } diff --git a/spec/requests/api/v1_alpha/collections_spec.rb b/spec/requests/api/v1_alpha/collections_spec.rb index a0573342d8..f448659bf5 100644 --- a/spec/requests/api/v1_alpha/collections_spec.rb +++ b/spec/requests/api/v1_alpha/collections_spec.rb @@ -180,7 +180,6 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do 'error' => a_hash_including({ 'details' => a_hash_including({ 'name' => [{ 'error' => 'ERR_BLANK', 'description' => "can't be blank" }], - 'description' => [{ 'error' => 'ERR_BLANK', 'description' => "can't be blank" }], }), }), })