diff --git a/app/javascript/mastodon/api/antennas.ts b/app/javascript/mastodon/api/antennas.ts index d942a9592d..7324e95033 100644 --- a/app/javascript/mastodon/api/antennas.ts +++ b/app/javascript/mastodon/api/antennas.ts @@ -28,6 +28,16 @@ export const apiGetDomains = (antennaId: string) => limit: 0, }); +export const apiAddDomain = (antennaId: string, domain: string) => + apiRequestPost(`v1/antennas/${antennaId}/domains`, { + domains: [domain], + }); + +export const apiRemoveDomain = (antennaId: string, domain: string) => + apiRequestDelete(`v1/antennas/${antennaId}/domains`, { + domains: [domain], + }); + export const apiGetExcludeDomains = (antennaId: string) => apiRequestGet(`v1/antennas/${antennaId}/exclude_domains`, { limit: 0, diff --git a/app/javascript/mastodon/api_types/antennas.ts b/app/javascript/mastodon/api_types/antennas.ts index 58d315498b..75a8835276 100644 --- a/app/javascript/mastodon/api_types/antennas.ts +++ b/app/javascript/mastodon/api_types/antennas.ts @@ -10,17 +10,7 @@ export interface ApiAntennaJSON { insert_feeds: boolean; with_media_only: boolean; ignore_reblog: boolean; - accounts_count: number; - domains_count: number; - tags_count: number; - keywords_count: number; list: ApiListJSON | null; - domains: string[] | undefined; - exclude_domains: string[] | undefined; - keywords: string[] | undefined; - exclude_keywords: string[] | undefined; - tags: string[] | undefined; - exclude_tags: string[] | undefined; list_id: string | undefined; } diff --git a/app/javascript/mastodon/features/antenna_setting/index.tsx b/app/javascript/mastodon/features/antenna_setting/index.tsx new file mode 100644 index 0000000000..985fd1d9e4 --- /dev/null +++ b/app/javascript/mastodon/features/antenna_setting/index.tsx @@ -0,0 +1,417 @@ +import { useEffect, useState, useCallback } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { useParams, Link } from 'react-router-dom'; + +import DeleteIcon from '@/material-icons/400-24px/delete.svg?react'; +import DomainIcon from '@/material-icons/400-24px/dns.svg?react'; +//import EditIcon from '@/material-icons/400-24px/edit.svg?react'; +//import HashtagIcon from '@/material-icons/400-24px/tag.svg?react'; +//import KeywordIcon from '@/material-icons/400-24px/title.svg?react'; +import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react'; +import { + fetchAntenna, + fetchAntennaAccounts, + fetchAntennaDomains, + fetchAntennaExcludeAccounts, + fetchAntennaKeywords, + fetchAntennaTags, +} from 'mastodon/actions/antennas'; +import { + apiAddDomain, + apiGetAccounts, + apiRemoveDomain, +} from 'mastodon/api/antennas'; +import { Button } from 'mastodon/components/button'; +import Column from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import type { IconProp , Icon } from 'mastodon/components/icon'; +import { IconButton } from 'mastodon/components/icon_button'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + deleteMessage: { + id: 'confirmations.delete_antenna.message', + defaultMessage: 'Are you sure you want to permanently delete this antenna?', + }, + deleteConfirm: { + id: 'confirmations.delete_antenna.confirm', + defaultMessage: 'Delete', + }, + editAccounts: { + id: 'antennas.edit_accounts', + defaultMessage: 'Edit accounts', + }, + noOptions: { + id: 'antennas.select.no_options_message', + defaultMessage: 'Empty lists', + }, + placeholder: { + id: 'antennas.select.placeholder', + defaultMessage: 'Select list', + }, + addDomainLabel: { + id: 'antennas.add_domain_placeholder', + defaultMessage: 'New domain', + }, + addKeywordLabel: { + id: 'antennas.add_keyword_placeholder', + defaultMessage: 'New keyword', + }, + addTagLabel: { + id: 'antennas.add_tag_placeholder', + defaultMessage: 'New tag', + }, + addDomainTitle: { id: 'antennas.add_domain', defaultMessage: 'Add domain' }, + addKeywordTitle: { + id: 'antennas.add_keyword', + defaultMessage: 'Add keyword', + }, + addTagTitle: { id: 'antennas.add_tag', defaultMessage: 'Add tag' }, + accounts: { id: 'antennas.accounts', defaultMessage: '{count} accounts' }, + domains: { id: 'antennas.domains', defaultMessage: '{count} domains' }, + tags: { id: 'antennas.tags', defaultMessage: '{count} tags' }, + keywords: { id: 'antennas.keywords', defaultMessage: '{count} keywords' }, + setHome: { id: 'antennas.select.set_home', defaultMessage: 'Set home' }, +}); + +const TextListItem: React.FC<{ + icon: string; + iconComponent: IconProp; + value: string; + onRemove: (value: string) => void; +}> = ({ icon, iconComponent, value, onRemove }) => { + const handleRemove = useCallback(() => { onRemove(value); }, [onRemove, value]); + + return ( +
+ + {value} + +
+ ); +}; + +const TextList: React.FC<{ + values: string[]; + disabled?: boolean; + icon: string; + iconComponent: IconProp; + label: string; + title: string; + onAdd: (value: string) => void; + onRemove: (value: string) => void; +}> = ({ + values, + disabled, + icon, + iconComponent, + label, + title, + onAdd, + onRemove, +}) => { + const [value, setValue] = useState(''); + + const handleValueChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + setValue(value); + }, + [setValue], + ); + + const handleAdd = useCallback(() => { + onAdd(value); + setValue(''); + }, [onAdd, value]); + + const handleRemove = useCallback( + (removeValue: string) => { onRemove(removeValue); }, + [onRemove], + ); + + const handleSubmit = handleAdd; + + return ( +
+ {values.map((val) => ( + + ))} + +
+ + +
+ ); +}; + +const RadioPanel: React.FC<{ + items: { title: string; value: string }[]; + valueLengths: number[]; + alertMessage: React.ReactElement; + onChange: (value: string) => void; +}> = ({ items, valueLengths, alertMessage, onChange }) => { + const [error, setError] = useState(false); + const [value, setValue] = useState(''); + + useEffect(() => { + if (valueLengths.length >= 2) { + setError(valueLengths.slice(1).some((v) => v > 0)); + } else { + setError(false); + } + }, [valueLengths]); + + useEffect(() => { + if (items.length > 0 && !items.some((i) => i.value === value)) { + for (let i = 0; i < valueLengths.length; i++) { + const length = valueLengths[i] ?? 0; + const item = items[i] ?? { value: '' }; + if (length > 0) { + setValue(item.value); + return; + } + } + setValue(items[0]?.value ?? ''); + } + }, [items]); + + const handleChange = useCallback( + ({ currentTarget }: React.MouseEvent) => { + const selected = currentTarget.getAttribute('data-value') ?? ''; + if (value !== selected) { + onChange(selected); + setValue(selected); + } + }, + [setValue], + ); + + return ( +
+
+ {items.map((item) => ( + + ))} +
+ + {error &&
{alertMessage}
} +
+ ); +}; + +const MembersLink: React.FC<{ + id: string; + onCountFetched: (count: number) => void; +}> = ({ id, onCountFetched }) => { + const [count, setCount] = useState(0); + const [avatars, setAvatars] = useState([]); + + useEffect(() => { + void apiGetAccounts(id) + .then((data) => { + setCount(data.length); + onCountFetched(data.length); + setAvatars(data.slice(0, 3).map((a) => a.avatar)); + return ''; + }) + .catch(() => { + // Nothing + }); + }, [id, setCount, setAvatars]); + + return ( + +
+ + + + +
+ +
+ {avatars.map((url) => ( + + ))} +
+ + ); +}; + +const AntennaSetting: React.FC<{ + multiColumn?: boolean; +}> = ({ multiColumn }) => { + const dispatch = useAppDispatch(); + const { id } = useParams<{ id?: string }>(); + const intl = useIntl(); + //const history = useHistory(); + + const antenna = useAppSelector((state) => + id ? state.antennas.get(id) : undefined, + ); + const domains = useAppSelector((state) => + id ? state.antennas.getIn(['domains', id]) : undefined, + ) as string[] | undefined; + const [domainList, setDomainList] = useState([] as string[]); + const [accountsCount, setAccountsCount] = useState(0); + const [rangeMode, setRangeMode] = useState('accounts'); + + useEffect(() => { + if (id) { + dispatch(fetchAntenna(id)); + dispatch(fetchAntennaKeywords(id)); + dispatch(fetchAntennaDomains(id)); + dispatch(fetchAntennaTags(id)); + dispatch(fetchAntennaAccounts(id)); + dispatch(fetchAntennaExcludeAccounts(id)); + } + }, [dispatch, id]); + + useEffect(() => { + if (id && antenna) { + setDomainList(domains ?? []); + } + }, [id, antenna, domains, setDomainList]); + + const handleAccountsFetched = useCallback( + (count: number) => { + setAccountsCount(count); + }, + [setAccountsCount], + ); + + const handleAddDomain = useCallback( + (value: string) => { + if (!id) return; + + void apiAddDomain(id, value).then(() => { + setDomainList([...domainList, value]); + return value; + }); + }, + [id, domainList, setDomainList], + ); + + const handleRemoveDomain = useCallback( + (value: string) => { + if (!id) return; + + void apiRemoveDomain(id, value).then(() => { + setDomainList(domainList.filter((v) => v !== value)); + return value; + }); + }, + [id, domainList, setDomainList], + ); + + const handleRangeRadioChange = useCallback( + (value: string) => { + setRangeMode(value); + }, + [id, antenna, setRangeMode], + ); + + if (!antenna || !id) return
; + + const rangeRadioItems = [ + { + value: 'accounts', + title: intl.formatMessage(messages.accounts, { count: accountsCount }), + }, + { + value: 'domains', + title: intl.formatMessage(messages.domains, { count: domainList.length }), + }, + ]; + const rangeRadioLengths = [0, domainList.length]; + + return ( + + + +
+ + +
+ } + onChange={handleRangeRadioChange} + /> + {rangeMode === 'accounts' && ( + + )} + {rangeMode === 'domains' && ( + + )} +
+ + ); +}; + +// eslint-disable-next-line import/no-default-export +export default AntennaSetting; diff --git a/app/javascript/mastodon/features/antenna_setting/index.jsx b/app/javascript/mastodon/features/antenna_setting/index_.jsx similarity index 100% rename from app/javascript/mastodon/features/antenna_setting/index.jsx rename to app/javascript/mastodon/features/antenna_setting/index_.jsx diff --git a/app/javascript/mastodon/features/antennas/members.tsx b/app/javascript/mastodon/features/antennas/members.tsx index e8bfe41227..912080fb34 100644 --- a/app/javascript/mastodon/features/antennas/members.tsx +++ b/app/javascript/mastodon/features/antennas/members.tsx @@ -12,14 +12,14 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react'; import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react'; import { fetchFollowing } from 'mastodon/actions/accounts'; +import { fetchAntenna } from 'mastodon/actions/antennas'; import { importFetchedAccounts } from 'mastodon/actions/importer'; -import { fetchList } from 'mastodon/actions/lists'; import { apiRequest } from 'mastodon/api'; import { apiGetAccounts, - apiAddAccountToList, - apiRemoveAccountFromList, -} from 'mastodon/api/lists'; + apiAddAccountToAntenna, + apiRemoveAccountFromAntenna, +} from 'mastodon/api/antennas'; import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; import { Avatar } from 'mastodon/components/avatar'; import { Button } from 'mastodon/components/button'; @@ -36,14 +36,20 @@ import { me } from 'mastodon/initial_state'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; const messages = defineMessages({ - heading: { id: 'column.list_members', defaultMessage: 'Manage list members' }, + heading: { + id: 'column.antenna_members', + defaultMessage: 'Manage antenna members', + }, placeholder: { - id: 'lists.search_placeholder', + id: 'antennas.search_placeholder', defaultMessage: 'Search people you follow', }, - enterSearch: { id: 'lists.add_to_list', defaultMessage: 'Add to list' }, - add: { id: 'lists.add_member', defaultMessage: 'Add' }, - remove: { id: 'lists.remove_member', defaultMessage: 'Remove' }, + enterSearch: { + id: 'antennas.add_to_antenna', + defaultMessage: 'Add to antenna', + }, + add: { id: 'antennas.add_member', defaultMessage: 'Add' }, + remove: { id: 'antennas.remove_member', defaultMessage: 'Remove' }, back: { id: 'column_back_button.label', defaultMessage: 'Back' }, }); @@ -99,22 +105,22 @@ const ColumnSearchHeader: React.FC<{ const AccountItem: React.FC<{ accountId: string; - listId: string; - partOfList: boolean; + antennaId: string; + partOfAntenna: boolean; onToggle: (accountId: string) => void; -}> = ({ accountId, listId, partOfList, onToggle }) => { +}> = ({ accountId, antennaId, partOfAntenna, onToggle }) => { const intl = useIntl(); const account = useAppSelector((state) => state.accounts.get(accountId)); const handleClick = useCallback(() => { - if (partOfList) { - void apiRemoveAccountFromList(listId, accountId); + if (partOfAntenna) { + void apiRemoveAccountFromAntenna(antennaId, accountId); } else { - void apiAddAccountToList(listId, accountId); + void apiAddAccountToAntenna(antennaId, accountId); } onToggle(accountId); - }, [accountId, listId, partOfList, onToggle]); + }, [accountId, antennaId, partOfAntenna, onToggle]); if (!account) { return null; @@ -154,7 +160,7 @@ const AccountItem: React.FC<{