import './filters.css'; import { i18n } from '@lingui/core'; import { msg, Plural, t, Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { useEffect, useReducer, useRef, useState } from 'preact/hooks'; import Icon from '../components/icon'; import Link from '../components/link'; import Loader from '../components/loader'; import MenuConfirm from '../components/menu-confirm'; import Modal from '../components/modal'; import NavMenu from '../components/nav-menu'; import RelativeTime from '../components/relative-time'; import { api } from '../utils/api'; import i18nDuration from '../utils/i18n-duration'; import useInterval from '../utils/useInterval'; import useTitle from '../utils/useTitle'; const FILTER_CONTEXT = ['home', 'public', 'notifications', 'thread', 'account']; const FILTER_CONTEXT_UNIMPLEMENTED = ['notifications', 'thread', 'account']; const FILTER_CONTEXT_LABELS = { home: msg`Home and lists`, notifications: msg`Notifications`, public: msg`Public timelines`, thread: msg`Conversations`, account: msg`Profiles`, }; const EXPIRY_DURATIONS = [ 0, // forever 30 * 60, // 30 minutes 60 * 60, // 1 hour 6 * 60 * 60, // 6 hours 12 * 60 * 60, // 12 hours 60 * 60 * 24, // 24 hours 60 * 60 * 24 * 7, // 7 days 60 * 60 * 24 * 30, // 30 days ]; const EXPIRY_DURATIONS_LABELS = { 0: msg`Never`, 1800: i18nDuration(30, 'minute'), 3600: i18nDuration(1, 'hour'), 21600: i18nDuration(6, 'hour'), 43200: i18nDuration(12, 'hour'), 86_400: i18nDuration(24, 'hour'), 604_800: i18nDuration(7, 'day'), 2_592_000: i18nDuration(30, 'day'), }; function Filters() { const { masto } = api(); useTitle(t`Filters`, `/ft`); const [uiState, setUIState] = useState('default'); const [showFiltersAddEditModal, setShowFiltersAddEditModal] = useState(false); const [reloadCount, reload] = useReducer((c) => c + 1, 0); const [filters, setFilters] = useState([]); useEffect(() => { setUIState('loading'); (async () => { try { const filters = await masto.v2.filters.list(); filters.sort((a, b) => a.title.localeCompare(b.title)); filters.forEach((filter) => { if (filter.keywords?.length) { filter.keywords.sort((a, b) => a.id - b.id); } }); console.log(filters); setFilters(filters); setUIState('default'); } catch (e) { console.error(e); setUIState('error'); } })(); }, [reloadCount]); return (

Filters

{filters.length > 0 ? ( <>
    {filters.map((filter) => { const { id, title, expiresAt, keywords } = filter; return (
  • {title}

    {keywords?.length > 0 && (
    {keywords.map((k) => ( <> {' '} ))}
    )}
  • ); })}
{filters.length > 1 && (
)} ) : uiState === 'loading' ? (

) : uiState === 'error' ? (

Unable to load filters.

) : (

No filters yet.

)}
{!!showFiltersAddEditModal && ( { setShowFiltersAddEditModal(false); }} > { if (result.state === 'success') { reload(); } setShowFiltersAddEditModal(false); }} /> )}
); } let _id = 1; const incID = () => _id++; function FiltersAddEdit({ filter, onClose }) { const { _ } = useLingui(); const { masto } = api(); const [uiState, setUIState] = useState('default'); const editMode = !!filter; const { context, expiresAt, id, keywords, title, filterAction } = filter || {}; const hasExpiry = !!expiresAt; const expiresAtDate = hasExpiry && new Date(expiresAt); const [editKeywords, setEditKeywords] = useState(keywords || []); const keywordsRef = useRef(); // Hacky way of handling removed keywords for both existing and new ones const [removedKeywordIDs, setRemovedKeywordIDs] = useState([]); const [removedKeyword_IDs, setRemovedKeyword_IDs] = useState([]); const filteredEditKeywords = editKeywords.filter( (k) => !removedKeywordIDs.includes(k.id) && !removedKeyword_IDs.includes(k._id), ); return (
{!!onClose && ( )}

{editMode ? t`Edit filter` : t`New filter`}

{ e.preventDefault(); const formData = new FormData(e.target); const title = formData.get('title'); const keywordIDs = formData.getAll('keyword_attributes[][id]'); const keywordKeywords = formData.getAll( 'keyword_attributes[][keyword]', ); // const keywordWholeWords = formData.getAll( // 'keyword_attributes[][whole_word]', // ); // Not using getAll because it skips the empty checkboxes const keywordWholeWords = [ ...keywordsRef.current.querySelectorAll( 'input[name="keyword_attributes[][whole_word]"]', ), ].map((i) => i.checked); const keywordsAttributes = keywordKeywords.map((k, i) => ({ id: keywordIDs[i] || undefined, keyword: k, wholeWord: keywordWholeWords[i], })); // if (editMode && keywords?.length) { // // Find which one got deleted and add to keywordsAttributes // keywords.forEach((k) => { // if (!keywordsAttributes.find((ka) => ka.id === k.id)) { // keywordsAttributes.push({ // ...k, // _destroy: true, // }); // } // }); // } if (editMode && removedKeywordIDs?.length) { removedKeywordIDs.forEach((id) => { keywordsAttributes.push({ id, _destroy: true, }); }); } const context = formData.getAll('context'); let expiresIn = formData.get('expires_in'); const filterAction = formData.get('filter_action'); console.log({ title, keywordIDs, keywords: keywordKeywords, wholeWords: keywordWholeWords, keywordsAttributes, context, expiresIn, filterAction, }); // Required fields if (!title || !context?.length) { return; } setUIState('loading'); (async () => { try { let filterResult; if (editMode) { if (expiresIn === '' || expiresIn === null) { // No value // Preserve existing expiry if not specified // Seconds from now to expiresAtDate // Other clients don't do this if (hasExpiry) { expiresIn = Math.floor( (expiresAtDate - new Date()) / 1000, ); } else { expiresIn = null; } } else if (expiresIn === '0' || expiresIn === 0) { // 0 = Never expiresIn = null; } else { expiresIn = +expiresIn; } filterResult = await masto.v2.filters.$select(id).update({ title, context, expiresIn, keywordsAttributes, filterAction, }); } else { expiresIn = +expiresIn || null; filterResult = await masto.v2.filters.create({ title, context, expiresIn, keywordsAttributes, filterAction, }); } console.log({ filterResult }); setUIState('default'); onClose?.({ state: 'success', filter: filterResult, }); } catch (error) { console.error(error); setUIState('error'); alert( editMode ? t`Unable to edit filter` : t`Unable to create filter`, ); } })(); }} >
{filteredEditKeywords.length ? (
    {filteredEditKeywords.map((k) => { const { id, keyword, wholeWord, _id } = k; return (
  • ); })}
) : (
No keywords. Add one.
)}
{' '} {filteredEditKeywords?.length > 1 && ( )}
Filter from…
{FILTER_CONTEXT.map((ctx) => (
{' '}
))}

* Not implemented yet

{editMode && ( Status:{' '} )}

Filtered post will be…
{' '}

); } function ExpiryStatus({ expiresAt, showNeverExpires }) { const hasExpiry = !!expiresAt; const expiresAtDate = hasExpiry && new Date(expiresAt); const expired = hasExpiry && expiresAtDate <= new Date(); // If less than a minute left, re-render interval every second, else every minute const [_, rerender] = useReducer((c) => c + 1, 0); useInterval(rerender, expired || 30_000); return expired ? ( t`Expired` ) : hasExpiry ? ( Expiring ) : ( showNeverExpires && t`Never expires` ); } export default Filters;