[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 {
|
import type {
|
||||||
ApiAccountJSON,
|
ApiAccountJSON,
|
||||||
ApiFamiliarFollowersJSON,
|
ApiFamiliarFollowersJSON,
|
||||||
} from 'flavours/glitch/api_types/accounts';
|
} from 'flavours/glitch/api_types/accounts';
|
||||||
import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships';
|
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) =>
|
export const apiSubmitAccountNote = (id: string, value: string) =>
|
||||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
|
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
|
||||||
@@ -30,7 +37,19 @@ export const apiRemoveAccountFromFollowers = (id: string) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const apiGetFeaturedTags = (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) =>
|
export const apiGetEndorsedAccounts = (id: string) =>
|
||||||
apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);
|
apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);
|
||||||
|
|||||||
@@ -4,11 +4,29 @@ interface ApiHistoryJSON {
|
|||||||
uses: string;
|
uses: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiHashtagJSON {
|
interface ApiHashtagBase {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiHashtagJSON extends ApiHashtagBase {
|
||||||
history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
|
history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
|
||||||
following?: boolean;
|
following?: boolean;
|
||||||
featuring?: 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`
|
* 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
|
* Providing this function turns the combobox into a multi-select box that assumes
|
||||||
* multiple options to be selectable. Single-selection is handled automatically.
|
* multiple options to be selectable. Single-selection is handled automatically.
|
||||||
@@ -113,7 +113,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
value,
|
value,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
items,
|
items,
|
||||||
getItemId,
|
getItemId = (item) => item.id,
|
||||||
getIsItemDisabled,
|
getIsItemDisabled,
|
||||||
getIsItemSelected,
|
getIsItemSelected,
|
||||||
disabled,
|
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 { FC, ReactNode } from 'react';
|
||||||
|
|
||||||
import type { MessageDescriptor } from 'react-intl';
|
import type { MessageDescriptor } from 'react-intl';
|
||||||
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
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';
|
import classes from '../styles.module.scss';
|
||||||
|
|
||||||
const buttonMessage = defineMessage({
|
|
||||||
id: 'account_edit.section_edit_button',
|
|
||||||
defaultMessage: 'Edit',
|
|
||||||
});
|
|
||||||
|
|
||||||
interface AccountEditSectionProps {
|
interface AccountEditSectionProps {
|
||||||
title: MessageDescriptor;
|
title: MessageDescriptor;
|
||||||
description?: MessageDescriptor;
|
description?: MessageDescriptor;
|
||||||
showDescription?: boolean;
|
showDescription?: boolean;
|
||||||
onEdit?: () => void;
|
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
extraButtons?: ReactNode;
|
buttons?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AccountEditSection: FC<AccountEditSectionProps> = ({
|
export const AccountEditSection: FC<AccountEditSectionProps> = ({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
showDescription,
|
showDescription,
|
||||||
onEdit,
|
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
extraButtons,
|
buttons,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
return (
|
return (
|
||||||
<section className={classNames(className, classes.section)}>
|
<section className={classNames(className, classes.section)}>
|
||||||
<header className={classes.sectionHeader}>
|
<header className={classes.sectionHeader}>
|
||||||
<h3 className={classes.sectionTitle}>
|
<h3 className={classes.sectionTitle}>
|
||||||
<FormattedMessage {...title} />
|
<FormattedMessage {...title} />
|
||||||
</h3>
|
</h3>
|
||||||
{onEdit && (
|
{buttons}
|
||||||
<IconButton
|
|
||||||
icon='pencil'
|
|
||||||
iconComponent={EditIcon}
|
|
||||||
onClick={onEdit}
|
|
||||||
title={`${intl.formatMessage(buttonMessage)} ${intl.formatMessage(title)}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{extraButtons}
|
|
||||||
</header>
|
</header>
|
||||||
{showDescription && (
|
{showDescription && (
|
||||||
<p className={classes.sectionSubtitle}>
|
<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 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 type { ModalType } from '@/flavours/glitch/actions/modal';
|
||||||
import { openModal } from '@/flavours/glitch/actions/modal';
|
import { openModal } from '@/flavours/glitch/actions/modal';
|
||||||
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
||||||
import { Avatar } from '@/flavours/glitch/components/avatar';
|
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 { 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 { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||||
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||||
import { autoPlayGif } from '@/flavours/glitch/initial_state';
|
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 { AccountEditSection } from './components/section';
|
||||||
import classes from './styles.module.scss';
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
columnTitle: {
|
||||||
|
id: 'account_edit.column_title',
|
||||||
|
defaultMessage: 'Edit Profile',
|
||||||
|
},
|
||||||
displayNameTitle: {
|
displayNameTitle: {
|
||||||
id: 'account_edit.display_name.title',
|
id: 'account_edit.display_name.title',
|
||||||
defaultMessage: 'Display name',
|
defaultMessage: 'Display name',
|
||||||
@@ -58,6 +61,10 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Help others identify, and have quick access to, your favorite topics.',
|
'Help others identify, and have quick access to, your favorite topics.',
|
||||||
},
|
},
|
||||||
|
featuredHashtagsItem: {
|
||||||
|
id: 'account_edit.featured_hashtags.item',
|
||||||
|
defaultMessage: 'hashtags',
|
||||||
|
},
|
||||||
profileTabTitle: {
|
profileTabTitle: {
|
||||||
id: 'account_edit.profile_tab.title',
|
id: 'account_edit.profile_tab.title',
|
||||||
defaultMessage: 'Profile tab settings',
|
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 accountId = useCurrentAccountId();
|
||||||
const account = useAccount(accountId);
|
const account = useAccount(accountId);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { tags: featuredTags, isLoading: isTagsLoading } = useAppSelector(
|
||||||
|
(state) => state.profileEdit,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
void dispatch(fetchFeaturedTags());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleOpenModal = useCallback(
|
const handleOpenModal = useCallback(
|
||||||
(type: ModalType, props?: Record<string, unknown>) => {
|
(type: ModalType, props?: Record<string, unknown>) => {
|
||||||
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
|
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
|
||||||
@@ -87,38 +102,25 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||||||
handleOpenModal('ACCOUNT_EDIT_BIO');
|
handleOpenModal('ACCOUNT_EDIT_BIO');
|
||||||
}, [handleOpenModal]);
|
}, [handleOpenModal]);
|
||||||
|
|
||||||
if (!accountId) {
|
const history = useHistory();
|
||||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
const handleFeaturedTagsEdit = useCallback(() => {
|
||||||
}
|
history.push('/profile/featured_tags');
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
if (!account) {
|
if (!accountId || !account) {
|
||||||
return (
|
return <AccountEditEmptyColumn notFound={!accountId} />;
|
||||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerSrc = autoPlayGif ? account.header : account.header_static;
|
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 (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
<AccountEditColumn
|
||||||
<ColumnHeader
|
title={intl.formatMessage(messages.columnTitle)}
|
||||||
title={intl.formatMessage({
|
to={`/@${account.acct}`}
|
||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<header>
|
<header>
|
||||||
<div className={classes.profileImage}>
|
<div className={classes.profileImage}>
|
||||||
{headerSrc && <img src={headerSrc} alt='' />}
|
{headerSrc && <img src={headerSrc} alt='' />}
|
||||||
@@ -129,8 +131,14 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||||||
<AccountEditSection
|
<AccountEditSection
|
||||||
title={messages.displayNameTitle}
|
title={messages.displayNameTitle}
|
||||||
description={messages.displayNamePlaceholder}
|
description={messages.displayNamePlaceholder}
|
||||||
showDescription={account.display_name.length === 0}
|
showDescription={!hasName}
|
||||||
onEdit={handleNameEdit}
|
buttons={
|
||||||
|
<EditButton
|
||||||
|
onClick={handleNameEdit}
|
||||||
|
item={messages.displayNameTitle}
|
||||||
|
edit={hasName}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<DisplayNameSimple account={account} />
|
<DisplayNameSimple account={account} />
|
||||||
</AccountEditSection>
|
</AccountEditSection>
|
||||||
@@ -138,8 +146,14 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||||||
<AccountEditSection
|
<AccountEditSection
|
||||||
title={messages.bioTitle}
|
title={messages.bioTitle}
|
||||||
description={messages.bioPlaceholder}
|
description={messages.bioPlaceholder}
|
||||||
showDescription={!account.note_plain}
|
showDescription={!hasBio}
|
||||||
onEdit={handleBioEdit}
|
buttons={
|
||||||
|
<EditButton
|
||||||
|
onClick={handleBioEdit}
|
||||||
|
item={messages.bioTitle}
|
||||||
|
edit={hasBio}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<AccountBio accountId={accountId} />
|
<AccountBio accountId={accountId} />
|
||||||
</AccountEditSection>
|
</AccountEditSection>
|
||||||
@@ -153,14 +167,23 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||||||
<AccountEditSection
|
<AccountEditSection
|
||||||
title={messages.featuredHashtagsTitle}
|
title={messages.featuredHashtagsTitle}
|
||||||
description={messages.featuredHashtagsPlaceholder}
|
description={messages.featuredHashtagsPlaceholder}
|
||||||
showDescription
|
showDescription={!hasTags}
|
||||||
/>
|
buttons={
|
||||||
|
<EditButton
|
||||||
|
onClick={handleFeaturedTagsEdit}
|
||||||
|
edit={hasTags}
|
||||||
|
item={messages.featuredHashtagsItem}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{featuredTags.map((tag) => `#${tag.name}`).join(', ')}
|
||||||
|
</AccountEditSection>
|
||||||
|
|
||||||
<AccountEditSection
|
<AccountEditSection
|
||||||
title={messages.profileTabTitle}
|
title={messages.profileTabTitle}
|
||||||
description={messages.profileTabSubtitle}
|
description={messages.profileTabSubtitle}
|
||||||
showDescription
|
showDescription
|
||||||
/>
|
/>
|
||||||
</Column>
|
</AccountEditColumn>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
.column {
|
// Profile Edit Page
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profileImage {
|
.profileImage {
|
||||||
height: 120px;
|
height: 120px;
|
||||||
@@ -35,40 +24,41 @@
|
|||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
// Featured Tags Page
|
||||||
padding: 20px;
|
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
.wrapper {
|
||||||
font-size: 15px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHeader {
|
.autoComplete,
|
||||||
|
.tagSuggestions {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagSuggestions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
|
|
||||||
> button {
|
// Add more padding to the suggestions label
|
||||||
border: 1px solid var(--color-border-primary);
|
> span {
|
||||||
border-radius: 8px;
|
margin-right: 4px;
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 4px;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.tagItem {
|
||||||
flex-grow: 1;
|
> h4 {
|
||||||
font-size: 17px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
> p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionSubtitle {
|
// Modals
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputWrapper {
|
.inputWrapper {
|
||||||
position: relative;
|
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 {
|
.counter {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import {
|
|||||||
AccountFeatured,
|
AccountFeatured,
|
||||||
AccountAbout,
|
AccountAbout,
|
||||||
AccountEdit,
|
AccountEdit,
|
||||||
|
AccountEditFeaturedTags,
|
||||||
Quotes,
|
Quotes,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { ColumnsContextProvider } from './util/columns_context';
|
import { ColumnsContextProvider } from './util/columns_context';
|
||||||
@@ -172,9 +173,8 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
redirect = <Redirect from='/' to='/about' exact />;
|
redirect = <Redirect from='/' to='/about' exact />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileRedesignEnabled = isServerFeatureEnabled('profile_redesign');
|
|
||||||
const profileRedesignRoutes = [];
|
const profileRedesignRoutes = [];
|
||||||
if (profileRedesignEnabled) {
|
if (isServerFeatureEnabled('profile_redesign')) {
|
||||||
profileRedesignRoutes.push(
|
profileRedesignRoutes.push(
|
||||||
<WrappedRoute key="posts" path={['/@:acct/posts', '/accounts/:id/posts']} exact component={AccountTimeline} content={children} />,
|
<WrappedRoute key="posts" path={['/@:acct/posts', '/accounts/:id/posts']} exact component={AccountTimeline} content={children} />,
|
||||||
);
|
);
|
||||||
@@ -196,13 +196,27 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If the redesign is not enabled but someone shares an /about link, redirect to the root.
|
|
||||||
profileRedesignRoutes.push(
|
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-acct-redirect" from='/@:acct/about' to='/@:acct' exact />,
|
||||||
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id' 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 (
|
return (
|
||||||
<ColumnsContextProvider multiColumn={!singleColumn}>
|
<ColumnsContextProvider multiColumn={!singleColumn}>
|
||||||
<ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
|
<ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
|
||||||
@@ -242,8 +256,6 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
|
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
|
||||||
<WrappedRoute path='/pinned' component={PinnedStatuses} 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', '/start/profile']} exact component={OnboardingProfile} content={children} />
|
||||||
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
|
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
|
||||||
<WrappedRoute path='/directory' component={Directory} 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='/search' component={Search} content={children} />
|
||||||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||||
|
|
||||||
{!profileRedesignEnabled && <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />}
|
|
||||||
{...profileRedesignRoutes}
|
{...profileRedesignRoutes}
|
||||||
|
|
||||||
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
|
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
|
||||||
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} 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 }} />
|
<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 }));
|
.then((module) => ({ default: module.AccountEdit }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AccountEditFeaturedTags() {
|
||||||
|
return import('../../account_edit/featured_tags')
|
||||||
|
.then((module) => ({ default: module.AccountEditFeaturedTags }));
|
||||||
|
}
|
||||||
|
|
||||||
export function Followers () {
|
export function Followers () {
|
||||||
return import('../../followers');
|
return import('../../followers');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { annualReport } from './annual_report';
|
import { annualReport } from './annual_report';
|
||||||
import { collections } from './collections';
|
import { collections } from './collections';
|
||||||
|
import { profileEdit } from './profile_edit';
|
||||||
|
|
||||||
export const sliceReducers = {
|
export const sliceReducers = {
|
||||||
annualReport,
|
annualReport,
|
||||||
collections,
|
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,
|
follow_requests: initialListState,
|
||||||
blocks: initialListState,
|
blocks: initialListState,
|
||||||
mutes: initialListState,
|
mutes: initialListState,
|
||||||
featured_tags: initialListState,
|
/** @type {ImmutableMap<string, typeof initialListState>} */
|
||||||
|
featured_tags: ImmutableMap(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeList = (state, path, accounts, next) => {
|
const normalizeList = (state, path, accounts, next) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user