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 ( <div id="filters-page" class="deck-container" tabIndex="-1"> <div class="timeline-deck deck"> <header> <div class="header-grid"> <div class="header-side"> <NavMenu /> <Link to="/" class="button plain"> <Icon icon="home" size="l" /> </Link> </div> <h1>Filters</h1> <div class="header-side"> <button type="button" class="plain" onClick={() => { setShowFiltersAddEditModal(true); }} > <Icon icon="plus" size="l" alt="New filter" /> </button> </div> </div> </header> <main> {filters.length > 0 ? ( <> <ul class="filters-list"> {filters.map((filter) => { const { id, title, expiresAt, keywords } = filter; return ( <li key={id}> <div> <h2>{title}</h2> {keywords?.length > 0 && ( <div> {keywords.map((k) => ( <> <span class="tag collapsed insignificant"> {k.wholeWord ? `“${k.keyword}”` : k.keyword} </span>{' '} </> ))} </div> )} <small class="insignificant"> <ExpiryStatus expiresAt={expiresAt} /> </small> </div> <button type="button" class="plain" onClick={() => { setShowFiltersAddEditModal({ filter, }); }} > <Icon icon="pencil" size="l" alt="Edit filter" /> </button> </li> ); })} </ul> {filters.length > 1 && ( <footer class="ui-state"> <small class="insignificant"> {filters.length} filter {filters.length === 1 ? '' : 's'} </small> </footer> )} </> ) : uiState === 'loading' ? ( <p class="ui-state"> <Loader /> </p> ) : uiState === 'error' ? ( <p class="ui-state">Unable to load filters.</p> ) : ( <p class="ui-state">No filters yet.</p> )} </main> </div> {!!showFiltersAddEditModal && ( <Modal title="Add filter" onClose={() => { setShowFiltersAddEditModal(false); }} > <FiltersAddEdit filter={showFiltersAddEditModal?.filter} onClose={(result) => { if (result.state === 'success') { reload(); } setShowFiltersAddEditModal(false); }} /> </Modal> )} </div> ); } 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 ( <div class="sheet" id="filters-add-edit-modal"> {!!onClose && ( <button type="button" class="sheet-close" onClick={onClose}> <Icon icon="x" /> </button> )} <header> <h2>{editMode ? 'Edit filter' : 'New filter'}</h2> </header> <main> <form onSubmit={(e) => { 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 ? 'Unable to edit filter' : 'Unable to create filter', ); } })(); }} > <div class="filter-form-row"> <label> <b>Title</b> <input type="text" name="title" defaultValue={title} disabled={uiState === 'loading'} dir="auto" required /> </label> </div> <div class="filter-form-keywords" ref={keywordsRef}> {filteredEditKeywords.length ? ( <ul class="filter-keywords"> {filteredEditKeywords.map((k) => { const { id, keyword, wholeWord, _id } = k; return ( <li key={`${id}-${_id}`}> <input type="hidden" name="keyword_attributes[][id]" value={id} /> <input name="keyword_attributes[][keyword]" type="text" defaultValue={keyword} disabled={uiState === 'loading'} required dir="auto" /> <div class="filter-keyword-actions"> <label> <input name="keyword_attributes[][whole_word]" type="checkbox" value={id} // Hacky way to map checkbox boolean to the keyword id defaultChecked={wholeWord} disabled={uiState === 'loading'} />{' '} Whole word </label> <button type="button" class="light danger small" disabled={uiState === 'loading'} onClick={() => { if (id) { removedKeywordIDs.push(id); setRemovedKeywordIDs([...removedKeywordIDs]); } else if (_id) { removedKeyword_IDs.push(_id); setRemovedKeyword_IDs([...removedKeyword_IDs]); } }} > <Icon icon="x" /> </button> </div> </li> ); })} </ul> ) : ( <div class="filter-keywords"> <div class="insignificant">No keywords. Add one.</div> </div> )} <footer class="filter-keywords-footer"> <button type="button" class="light" onClick={() => { setEditKeywords([ ...editKeywords, { _id: incID(), keyword: '', wholeWord: true, }, ]); setTimeout(() => { // Focus last input const fields = keywordsRef.current.querySelectorAll( 'input[type="text"]', ); fields[fields.length - 1]?.focus?.(); }, 10); }} > Add keyword </button>{' '} {filteredEditKeywords?.length > 1 && ( <small class="insignificant"> {filteredEditKeywords.length} keyword {filteredEditKeywords.length === 1 ? '' : 's'} </small> )} </footer> </div> <div class="filter-form-cols"> <div class="filter-form-col"> <div> <b>Filter from…</b> </div> {FILTER_CONTEXT.map((ctx) => ( <div> <label class={ FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? 'insignificant' : '' } > <input type="checkbox" name="context" value={ctx} defaultChecked={!!context ? context.includes(ctx) : true} disabled={uiState === 'loading'} />{' '} {FILTER_CONTEXT_LABELS[ctx]} {FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? '*' : ''} </label>{' '} </div> ))} <p> <small class="insignificant">* Not implemented yet</small> </p> </div> <div class="filter-form-col"> {editMode && ( <> Status:{' '} <b> <ExpiryStatus expiresAt={expiresAt} showNeverExpires /> </b> </> )} <div> <label for="filters-expires_in"> {editMode ? 'Change expiry' : 'Expiry'} </label> <select id="filters-expires_in" name="expires_in" disabled={uiState === 'loading'} defaultValue={editMode ? undefined : 0} > {editMode && <option></option>} {EXPIRY_DURATIONS.map((v) => ( <option value={v}>{EXPIRY_DURATIONS_LABELS[v]}</option> ))} </select> </div> <p> Filtered post will be… <br /> <label class="ib"> <input type="radio" name="filter_action" value="warn" defaultChecked={filterAction === 'warn' || !editMode} disabled={uiState === 'loading'} />{' '} minimized </label>{' '} <label class="ib"> <input type="radio" name="filter_action" value="hide" defaultChecked={filterAction === 'hide'} disabled={uiState === 'loading'} />{' '} hidden </label> </p> </div> </div> <footer class="filter-form-footer"> <span> <button type="submit" disabled={uiState === 'loading'}> {editMode ? 'Save' : 'Create'} </button>{' '} <Loader abrupt hidden={uiState !== 'loading'} /> </span> {editMode && ( <MenuConfirm disabled={uiState === 'loading'} align="end" menuItemClassName="danger" confirmLabel="Delete this filter?" onClick={() => { setUIState('loading'); (async () => { try { await masto.v2.filters.$select(id).remove(); setUIState('default'); onClose?.({ state: 'success', }); } catch (e) { console.error(e); setUIState('error'); alert('Unable to delete filter.'); } })(); }} > <button type="button" class="light danger" onClick={() => {}} disabled={uiState === 'loading'} > Delete… </button> </MenuConfirm> )} </footer> </form> </main> </div> ); } 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 <RelativeTime datetime={expiresAtDate} /> </> ) : ( showNeverExpires && 'Never expires' ); } export default Filters;