import './filters.css'; 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 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: 'Home and lists', notifications: 'Notifications', public: 'Public timelines', thread: 'Conversations', account: '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: 'Never', 1800: '30 minutes', 3600: '1 hour', 21600: '6 hours', 43200: '12 hours', 86_400: '24 hours', 604_800: '7 days', 2_592_000: '30 days', }; function Filters() { const { masto } = api(); useTitle(`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 && (
{filters.length} filter {filters.length === 1 ? '' : 's'}
)} ) : 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 { 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 ? 'Edit filter' : '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 expiresIn = Math.floor((expiresAtDate - new Date()) / 1000); } 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 ? 'Unable to edit filter' : 'Unable to create filter', ); } })(); }} >
{filteredEditKeywords.length ? (
    {filteredEditKeywords.map((k) => { const { id, keyword, wholeWord, _id } = k; return (
  • ); })}
) : (
No keywords. Add one.
)}
{' '} {filteredEditKeywords?.length > 1 && ( {filteredEditKeywords.length} keyword {filteredEditKeywords.length === 1 ? '' : 's'} )}
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 ? ( 'Expired' ) : hasExpiry ? ( <> Expiring ) : ( showNeverExpires && 'Never expires' ); } export default Filters;