Profile editing: Featured tags (#37952)

main
Echo 18 hours ago committed by GitHub
parent e2aecd040c
commit ef6405ab28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,10 +1,13 @@
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
import { apiRequestPost, apiRequestGet, apiRequestDelete } from 'mastodon/api';
import type {
ApiAccountJSON,
ApiFamiliarFollowersJSON,
} from 'mastodon/api_types/accounts';
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
import type {
ApiFeaturedTagJSON,
ApiHashtagJSON,
} from 'mastodon/api_types/tags';
export const apiSubmitAccountNote = (id: string, value: string) =>
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
@ -30,7 +33,19 @@ export const apiRemoveAccountFromFollowers = (id: string) =>
);
export const apiGetFeaturedTags = (id: string) =>
apiRequestGet<ApiHashtagJSON>(`v1/accounts/${id}/featured_tags`);
apiRequestGet<ApiHashtagJSON[]>(`v1/accounts/${id}/featured_tags`);
export const apiGetCurrentFeaturedTags = () =>
apiRequestGet<ApiFeaturedTagJSON[]>(`v1/featured_tags`);
export const apiPostFeaturedTag = (name: string) =>
apiRequestPost<ApiFeaturedTagJSON>('v1/featured_tags', { name });
export const apiDeleteFeaturedTag = (id: string) =>
apiRequestDelete(`v1/featured_tags/${id}`);
export const apiGetTagSuggestions = () =>
apiRequestGet<ApiHashtagJSON[]>('v1/featured_tags/suggestions');
export const apiGetEndorsedAccounts = (id: string) =>
apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);

@ -4,11 +4,29 @@ interface ApiHistoryJSON {
uses: string;
}
export interface ApiHashtagJSON {
interface ApiHashtagBase {
id: string;
name: string;
url: string;
}
export interface ApiHashtagJSON extends ApiHashtagBase {
history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
following?: boolean;
featuring?: boolean;
}
export interface ApiFeaturedTagJSON extends ApiHashtagBase {
statuses_count: number;
last_status_at: string | null;
}
export function hashtagToFeaturedTag(tag: ApiHashtagJSON): ApiFeaturedTagJSON {
return {
id: tag.id,
name: tag.name,
url: tag.url,
statuses_count: 0,
last_status_at: null,
};
}

@ -49,7 +49,7 @@ interface ComboboxProps<T extends ComboboxItem> extends TextInputProps {
/**
* A function that must return a unique id for each option passed via `items`
*/
getItemId: (item: T) => string;
getItemId?: (item: T) => string;
/**
* Providing this function turns the combobox into a multi-select box that assumes
* multiple options to be selectable. Single-selection is handled automatically.
@ -113,7 +113,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
value,
isLoading = false,
items,
getItemId,
getItemId = (item) => item.id,
getIsItemDisabled,
getIsItemSelected,
disabled,

@ -0,0 +1,57 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { Column } from '@/mastodon/components/column';
import { ColumnHeader } from '@/mastodon/components/column_header';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
import { useColumnsContext } from '../../ui/util/columns_context';
import classes from '../styles.module.scss';
export const AccountEditEmptyColumn: FC<{
notFound?: boolean;
}> = ({ notFound }) => {
const { multiColumn } = useColumnsContext();
if (notFound) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
return (
<Column bindToDocument={!multiColumn} className={classes.column}>
<LoadingIndicator />
</Column>
);
};
export const AccountEditColumn: FC<{
title: string;
to: string;
children: React.ReactNode;
}> = ({ to, title, children }) => {
const { multiColumn } = useColumnsContext();
return (
<Column bindToDocument={!multiColumn} className={classes.column}>
<ColumnHeader
title={title}
className={classes.columnHeader}
showBackButton
extraButton={
<Link to={to} className='button'>
<FormattedMessage
id='account_edit.column_button'
defaultMessage='Done'
/>
</Link>
}
/>
{children}
</Column>
);
};

@ -0,0 +1,100 @@
import type { FC, MouseEventHandler } from 'react';
import type { MessageDescriptor } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { Button } from '@/mastodon/components/button';
import { IconButton } from '@/mastodon/components/icon_button';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import classes from '../styles.module.scss';
const messages = defineMessages({
add: {
id: 'account_edit.button.add',
defaultMessage: 'Add {item}',
},
edit: {
id: 'account_edit.button.edit',
defaultMessage: 'Edit {item}',
},
delete: {
id: 'account_edit.button.delete',
defaultMessage: 'Delete {item}',
},
});
export interface EditButtonProps {
onClick: MouseEventHandler;
item: string | MessageDescriptor;
edit?: boolean;
icon?: boolean;
disabled?: boolean;
}
export const EditButton: FC<EditButtonProps> = ({
onClick,
item,
edit = false,
icon = edit,
disabled,
}) => {
const intl = useIntl();
const itemText = typeof item === 'string' ? item : intl.formatMessage(item);
const label = intl.formatMessage(messages[edit ? 'edit' : 'add'], {
item: itemText,
});
if (icon) {
return (
<EditIconButton title={label} onClick={onClick} disabled={disabled} />
);
}
return (
<Button
className={classes.editButton}
onClick={onClick}
disabled={disabled}
>
{label}
</Button>
);
};
export const EditIconButton: FC<{
onClick: MouseEventHandler;
title: string;
disabled?: boolean;
}> = ({ title, onClick, disabled }) => (
<IconButton
icon='pencil'
iconComponent={EditIcon}
onClick={onClick}
className={classes.editButton}
title={title}
disabled={disabled}
/>
);
export const DeleteIconButton: FC<{
onClick: MouseEventHandler;
item: string;
disabled?: boolean;
}> = ({ onClick, item, disabled }) => {
const intl = useIntl();
return (
<IconButton
icon='delete'
iconComponent={DeleteIcon}
onClick={onClick}
className={classNames(classes.editButton, classes.deleteButton)}
title={intl.formatMessage(messages.delete, { item })}
disabled={disabled}
/>
);
};

@ -0,0 +1,89 @@
import { useCallback } from 'react';
import classes from '../styles.module.scss';
import { DeleteIconButton, EditButton } from './edit_button';
interface AnyItem {
id: string;
name: string;
}
interface AccountEditItemListProps<Item extends AnyItem = AnyItem> {
renderItem?: (item: Item) => React.ReactNode;
items: Item[];
onEdit?: (item: Item) => void;
onDelete?: (item: Item) => void;
disabled?: boolean;
}
export const AccountEditItemList = <Item extends AnyItem>({
renderItem,
items,
onEdit,
onDelete,
disabled,
}: AccountEditItemListProps<Item>) => {
if (items.length === 0) {
return null;
}
return (
<ul className={classes.itemList}>
{items.map((item) => (
<li key={item.id}>
<span>{renderItem?.(item) ?? item.name}</span>
<AccountEditItemButtons
item={item}
onEdit={onEdit}
onDelete={onDelete}
disabled={disabled}
/>
</li>
))}
</ul>
);
};
type AccountEditItemButtonsProps<Item extends AnyItem = AnyItem> = Pick<
AccountEditItemListProps<Item>,
'onEdit' | 'onDelete' | 'disabled'
> & { item: Item };
const AccountEditItemButtons = <Item extends AnyItem>({
item,
onDelete,
onEdit,
disabled,
}: AccountEditItemButtonsProps<Item>) => {
const handleEdit = useCallback(() => {
onEdit?.(item);
}, [item, onEdit]);
const handleDelete = useCallback(() => {
onDelete?.(item);
}, [item, onDelete]);
if (!onEdit && !onDelete) {
return null;
}
return (
<div className={classes.itemListButtons}>
{onEdit && (
<EditButton
edit
item={item.name}
disabled={disabled}
onClick={handleEdit}
/>
)}
{onDelete && (
<DeleteIconButton
item={item.name}
disabled={disabled}
onClick={handleDelete}
/>
)}
</div>
);
};

@ -1,55 +1,36 @@
import type { FC, ReactNode } from 'react';
import type { MessageDescriptor } from 'react-intl';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { IconButton } from '@/mastodon/components/icon_button';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import classes from '../styles.module.scss';
const buttonMessage = defineMessage({
id: 'account_edit.section_edit_button',
defaultMessage: 'Edit',
});
interface AccountEditSectionProps {
title: MessageDescriptor;
description?: MessageDescriptor;
showDescription?: boolean;
onEdit?: () => void;
children?: ReactNode;
className?: string;
extraButtons?: ReactNode;
buttons?: ReactNode;
}
export const AccountEditSection: FC<AccountEditSectionProps> = ({
title,
description,
showDescription,
onEdit,
children,
className,
extraButtons,
buttons,
}) => {
const intl = useIntl();
return (
<section className={classNames(className, classes.section)}>
<header className={classes.sectionHeader}>
<h3 className={classes.sectionTitle}>
<FormattedMessage {...title} />
</h3>
{onEdit && (
<IconButton
icon='pencil'
iconComponent={EditIcon}
onClick={onEdit}
title={`${intl.formatMessage(buttonMessage)} ${intl.formatMessage(title)}`}
/>
)}
{extraButtons}
{buttons}
</header>
{showDescription && (
<p className={classes.sectionSubtitle}>

@ -0,0 +1,60 @@
import type { ChangeEventHandler, FC } from 'react';
import { useCallback } from 'react';
import { useIntl } from 'react-intl';
import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags';
import { Combobox } from '@/mastodon/components/form_fields';
import {
addFeaturedTag,
clearSearch,
updateSearchQuery,
} from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import classes from '../styles.module.scss';
export const AccountEditTagSearch: FC = () => {
const { query, isLoading, results } = useAppSelector(
(state) => state.profileEdit.search,
);
const dispatch = useAppDispatch();
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
void dispatch(updateSearchQuery(e.target.value));
},
[dispatch],
);
const intl = useIntl();
const handleSelect = useCallback(
(item: ApiFeaturedTagJSON) => {
void dispatch(clearSearch());
void dispatch(addFeaturedTag({ name: item.name }));
},
[dispatch],
);
return (
<Combobox
value={query}
onChange={handleSearchChange}
placeholder={intl.formatMessage({
id: 'account_edit_tags.search_placeholder',
defaultMessage: 'Enter a hashtag…',
})}
items={results ?? []}
isLoading={isLoading}
renderItem={renderItem}
onSelectItem={handleSelect}
className={classes.autoComplete}
icon={SearchIcon}
type='search'
/>
);
};
const renderItem = (item: ApiFeaturedTagJSON) => <p>#{item.name}</p>;

@ -0,0 +1,117 @@
import { useCallback, useEffect } from 'react';
import type { FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { Tag } from '@/mastodon/components/tags/tag';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import {
addFeaturedTag,
deleteFeaturedTag,
fetchFeaturedTags,
fetchSuggestedTags,
} from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
import { AccountEditItemList } from './components/item_list';
import { AccountEditTagSearch } from './components/tag_search';
import classes from './styles.module.scss';
const messages = defineMessages({
columnTitle: {
id: 'account_edit_tags.column_title',
defaultMessage: 'Edit featured hashtags',
},
});
export const AccountEditFeaturedTags: FC = () => {
const accountId = useCurrentAccountId();
const account = useAccount(accountId);
const intl = useIntl();
const { tags, tagSuggestions, isLoading, isPending } = useAppSelector(
(state) => state.profileEdit,
);
const dispatch = useAppDispatch();
useEffect(() => {
void dispatch(fetchFeaturedTags());
void dispatch(fetchSuggestedTags());
}, [dispatch]);
const handleDeleteTag = useCallback(
({ id }: { id: string }) => {
void dispatch(deleteFeaturedTag({ tagId: id }));
},
[dispatch],
);
if (!accountId || !account) {
return <AccountEditEmptyColumn notFound={!accountId} />;
}
return (
<AccountEditColumn
title={intl.formatMessage(messages.columnTitle)}
to='/profile/edit'
>
<div className={classes.wrapper}>
<FormattedMessage
id='account_edit_tags.help_text'
defaultMessage='Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.'
tagName='p'
/>
<AccountEditTagSearch />
{tagSuggestions.length > 0 && (
<div className={classes.tagSuggestions}>
<FormattedMessage
id='account_edit_tags.suggestions'
defaultMessage='Suggestions:'
/>
{tagSuggestions.map((tag) => (
<SuggestedTag name={tag.name} key={tag.id} disabled={isPending} />
))}
</div>
)}
{isLoading && <LoadingIndicator />}
<AccountEditItemList
items={tags}
disabled={isPending}
renderItem={renderTag}
onDelete={handleDeleteTag}
/>
</div>
</AccountEditColumn>
);
};
function renderTag(tag: ApiFeaturedTagJSON) {
return (
<div className={classes.tagItem}>
<h4>#{tag.name}</h4>
{tag.statuses_count > 0 && (
<FormattedMessage
id='account_edit_tags.tag_status_count'
defaultMessage='{count} posts'
values={{ count: tag.statuses_count }}
tagName='p'
/>
)}
</div>
);
}
const SuggestedTag: FC<{ name: string; disabled?: boolean }> = ({
name,
disabled,
}) => {
const dispatch = useAppDispatch();
const handleAddTag = useCallback(() => {
void dispatch(addFeaturedTag({ name }));
}, [dispatch, name]);
return <Tag name={name} onClick={handleAddTag} disabled={disabled} />;
};

@ -1,28 +1,31 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import type { FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import type { ModalType } from '@/mastodon/actions/modal';
import { openModal } from '@/mastodon/actions/modal';
import { AccountBio } from '@/mastodon/components/account_bio';
import { Avatar } from '@/mastodon/components/avatar';
import { Column } from '@/mastodon/components/column';
import { ColumnHeader } from '@/mastodon/components/column_header';
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import { autoPlayGif } from '@/mastodon/initial_state';
import { useAppDispatch } from '@/mastodon/store';
import { fetchFeaturedTags } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
import { EditButton } from './components/edit_button';
import { AccountEditSection } from './components/section';
import classes from './styles.module.scss';
const messages = defineMessages({
columnTitle: {
id: 'account_edit.column_title',
defaultMessage: 'Edit Profile',
},
displayNameTitle: {
id: 'account_edit.display_name.title',
defaultMessage: 'Display name',
@ -58,6 +61,10 @@ const messages = defineMessages({
defaultMessage:
'Help others identify, and have quick access to, your favorite topics.',
},
featuredHashtagsItem: {
id: 'account_edit.featured_hashtags.item',
defaultMessage: 'hashtags',
},
profileTabTitle: {
id: 'account_edit.profile_tab.title',
defaultMessage: 'Profile tab settings',
@ -68,12 +75,20 @@ const messages = defineMessages({
},
});
export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
export const AccountEdit: FC = () => {
const accountId = useCurrentAccountId();
const account = useAccount(accountId);
const intl = useIntl();
const dispatch = useAppDispatch();
const { tags: featuredTags, isLoading: isTagsLoading } = useAppSelector(
(state) => state.profileEdit,
);
useEffect(() => {
void dispatch(fetchFeaturedTags());
}, [dispatch]);
const handleOpenModal = useCallback(
(type: ModalType, props?: Record<string, unknown>) => {
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
@ -87,38 +102,25 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
handleOpenModal('ACCOUNT_EDIT_BIO');
}, [handleOpenModal]);
if (!accountId) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
const history = useHistory();
const handleFeaturedTagsEdit = useCallback(() => {
history.push('/profile/featured_tags');
}, [history]);
if (!account) {
return (
<Column bindToDocument={!multiColumn} className={classes.column}>
<LoadingIndicator />
</Column>
);
if (!accountId || !account) {
return <AccountEditEmptyColumn notFound={!accountId} />;
}
const headerSrc = autoPlayGif ? account.header : account.header_static;
const hasName = !!account.display_name;
const hasBio = !!account.note_plain;
const hasTags = !isTagsLoading && featuredTags.length > 0;
return (
<Column bindToDocument={!multiColumn} className={classes.column}>
<ColumnHeader
title={intl.formatMessage({
id: 'account_edit.column_title',
defaultMessage: 'Edit Profile',
})}
className={classes.columnHeader}
showBackButton
extraButton={
<Link to={`/@${account.acct}`} className='button'>
<FormattedMessage
id='account_edit.column_button'
defaultMessage='Done'
/>
</Link>
}
/>
<AccountEditColumn
title={intl.formatMessage(messages.columnTitle)}
to={`/@${account.acct}`}
>
<header>
<div className={classes.profileImage}>
{headerSrc && <img src={headerSrc} alt='' />}
@ -129,8 +131,14 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
<AccountEditSection
title={messages.displayNameTitle}
description={messages.displayNamePlaceholder}
showDescription={account.display_name.length === 0}
onEdit={handleNameEdit}
showDescription={!hasName}
buttons={
<EditButton
onClick={handleNameEdit}
item={messages.displayNameTitle}
edit={hasName}
/>
}
>
<DisplayNameSimple account={account} />
</AccountEditSection>
@ -138,8 +146,14 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
<AccountEditSection
title={messages.bioTitle}
description={messages.bioPlaceholder}
showDescription={!account.note_plain}
onEdit={handleBioEdit}
showDescription={!hasBio}
buttons={
<EditButton
onClick={handleBioEdit}
item={messages.bioTitle}
edit={hasBio}
/>
}
>
<AccountBio accountId={accountId} />
</AccountEditSection>
@ -153,14 +167,23 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
<AccountEditSection
title={messages.featuredHashtagsTitle}
description={messages.featuredHashtagsPlaceholder}
showDescription
/>
showDescription={!hasTags}
buttons={
<EditButton
onClick={handleFeaturedTagsEdit}
edit={hasTags}
item={messages.featuredHashtagsItem}
/>
}
>
{featuredTags.map((tag) => `#${tag.name}`).join(', ')}
</AccountEditSection>
<AccountEditSection
title={messages.profileTabTitle}
description={messages.profileTabSubtitle}
showDescription
/>
</Column>
</AccountEditColumn>
);
};

@ -1,15 +1,4 @@
.column {
border: 1px solid var(--color-border-primary);
border-top-width: 0;
}
.columnHeader {
:global(.column-header__buttons) {
align-items: center;
padding-inline-end: 16px;
height: auto;
}
}
// Profile Edit Page
.profileImage {
height: 120px;
@ -35,41 +24,42 @@
border: 1px solid var(--color-border-primary);
}
.section {
padding: 20px;
border-bottom: 1px solid var(--color-border-primary);
font-size: 15px;
// Featured Tags Page
.wrapper {
padding: 24px;
}
.sectionHeader {
.autoComplete,
.tagSuggestions {
margin: 12px 0;
}
.tagSuggestions {
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 8px;
> button {
border: 1px solid var(--color-border-primary);
border-radius: 8px;
box-sizing: border-box;
padding: 4px;
svg {
width: 20px;
height: 20px;
}
// Add more padding to the suggestions label
> span {
margin-right: 4px;
}
}
.sectionTitle {
flex-grow: 1;
font-size: 17px;
font-weight: 600;
}
.tagItem {
> h4 {
font-size: 15px;
font-weight: 500;
}
.sectionSubtitle {
color: var(--color-text-secondary);
> p {
color: var(--color-text-secondary);
}
}
// Modals
.inputWrapper {
position: relative;
}
@ -100,6 +90,104 @@ textarea.inputText {
}
}
// Column component
.column {
border: 1px solid var(--color-border-primary);
border-top-width: 0;
}
.columnHeader {
:global(.column-header__buttons) {
align-items: center;
padding-inline-end: 16px;
height: auto;
}
}
// Edit button component
.editButton {
border: 1px solid var(--color-border-primary);
border-radius: 8px;
box-sizing: border-box;
padding: 4px;
transition:
color 0.2s ease-in-out,
background-color 0.2s ease-in-out;
&:global(.button) {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 13px;
padding: 4px 8px;
&:active,
&:focus,
&:hover {
background-color: var(--color-bg-brand-softer);
}
}
svg {
width: 20px;
height: 20px;
}
}
.deleteButton {
--default-icon-color: var(--color-text-error);
--hover-bg-color: var(--color-bg-error-base-hover);
--hover-icon-color: var(--color-text-on-error-base);
}
// Item list component
.itemList {
> li {
display: flex;
align-items: center;
padding: 12px 0;
> :first-child {
flex-grow: 1;
}
}
}
.itemListButtons {
display: flex;
align-items: center;
gap: 4px;
}
// Section component
.section {
padding: 20px;
border-bottom: 1px solid var(--color-border-primary);
font-size: 15px;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.sectionTitle {
flex-grow: 1;
font-size: 17px;
font-weight: 600;
}
.sectionSubtitle {
color: var(--color-text-secondary);
}
// Counter component
.counter {
margin-top: 4px;
font-size: 13px;

@ -82,6 +82,7 @@ import {
AccountFeatured,
AccountAbout,
AccountEdit,
AccountEditFeaturedTags,
Quotes,
} from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context';
@ -164,9 +165,8 @@ class SwitchingColumnsArea extends PureComponent {
redirect = <Redirect from='/' to='/about' exact />;
}
const profileRedesignEnabled = isServerFeatureEnabled('profile_redesign');
const profileRedesignRoutes = [];
if (profileRedesignEnabled) {
if (isServerFeatureEnabled('profile_redesign')) {
profileRedesignRoutes.push(
<WrappedRoute key="posts" path={['/@:acct/posts', '/accounts/:id/posts']} exact component={AccountTimeline} content={children} />,
);
@ -188,13 +188,27 @@ class SwitchingColumnsArea extends PureComponent {
);
}
} else {
// If the redesign is not enabled but someone shares an /about link, redirect to the root.
profileRedesignRoutes.push(
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />,
// If the redesign is not enabled but someone shares an /about link, redirect to the root.
<Redirect key="about-acct-redirect" from='/@:acct/about' to='/@:acct' exact />,
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id' exact />
);
}
if (isClientFeatureEnabled('profile_editing')) {
profileRedesignRoutes.push(
<WrappedRoute key="edit" path='/profile/edit' component={AccountEdit} content={children} />,
<WrappedRoute key="featured_tags" path='/profile/featured_tags' component={AccountEditFeaturedTags} content={children} />
)
} else {
// If profile editing is not enabled, redirect to the home timeline as the current editing pages are outside React Router.
profileRedesignRoutes.push(
<Redirect key="edit-redirect" from='/profile/edit' to='/' exact />,
<Redirect key="featured-tags-redirect" from='/profile/featured_tags' to='/' exact />,
);
}
return (
<ColumnsContextProvider multiColumn={!singleColumn}>
<ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
@ -234,8 +248,6 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
{isClientFeatureEnabled('profile_editing') && <WrappedRoute key="edit" path='/profile/edit' component={AccountEdit} content={children} />}
<WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} />
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} />
@ -243,8 +255,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/search' component={Search} content={children} />
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
{!profileRedesignEnabled && <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />}
{...profileRedesignRoutes}
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />

@ -103,6 +103,11 @@ export function AccountEdit() {
.then((module) => ({ default: module.AccountEdit }));
}
export function AccountEditFeaturedTags() {
return import('../../account_edit/featured_tags')
.then((module) => ({ default: module.AccountEditFeaturedTags }));
}
export function Followers () {
return import('../../followers');
}

@ -145,6 +145,9 @@
"account_edit.bio.title": "Bio",
"account_edit.bio_modal.add_title": "Add bio",
"account_edit.bio_modal.edit_title": "Edit bio",
"account_edit.button.add": "Add {item}",
"account_edit.button.delete": "Delete {item}",
"account_edit.button.edit": "Edit {item}",
"account_edit.char_counter": "{currentLength}/{maxLength} characters",
"account_edit.column_button": "Done",
"account_edit.column_title": "Edit Profile",
@ -152,6 +155,7 @@
"account_edit.custom_fields.title": "Custom fields",
"account_edit.display_name.placeholder": "Your display name is how your name appears on your profile and in timelines.",
"account_edit.display_name.title": "Display name",
"account_edit.featured_hashtags.item": "hashtags",
"account_edit.featured_hashtags.placeholder": "Help others identify, and have quick access to, your favorite topics.",
"account_edit.featured_hashtags.title": "Featured hashtags",
"account_edit.name_modal.add_title": "Add display name",
@ -159,7 +163,11 @@
"account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.",
"account_edit.profile_tab.title": "Profile tab settings",
"account_edit.save": "Save",
"account_edit.section_edit_button": "Edit",
"account_edit_tags.column_title": "Edit featured hashtags",
"account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.",
"account_edit_tags.search_placeholder": "Enter a hashtag…",
"account_edit_tags.suggestions": "Suggestions:",
"account_edit_tags.tag_status_count": "{count} posts",
"account_note.placeholder": "Click to add note",
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",

@ -1,7 +1,9 @@
import { annualReport } from './annual_report';
import { collections } from './collections';
import { profileEdit } from './profile_edit';
export const sliceReducers = {
annualReport,
collections,
profileEdit,
};

@ -0,0 +1,178 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { debounce } from 'lodash';
import {
apiDeleteFeaturedTag,
apiGetCurrentFeaturedTags,
apiGetTagSuggestions,
apiPostFeaturedTag,
} from '@/mastodon/api/accounts';
import { apiGetSearch } from '@/mastodon/api/search';
import { hashtagToFeaturedTag } from '@/mastodon/api_types/tags';
import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags';
import type { AppDispatch } from '@/mastodon/store';
import {
createAppAsyncThunk,
createDataLoadingThunk,
} from '@/mastodon/store/typed_functions';
interface ProfileEditState {
tags: ApiFeaturedTagJSON[];
tagSuggestions: ApiFeaturedTagJSON[];
isLoading: boolean;
isPending: boolean;
search: {
query: string;
isLoading: boolean;
results?: ApiFeaturedTagJSON[];
};
}
const initialState: ProfileEditState = {
tags: [],
tagSuggestions: [],
isLoading: true,
isPending: false,
search: {
query: '',
isLoading: false,
},
};
const profileEditSlice = createSlice({
name: 'profileEdit',
initialState,
reducers: {
setSearchQuery(state, action: PayloadAction<string>) {
if (state.search.query === action.payload) {
return;
}
state.search.query = action.payload;
state.search.isLoading = false;
state.search.results = undefined;
},
clearSearch(state) {
state.search.query = '';
state.search.isLoading = false;
state.search.results = undefined;
},
},
extraReducers(builder) {
builder.addCase(fetchSuggestedTags.fulfilled, (state, action) => {
state.tagSuggestions = action.payload.map(hashtagToFeaturedTag);
state.isLoading = false;
});
builder.addCase(fetchFeaturedTags.fulfilled, (state, action) => {
state.tags = action.payload;
state.isLoading = false;
});
builder.addCase(addFeaturedTag.pending, (state) => {
state.isPending = true;
});
builder.addCase(addFeaturedTag.rejected, (state) => {
state.isPending = false;
});
builder.addCase(addFeaturedTag.fulfilled, (state, action) => {
state.tags = [...state.tags, action.payload].toSorted(
(a, b) => b.statuses_count - a.statuses_count,
);
state.tagSuggestions = state.tagSuggestions.filter(
(tag) => tag.name !== action.meta.arg.name,
);
state.isPending = false;
});
builder.addCase(deleteFeaturedTag.pending, (state) => {
state.isPending = true;
});
builder.addCase(deleteFeaturedTag.rejected, (state) => {
state.isPending = false;
});
builder.addCase(deleteFeaturedTag.fulfilled, (state, action) => {
state.tags = state.tags.filter((tag) => tag.id !== action.meta.arg.tagId);
state.isPending = false;
});
builder.addCase(fetchSearchResults.pending, (state) => {
state.search.isLoading = true;
});
builder.addCase(fetchSearchResults.rejected, (state) => {
state.search.isLoading = false;
state.search.results = undefined;
});
builder.addCase(fetchSearchResults.fulfilled, (state, action) => {
state.search.isLoading = false;
const searchResults: ApiFeaturedTagJSON[] = [];
const currentTags = new Set(state.tags.map((tag) => tag.name));
for (const tag of action.payload) {
if (currentTags.has(tag.name)) {
continue;
}
searchResults.push(hashtagToFeaturedTag(tag));
if (searchResults.length >= 10) {
break;
}
}
state.search.results = searchResults;
});
},
});
export const profileEdit = profileEditSlice.reducer;
export const { clearSearch } = profileEditSlice.actions;
export const fetchFeaturedTags = createDataLoadingThunk(
`${profileEditSlice.name}/fetchFeaturedTags`,
apiGetCurrentFeaturedTags,
{ useLoadingBar: false },
);
export const fetchSuggestedTags = createDataLoadingThunk(
`${profileEditSlice.name}/fetchSuggestedTags`,
apiGetTagSuggestions,
{ useLoadingBar: false },
);
export const addFeaturedTag = createDataLoadingThunk(
`${profileEditSlice.name}/addFeaturedTag`,
({ name }: { name: string }) => apiPostFeaturedTag(name),
{
condition(arg, { getState }) {
const state = getState();
return !state.profileEdit.tags.some((tag) => tag.name === arg.name);
},
},
);
export const deleteFeaturedTag = createDataLoadingThunk(
`${profileEditSlice.name}/deleteFeaturedTag`,
({ tagId }: { tagId: string }) => apiDeleteFeaturedTag(tagId),
);
const debouncedFetchSearchResults = debounce(
async (dispatch: AppDispatch, query: string) => {
await dispatch(fetchSearchResults({ q: query }));
},
300,
);
export const updateSearchQuery = createAppAsyncThunk(
`${profileEditSlice.name}/updateSearchQuery`,
(query: string, { dispatch }) => {
dispatch(profileEditSlice.actions.setSearchQuery(query));
if (query.trim().length > 0) {
void debouncedFetchSearchResults(dispatch, query);
}
},
);
export const fetchSearchResults = createDataLoadingThunk(
`${profileEditSlice.name}/fetchSearchResults`,
({ q }: { q: string }) => apiGetSearch({ q, type: 'hashtags', limit: 11 }),
(result) => result.hashtags,
);

@ -77,7 +77,8 @@ const initialState = ImmutableMap({
follow_requests: initialListState,
blocks: initialListState,
mutes: initialListState,
featured_tags: initialListState,
/** @type {ImmutableMap<string, typeof initialListState>} */
featured_tags: ImmutableMap(),
});
const normalizeList = (state, path, accounts, next) => {

Loading…
Cancel
Save