mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 11:11:11 +02:00
[Glitch] Profile editing: Featured tags
Port ef6405ab28 to glitch-soc
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
import { apiRequestPost, apiRequestGet } from 'flavours/glitch/api';
|
||||
import {
|
||||
apiRequestPost,
|
||||
apiRequestGet,
|
||||
apiRequestDelete,
|
||||
} from 'flavours/glitch/api';
|
||||
import type {
|
||||
ApiAccountJSON,
|
||||
ApiFamiliarFollowersJSON,
|
||||
} from 'flavours/glitch/api_types/accounts';
|
||||
import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships';
|
||||
import type { ApiHashtagJSON } from 'flavours/glitch/api_types/tags';
|
||||
import type {
|
||||
ApiFeaturedTagJSON,
|
||||
ApiHashtagJSON,
|
||||
} from 'flavours/glitch/api_types/tags';
|
||||
|
||||
export const apiSubmitAccountNote = (id: string, value: string) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
|
||||
@@ -30,7 +37,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 '@/flavours/glitch/components/column';
|
||||
import { ColumnHeader } from '@/flavours/glitch/components/column_header';
|
||||
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||
import BundleColumnError from '@/flavours/glitch/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 '@/flavours/glitch/components/button';
|
||||
import { IconButton } from '@/flavours/glitch/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 '@/flavours/glitch/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 '@/flavours/glitch/api_types/tags';
|
||||
import { Combobox } from '@/flavours/glitch/components/form_fields';
|
||||
import {
|
||||
addFeaturedTag,
|
||||
clearSearch,
|
||||
updateSearchQuery,
|
||||
} from '@/flavours/glitch/reducers/slices/profile_edit';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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 '@/flavours/glitch/api_types/tags';
|
||||
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||
import { Tag } from '@/flavours/glitch/components/tags/tag';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||
import {
|
||||
addFeaturedTag,
|
||||
deleteFeaturedTag,
|
||||
fetchFeaturedTags,
|
||||
fetchSuggestedTags,
|
||||
} from '@/flavours/glitch/reducers/slices/profile_edit';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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 page’s 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 '@/flavours/glitch/actions/modal';
|
||||
import { openModal } from '@/flavours/glitch/actions/modal';
|
||||
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
||||
import { Avatar } from '@/flavours/glitch/components/avatar';
|
||||
import { Column } from '@/flavours/glitch/components/column';
|
||||
import { ColumnHeader } from '@/flavours/glitch/components/column_header';
|
||||
import { DisplayNameSimple } from '@/flavours/glitch/components/display_name/simple';
|
||||
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||
import { autoPlayGif } from '@/flavours/glitch/initial_state';
|
||||
import { useAppDispatch } from '@/flavours/glitch/store';
|
||||
import { fetchFeaturedTags } from '@/flavours/glitch/reducers/slices/profile_edit';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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,40 +24,41 @@
|
||||
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;
|
||||
}
|
||||
|
||||
> p {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
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;
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
AccountFeatured,
|
||||
AccountAbout,
|
||||
AccountEdit,
|
||||
AccountEditFeaturedTags,
|
||||
Quotes,
|
||||
} from './util/async-components';
|
||||
import { ColumnsContextProvider } from './util/columns_context';
|
||||
@@ -172,9 +173,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} />,
|
||||
);
|
||||
@@ -196,13 +196,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}>
|
||||
@@ -242,8 +256,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} />
|
||||
@@ -251,8 +263,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');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { annualReport } from './annual_report';
|
||||
import { collections } from './collections';
|
||||
import { profileEdit } from './profile_edit';
|
||||
|
||||
export const sliceReducers = {
|
||||
annualReport,
|
||||
collections,
|
||||
profileEdit,
|
||||
};
|
||||
|
||||
178
app/javascript/flavours/glitch/reducers/slices/profile_edit.ts
Normal file
178
app/javascript/flavours/glitch/reducers/slices/profile_edit.ts
Normal file
@@ -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 '@/flavours/glitch/api/accounts';
|
||||
import { apiGetSearch } from '@/flavours/glitch/api/search';
|
||||
import { hashtagToFeaturedTag } from '@/flavours/glitch/api_types/tags';
|
||||
import type { ApiFeaturedTagJSON } from '@/flavours/glitch/api_types/tags';
|
||||
import type { AppDispatch } from '@/flavours/glitch/store';
|
||||
import {
|
||||
createAppAsyncThunk,
|
||||
createDataLoadingThunk,
|
||||
} from '@/flavours/glitch/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) => {
|
||||
|
||||
Reference in New Issue
Block a user