diff --git a/src/app.jsx b/src/app.jsx index 8e39b0e..c9b7c36 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -27,6 +27,7 @@ import AccountStatuses from './pages/account-statuses'; import Bookmarks from './pages/bookmarks'; // import Catchup from './pages/catchup'; import Favourites from './pages/favourites'; +import Filters from './pages/filters'; import FollowedHashtags from './pages/followed-hashtags'; import Following from './pages/following'; import Hashtag from './pages/hashtag'; @@ -463,7 +464,8 @@ function SecondaryRoutes({ isLoggedIn }) { } /> } /> - } /> + } /> + } /> import('@iconify-icons/mingcute/refresh-2-line'), emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'), filter: () => import('@iconify-icons/mingcute/filter-2-line'), + filters: () => import('@iconify-icons/mingcute/filter-line'), chart: () => import('@iconify-icons/mingcute/chart-line-line'), react: () => import('@iconify-icons/mingcute/react-line'), layout4: () => import('@iconify-icons/mingcute/layout-4-line'), diff --git a/src/components/nav-menu.jsx b/src/components/nav-menu.jsx index aa6aa69..0d4fe98 100644 --- a/src/components/nav-menu.jsx +++ b/src/components/nav-menu.jsx @@ -223,11 +223,15 @@ function NavMenu(props) { Likes - + {' '} Followed Hashtags + + + Filters + { states.showGenericAccounts = { diff --git a/src/pages/filters.css b/src/pages/filters.css new file mode 100644 index 0000000..43daff0 --- /dev/null +++ b/src/pages/filters.css @@ -0,0 +1,149 @@ +#filters-page { + .filters-list { + list-style: none; + padding: 0; + margin: 0; + + li { + padding: 8px 16px; + border-bottom: var(--hairline-width) solid var(--outline-color); + display: flex; + align-items: center; + justify-content: space-between; + } + + h2 { + font-weight: 500; + margin: 0; + padding: 0; + font-size: 1em; + } + } +} + +#filters-add-edit-modal { + .filter-form-row { + margin-bottom: 16px; + + + .filter-form-row { + margin-top: 16px; + border-top: 1px solid var(--outline-color); + padding-top: 16px; + } + } + + main { + padding-top: 10px; + line-height: 1.5; + + p { + margin-block: 1em; + } + } + + label { + display: flex; + align-items: center; + gap: 4px; + } + + .filter-form-keywords { + margin: 0 -16px 16px; + } + + .filter-form-cols { + display: flex; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; + + .filter-form-col { + flex-basis: 160px; + flex-grow: 1; + + > *:first-child { + margin-top: 0; + } + > *:last-child { + margin-bottom: 0; + } + } + } + + .filter-keywords { + --gap: 16px; + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--gap); + padding: var(--gap); + overflow-y: auto; + min-height: 80px; + max-height: 25vh; + background-color: var(--bg-faded-blur-color); + counter-reset: index; + scroll-behavior: smooth; + + li { + counter-increment: index; + display: flex; + gap: 4px; + align-items: center; + flex-wrap: wrap; + + &:not(:only-child):before { + content: counter(index); + font-size: 10px; + color: var(--text-insignificant-color); + align-self: flex-start; + } + + input[type='text'] { + flex-basis: 160px; + flex-grow: 100; + } + + .filter-keyword-actions { + display: flex; + gap: 8px; + flex-grow: 1; + align-items: center; + justify-content: space-between; + + label { + font-size: 0.8em; + line-height: 1; + } + } + } + } + + .filter-keywords-footer { + padding: 8px 16px 0; + display: flex; + justify-content: space-between; + } + + input[type='text'] { + display: block; + width: 100%; + } + + .filter-form-footer { + display: flex; + gap: 16px; + justify-content: space-between; + align-items: center; + + > span { + display: flex; + align-items: center; + } + + button[type='submit'] { + padding-inline: 24px; + } + } +} diff --git a/src/pages/filters.jsx b/src/pages/filters.jsx new file mode 100644 index 0000000..7d11152 --- /dev/null +++ b/src/pages/filters.jsx @@ -0,0 +1,580 @@ +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); + }} + /> + + )} +
+ ); +} + +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 [removedNewKeywordIndices, setRemovedNewKeywordIndices] = useState([]); + + 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', + ); + } + })(); + }} + > +
+ +
+
+ {editKeywords.length ? ( +
    + {editKeywords.map((k, index) => { + const { id, keyword, wholeWord } = k; + const removed = + removedKeywordIDs.includes(id) || + removedNewKeywordIndices.includes(index); + if (removed) return null; + return ( +
  • + + +
    + + +
    +
  • + ); + })} +
+ ) : ( +
+
No keywords. Add one.
+
+ )} +
+ {' '} + {editKeywords?.length > 1 && ( + + {editKeywords.length} keyword + {editKeywords.length === 1 ? '' : 's'} + + )} +
+
+
+
+
+ Filter from… +
+ {FILTER_CONTEXT.map((ctx) => ( +
+ {' '} +
+ ))} +

+ * Not implemented yet +

+
+
+ {editMode && ( + <> + Status:{' '} + + + + + )} +
+ + +
+

+ Filtered post will be… +
+ {' '} + +

+
+
+
+ + {' '} + + {editMode && ( + { + 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.'); + } + })(); + }} + > + + + )} +
+
+
+
+ ); +} + +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; diff --git a/src/pages/followed-hashtags.jsx b/src/pages/followed-hashtags.jsx index a36f941..2465f18 100644 --- a/src/pages/followed-hashtags.jsx +++ b/src/pages/followed-hashtags.jsx @@ -10,7 +10,7 @@ import useTitle from '../utils/useTitle'; function FollowedHashtags() { const { masto, instance } = api(); - useTitle(`Followed Hashtags`, `/ft`); + useTitle(`Followed Hashtags`, `/fh`); const [uiState, setUIState] = useState('default'); const [followedHashtags, setFollowedHashtags] = useState([]);