Allow adding an account to a collection directly from the profile page (#39080)
This commit is contained in:
@@ -21,6 +21,10 @@ import {
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { initMuteModal } from '@/mastodon/actions/mutes';
|
||||
import { initReport } from '@/mastodon/actions/reports';
|
||||
import {
|
||||
canAccountBeAdded,
|
||||
canAccountBeAddedByFollowers,
|
||||
} from '@/mastodon/features/collections/utils';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
@@ -214,6 +218,10 @@ const redesignMessages = defineMessages({
|
||||
id: 'account.menu.add_to_list',
|
||||
defaultMessage: 'Add to list…',
|
||||
},
|
||||
addToCollection: {
|
||||
id: 'account.menu.add_to_collection',
|
||||
defaultMessage: 'Add to collection…',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.menu.open_original_page',
|
||||
defaultMessage: 'View on {domain}',
|
||||
@@ -294,35 +302,57 @@ function getMenuItems({
|
||||
return items;
|
||||
}
|
||||
|
||||
// List and featuring options
|
||||
// Add to list
|
||||
if (relationship?.following) {
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(redesignMessages.addToList),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
items.push({
|
||||
text: intl.formatMessage(redesignMessages.addToList),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add to collection
|
||||
if (
|
||||
canAccountBeAdded(account) ||
|
||||
(canAccountBeAddedByFollowers(account) && relationship?.following)
|
||||
) {
|
||||
items.push({
|
||||
text: intl.formatMessage(redesignMessages.addToCollection),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'COLLECTION_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Feature on profile
|
||||
if (relationship?.following) {
|
||||
items.push({
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
items.push(
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.wrapper {
|
||||
--list-item-gap: 16px;
|
||||
--list-item-padding-block: 12px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
||||
import { Toggle } from '@/mastodon/components/form_fields';
|
||||
import {
|
||||
ListItemContent,
|
||||
ListItemWrapper,
|
||||
} from '@/mastodon/components/list_item';
|
||||
import {
|
||||
AvatarGrid,
|
||||
CollectionInfo,
|
||||
} from 'mastodon/features/collections/components/collection_lockup';
|
||||
|
||||
import classes from './collection_toggle.module.scss';
|
||||
|
||||
export interface CollectionToggleProps {
|
||||
collection: ApiCollectionJSON;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
subtitle?: React.ReactNode;
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const CollectionToggle: React.FC<CollectionToggleProps> = ({
|
||||
collection,
|
||||
checked,
|
||||
disabled,
|
||||
subtitle,
|
||||
onChange,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const toggleId = `${uniqueId}-toggle`;
|
||||
const infoId = `${uniqueId}-info`;
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
className={classes.wrapper}
|
||||
icon={
|
||||
<AvatarGrid
|
||||
accountIds={collection.items.map((item) => item.account_id)}
|
||||
sensitive={collection.sensitive}
|
||||
/>
|
||||
}
|
||||
sideContent={
|
||||
<Toggle
|
||||
id={toggleId}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
aria-describedby={infoId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListItemContent
|
||||
as='label'
|
||||
htmlFor={toggleId}
|
||||
subtitle={
|
||||
subtitle ?? (
|
||||
<CollectionInfo
|
||||
collection={collection}
|
||||
withTimestamp={false}
|
||||
withAuthorHandle={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{collection.name}
|
||||
</ListItemContent>
|
||||
</ListItemWrapper>
|
||||
);
|
||||
};
|
||||
143
app/javascript/mastodon/features/collection_adder/index.tsx
Normal file
143
app/javascript/mastodon/features/collection_adder/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useId, useState } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import {
|
||||
addCollectionItem,
|
||||
removeCollectionItem,
|
||||
} from '@/mastodon/reducers/slices/collections';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { MAX_COLLECTION_ACCOUNT_COUNT } from '../collections/editor/accounts';
|
||||
import { useCollectionsCreatedBy } from '../collections/overview/created_by_you';
|
||||
|
||||
import { CollectionToggle } from './collection_toggle';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: {
|
||||
id: 'lightbox.close',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
});
|
||||
|
||||
const ListItem: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
account: Account;
|
||||
}> = ({ collection, account }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const accountItemInCollection = collection.items.find(
|
||||
(item) => item.account_id === account.id,
|
||||
);
|
||||
const isAccountInCollection = !!accountItemInCollection;
|
||||
|
||||
const addOrRemove = useCallback(
|
||||
async (shouldAdd: boolean) => {
|
||||
setIsUpdating(true);
|
||||
|
||||
if (shouldAdd) {
|
||||
await dispatch(
|
||||
addCollectionItem({
|
||||
collectionId: collection.id,
|
||||
accountId: account.id,
|
||||
}),
|
||||
);
|
||||
} else if (accountItemInCollection) {
|
||||
await dispatch(
|
||||
removeCollectionItem({
|
||||
collectionId: collection.id,
|
||||
itemId: accountItemInCollection.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setIsUpdating(false);
|
||||
},
|
||||
[account.id, collection.id, accountItemInCollection, dispatch],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
void addOrRemove(e.target.checked);
|
||||
},
|
||||
[addOrRemove],
|
||||
);
|
||||
|
||||
const hasMaxItemCount =
|
||||
!isAccountInCollection &&
|
||||
collection.item_count >= MAX_COLLECTION_ACCOUNT_COUNT;
|
||||
|
||||
return (
|
||||
<CollectionToggle
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
disabled={isUpdating || hasMaxItemCount}
|
||||
subtitle={
|
||||
hasMaxItemCount ? (
|
||||
<FormattedMessage
|
||||
id='collections.search_accounts_max_reached'
|
||||
defaultMessage='You have added the maximum number of accounts'
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
checked={isAccountInCollection}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionAdder: React.FC<{
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
}> = ({ accountId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const currentAccountId = useCurrentAccountId();
|
||||
const { collections, status } = useCollectionsCreatedBy(currentAccountId);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<div className='dialog-modal__header'>
|
||||
<IconButton
|
||||
className='dialog-modal__header__close'
|
||||
title={intl.formatMessage(messages.close)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<span className='dialog-modal__header__title' id={titleId}>
|
||||
<FormattedMessage
|
||||
id='collections.add_to_collection'
|
||||
defaultMessage='Add {name} to collections'
|
||||
values={{ name: <strong>@{account?.acct}</strong> }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='dialog-modal__content'>
|
||||
<div
|
||||
className='lists-scrollable'
|
||||
role='group'
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
{status === 'loading' || !account ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
collections.map((item) => (
|
||||
<ListItem key={item.id} collection={item} account={account} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -57,10 +57,45 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const { id, name } = collection;
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
className={classNames(classes.wrapper, className)}
|
||||
icon={
|
||||
<AvatarGrid
|
||||
accountIds={collection.items.map((item) => item.account_id)}
|
||||
sensitive={collection.sensitive}
|
||||
/>
|
||||
}
|
||||
sideContent={sideContent}
|
||||
>
|
||||
<ListItemLink
|
||||
as='h3'
|
||||
to={getCollectionPath(id)}
|
||||
subtitle={
|
||||
<CollectionInfo
|
||||
collection={collection}
|
||||
withAuthorHandle={withAuthorHandle}
|
||||
withTimestamp={withTimestamp}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</ListItemLink>
|
||||
</ListItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionInfo: React.FC<
|
||||
Pick<
|
||||
CollectionLockupProps,
|
||||
'collection' | 'withAuthorHandle' | 'withTimestamp'
|
||||
>
|
||||
> = ({ collection, withAuthorHandle, withTimestamp }) => {
|
||||
const authorAccount = useAccount(collection.account_id);
|
||||
const authorHandle = useAccountHandle(authorAccount, domain);
|
||||
|
||||
const collectionInfo = (
|
||||
return (
|
||||
<ul>
|
||||
{collection.sensitive && (
|
||||
<li className='sr-only'>
|
||||
@@ -98,25 +133,4 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
className={classNames(classes.wrapper, className)}
|
||||
icon={
|
||||
<AvatarGrid
|
||||
accountIds={collection.items.map((item) => item.account_id)}
|
||||
sensitive={collection.sensitive}
|
||||
/>
|
||||
}
|
||||
sideContent={sideContent}
|
||||
>
|
||||
<ListItemLink
|
||||
as='h3'
|
||||
to={getCollectionPath(id)}
|
||||
subtitle={collectionInfo}
|
||||
>
|
||||
{name}
|
||||
</ListItemLink>
|
||||
</ListItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,11 +39,12 @@ import {
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { PendingNote } from '../detail';
|
||||
import { canAccountBeAdded, canAccountBeAddedByFollowers } from '../utils';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
import { WizardStepTitle } from './wizard_step_title';
|
||||
|
||||
const MAX_ACCOUNT_COUNT = 25;
|
||||
export const MAX_COLLECTION_ACCOUNT_COUNT = 25;
|
||||
|
||||
const AddedAccountItem: React.FC<{
|
||||
accountId: string;
|
||||
@@ -99,9 +100,6 @@ const renderAccountItem = (account: ApiMutedAccountJSON) => (
|
||||
|
||||
type GroupKey = 'available' | 'mustFollow' | 'disabled';
|
||||
|
||||
const canAccountBeAdded = (account: ApiMutedAccountJSON) =>
|
||||
['automatic', 'manual'].includes(account.feature_approval.current_user);
|
||||
|
||||
function groupSuggestions(
|
||||
accounts: ApiMutedAccountJSON[],
|
||||
relationships: ImmutableMap<string, Relationship>,
|
||||
@@ -113,12 +111,8 @@ function groupSuggestions(
|
||||
return 'available';
|
||||
}
|
||||
|
||||
const canAccountBeAddedByFollowers =
|
||||
account.feature_approval.automatic.includes('followers') ||
|
||||
account.feature_approval.manual.includes('followers');
|
||||
|
||||
if (
|
||||
canAccountBeAddedByFollowers &&
|
||||
canAccountBeAddedByFollowers(account) &&
|
||||
!relationships.get(account.id)?.following
|
||||
) {
|
||||
return 'mustFollow';
|
||||
@@ -212,7 +206,7 @@ export const CollectionAccounts: React.FC<{
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const hasItems = editorItems.length > 0;
|
||||
const hasMaxItems = editorItems.length === MAX_ACCOUNT_COUNT;
|
||||
const hasMaxItems = editorItems.length === MAX_COLLECTION_ACCOUNT_COUNT;
|
||||
|
||||
const {
|
||||
accounts: suggestedAccounts,
|
||||
@@ -406,7 +400,10 @@ export const CollectionAccounts: React.FC<{
|
||||
<FormattedMessage
|
||||
id='collections.hints.accounts_counter'
|
||||
defaultMessage='{count}/{max} accounts'
|
||||
values={{ count: editorItems.length, max: MAX_ACCOUNT_COUNT }}
|
||||
values={{
|
||||
count: editorItems.length,
|
||||
max: MAX_COLLECTION_ACCOUNT_COUNT,
|
||||
}}
|
||||
/>
|
||||
</AccountsHeadingElement>
|
||||
)}
|
||||
@@ -426,7 +423,7 @@ export const CollectionAccounts: React.FC<{
|
||||
id='collections.accounts.empty_description'
|
||||
defaultMessage='Add up to {count} accounts'
|
||||
values={{
|
||||
count: MAX_ACCOUNT_COUNT,
|
||||
count: MAX_COLLECTION_ACCOUNT_COUNT,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ApiMutedAccountJSON } from '@/mastodon/api_types/accounts';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import { isServerFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
export function areCollectionsEnabled() {
|
||||
@@ -5,3 +7,12 @@ export function areCollectionsEnabled() {
|
||||
}
|
||||
|
||||
export const getCollectionPath = (id: string) => `/collections/${id}`;
|
||||
|
||||
export const canAccountBeAdded = (account: ApiMutedAccountJSON | Account) =>
|
||||
['automatic', 'manual'].includes(account.feature_approval.current_user);
|
||||
|
||||
export const canAccountBeAddedByFollowers = (
|
||||
account: ApiMutedAccountJSON | Account,
|
||||
) =>
|
||||
account.feature_approval.automatic.includes('followers') ||
|
||||
account.feature_approval.manual.includes('followers');
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useId } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import { Toggle } from '@/mastodon/components/form_fields';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import { fetchLists } from 'mastodon/actions/lists';
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
} from 'mastodon/api/lists';
|
||||
import type { ApiListJSON } from 'mastodon/api_types/lists';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { CheckBox } from 'mastodon/components/check_box';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { getOrderedLists } from 'mastodon/selectors/lists';
|
||||
@@ -42,6 +42,8 @@ const ListItem: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (id: string, checked: boolean) => void;
|
||||
}> = ({ id, title, checked, onChange }) => {
|
||||
const uniqueId = useId();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(id, e.target.checked);
|
||||
@@ -50,14 +52,13 @@ const ListItem: React.FC<{
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
<label className='lists__item'>
|
||||
<label className='lists__item' htmlFor={uniqueId}>
|
||||
<div className='lists__item__title'>
|
||||
<Icon id='list-ul' icon={ListAltIcon} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
|
||||
<CheckBox value={id} checked={checked} onChange={handleChange} />
|
||||
<Toggle id={uniqueId} checked={checked} onChange={handleChange} />
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -77,6 +77,7 @@ export const MODAL_COMPONENTS = {
|
||||
'DOMAIN_BLOCK': DomainBlockModal,
|
||||
'REPORT': ReportModal,
|
||||
'REPORT_COLLECTION': ReportCollectionModal,
|
||||
'COLLECTION_ADDER': () => import('@/mastodon/features/collection_adder').then(module => ({ default: module.CollectionAdder })),
|
||||
'SHARE_COLLECTION': () => import('@/mastodon/features/collections/components/share_modal').then(module => ({ default: module.CollectionShareModal })),
|
||||
'REVOKE_COLLECTION_INCLUSION': () => import('@/mastodon/features/collections/detail/revoke_collection_inclusion_modal').then(module => ({ default: module.RevokeCollectionInclusionModal })),
|
||||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
|
||||
"account.media": "Media",
|
||||
"account.mention": "Mention @{name}",
|
||||
"account.menu.add_to_collection": "Add to collection…",
|
||||
"account.menu.add_to_list": "Add to list…",
|
||||
"account.menu.block": "Block account",
|
||||
"account.menu.block_domain": "Block {domain}",
|
||||
@@ -372,6 +373,7 @@
|
||||
"collections.accounts.empty_description": "Add up to {count} accounts",
|
||||
"collections.accounts.empty_editor_title": "No one is in this collection yet",
|
||||
"collections.accounts.empty_title": "This collection is empty",
|
||||
"collections.add_to_collection": "Add {name} to collections",
|
||||
"collections.block_collection_owner": "Block account",
|
||||
"collections.by_account": "by {account_handle}",
|
||||
"collections.collection_description": "Description",
|
||||
|
||||
@@ -283,8 +283,12 @@ const collectionSlice = createSlice({
|
||||
builder.addCase(addCollectionItem.fulfilled, (state, action) => {
|
||||
const { collection_item } = action.payload;
|
||||
const { collectionId } = action.meta.arg;
|
||||
const collection = state.collections[collectionId];
|
||||
|
||||
state.collections[collectionId]?.items.push(collection_item);
|
||||
if (collection) {
|
||||
collection.items.push(collection_item);
|
||||
collection.item_count++;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -302,6 +306,7 @@ const collectionSlice = createSlice({
|
||||
collection.items = collection.items.filter(
|
||||
(item) => item.id !== itemId,
|
||||
);
|
||||
collection.item_count--;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user