mirror of https://github.com/mastodon/mastodon
Profile editing: Featured tags (#37952)
parent
e2aecd040c
commit
ef6405ab28
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>;
|
||||
@ -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,
|
||||
);
|
||||
Loading…
Reference in New Issue