this repo has no description
1import './filters.css';
2
3import { msg } from '@lingui/core/macro';
4import { Plural, Trans, useLingui } from '@lingui/react/macro';
5import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
6
7import Icon from '../components/icon';
8import Link from '../components/link';
9import Loader from '../components/loader';
10import MenuConfirm from '../components/menu-confirm';
11import Modal from '../components/modal';
12import NavMenu from '../components/nav-menu';
13import RelativeTime from '../components/relative-time';
14import { api } from '../utils/api';
15import i18nDuration from '../utils/i18n-duration';
16import { getAPIVersions } from '../utils/store-utils';
17import useInterval from '../utils/useInterval';
18import useTitle from '../utils/useTitle';
19
20const FILTER_CONTEXT = ['home', 'public', 'notifications', 'thread', 'account'];
21const FILTER_CONTEXT_UNIMPLEMENTED = ['thread', 'account'];
22const FILTER_CONTEXT_LABELS = {
23 home: msg`Home and lists`,
24 notifications: msg`Notifications`,
25 public: msg`Public timelines`,
26 thread: msg`Conversations`,
27 account: msg`Profiles`,
28};
29
30const EXPIRY_DURATIONS = [
31 0, // forever
32 30 * 60, // 30 minutes
33 60 * 60, // 1 hour
34 6 * 60 * 60, // 6 hours
35 12 * 60 * 60, // 12 hours
36 60 * 60 * 24, // 24 hours
37 60 * 60 * 24 * 7, // 7 days
38 60 * 60 * 24 * 30, // 30 days
39];
40
41const EXPIRY_DURATIONS_LABELS = {
42 0: msg`Never`,
43 1800: i18nDuration(30, 'minute'),
44 3600: i18nDuration(1, 'hour'),
45 21600: i18nDuration(6, 'hour'),
46 43200: i18nDuration(12, 'hour'),
47 86_400: i18nDuration(24, 'hour'),
48 604_800: i18nDuration(7, 'day'),
49 2_592_000: i18nDuration(30, 'day'),
50};
51
52function Filters() {
53 const { t } = useLingui();
54 const { masto } = api();
55 useTitle(t`Filters`, `/ft`);
56 const [uiState, setUIState] = useState('default');
57 const [showFiltersAddEditModal, setShowFiltersAddEditModal] = useState(false);
58
59 const [reloadCount, reload] = useReducer((c) => c + 1, 0);
60 const [filters, setFilters] = useState([]);
61 useEffect(() => {
62 setUIState('loading');
63 (async () => {
64 try {
65 const filters = await masto.v2.filters.list();
66 filters.sort((a, b) => a.title.localeCompare(b.title));
67 filters.forEach((filter) => {
68 if (filter.keywords?.length) {
69 filter.keywords.sort((a, b) => a.id - b.id);
70 }
71 });
72 console.log(filters);
73 setFilters(filters);
74 setUIState('default');
75 } catch (e) {
76 console.error(e);
77 setUIState('error');
78 }
79 })();
80 }, [reloadCount]);
81
82 return (
83 <div id="filters-page" class="deck-container" tabIndex="-1">
84 <div class="timeline-deck deck">
85 <header>
86 <div class="header-grid">
87 <div class="header-side">
88 <NavMenu />
89 <Link to="/" class="button plain">
90 <Icon icon="home" size="l" alt={t`Home`} />
91 </Link>
92 </div>
93 <h1>
94 <Trans>Filters</Trans>
95 </h1>
96 <div class="header-side">
97 <button
98 type="button"
99 class="plain"
100 onClick={() => {
101 setShowFiltersAddEditModal(true);
102 }}
103 >
104 <Icon icon="plus" size="l" alt={t`New filter`} />
105 </button>
106 </div>
107 </div>
108 </header>
109 <main>
110 {filters.length > 0 ? (
111 <>
112 <ul class="filters-list">
113 {filters.map((filter) => {
114 const { id, title, expiresAt, keywords } = filter;
115 return (
116 <li key={id}>
117 <div>
118 <h2>{title}</h2>
119 {keywords?.length > 0 && (
120 <div>
121 {keywords.map((k) => (
122 <>
123 <span class="tag collapsed insignificant">
124 {k.wholeWord ? `“${k.keyword}”` : k.keyword}
125 </span>{' '}
126 </>
127 ))}
128 </div>
129 )}
130 <small class="insignificant">
131 <ExpiryStatus expiresAt={expiresAt} />
132 </small>
133 </div>
134 <button
135 type="button"
136 class="plain"
137 onClick={() => {
138 setShowFiltersAddEditModal({
139 filter,
140 });
141 }}
142 >
143 <Icon icon="pencil" size="l" alt="Edit filter" />
144 </button>
145 </li>
146 );
147 })}
148 </ul>
149 {filters.length > 1 && (
150 <footer class="ui-state">
151 <small class="insignificant">
152 <Plural
153 value={filters.length}
154 one="# filter"
155 other="# filters"
156 />
157 </small>
158 </footer>
159 )}
160 </>
161 ) : uiState === 'loading' ? (
162 <p class="ui-state">
163 <Loader />
164 </p>
165 ) : uiState === 'error' ? (
166 <p class="ui-state">
167 <Trans>Unable to load filters.</Trans>
168 </p>
169 ) : (
170 <p class="ui-state">
171 <Trans>No filters yet.</Trans>
172 </p>
173 )}
174 </main>
175 </div>
176 {!!showFiltersAddEditModal && (
177 <Modal
178 title={t`Add filter`}
179 onClose={() => {
180 setShowFiltersAddEditModal(false);
181 }}
182 >
183 <FiltersAddEdit
184 filter={showFiltersAddEditModal?.filter}
185 onClose={(result) => {
186 if (result.state === 'success') {
187 reload();
188 }
189 setShowFiltersAddEditModal(false);
190 }}
191 />
192 </Modal>
193 )}
194 </div>
195 );
196}
197
198let _id = 1;
199const incID = () => _id++;
200function FiltersAddEdit({ filter, onClose }) {
201 const { _, t } = useLingui();
202 const { masto } = api();
203 const [uiState, setUIState] = useState('default');
204 const editMode = !!filter;
205 const { context, expiresAt, id, keywords, title, filterAction } =
206 filter || {};
207 const hasExpiry = !!expiresAt;
208 const expiresAtDate = hasExpiry && new Date(expiresAt);
209 const [editKeywords, setEditKeywords] = useState(keywords || []);
210 const keywordsRef = useRef();
211
212 // Hacky way of handling removed keywords for both existing and new ones
213 const [removedKeywordIDs, setRemovedKeywordIDs] = useState([]);
214 const [removedKeyword_IDs, setRemovedKeyword_IDs] = useState([]);
215
216 const filteredEditKeywords = editKeywords.filter(
217 (k) =>
218 !removedKeywordIDs.includes(k.id) && !removedKeyword_IDs.includes(k._id),
219 );
220
221 return (
222 <div class="sheet" id="filters-add-edit-modal">
223 {!!onClose && (
224 <button type="button" class="sheet-close" onClick={onClose}>
225 <Icon icon="x" alt={t`Close`} />
226 </button>
227 )}
228 <header>
229 <h2>{editMode ? t`Edit filter` : t`New filter`}</h2>
230 </header>
231 <main>
232 <form
233 onSubmit={(e) => {
234 e.preventDefault();
235 const formData = new FormData(e.target);
236 const title = formData.get('title');
237 const keywordIDs = formData.getAll('keyword_attributes[][id]');
238 const keywordKeywords = formData.getAll(
239 'keyword_attributes[][keyword]',
240 );
241 // const keywordWholeWords = formData.getAll(
242 // 'keyword_attributes[][whole_word]',
243 // );
244 // Not using getAll because it skips the empty checkboxes
245 const keywordWholeWords = [
246 ...keywordsRef.current.querySelectorAll(
247 'input[name="keyword_attributes[][whole_word]"]',
248 ),
249 ].map((i) => i.checked);
250 const keywordsAttributes = keywordKeywords.map((k, i) => ({
251 id: keywordIDs[i] || undefined,
252 keyword: k,
253 wholeWord: keywordWholeWords[i],
254 }));
255 // if (editMode && keywords?.length) {
256 // // Find which one got deleted and add to keywordsAttributes
257 // keywords.forEach((k) => {
258 // if (!keywordsAttributes.find((ka) => ka.id === k.id)) {
259 // keywordsAttributes.push({
260 // ...k,
261 // _destroy: true,
262 // });
263 // }
264 // });
265 // }
266 if (editMode && removedKeywordIDs?.length) {
267 removedKeywordIDs.forEach((id) => {
268 keywordsAttributes.push({
269 id,
270 _destroy: true,
271 });
272 });
273 }
274 const context = formData.getAll('context');
275 let expiresIn = formData.get('expires_in');
276 const filterAction = formData.get('filter_action');
277 console.log({
278 title,
279 keywordIDs,
280 keywords: keywordKeywords,
281 wholeWords: keywordWholeWords,
282 keywordsAttributes,
283 context,
284 expiresIn,
285 filterAction,
286 });
287
288 // Required fields
289 if (!title || !context?.length) {
290 return;
291 }
292
293 setUIState('loading');
294
295 (async () => {
296 try {
297 let filterResult;
298
299 if (editMode) {
300 if (expiresIn === '' || expiresIn === null) {
301 // No value
302 // Preserve existing expiry if not specified
303 // Seconds from now to expiresAtDate
304 // Other clients don't do this
305 if (hasExpiry) {
306 expiresIn = Math.floor(
307 (expiresAtDate - Date.now()) / 1000,
308 );
309 } else {
310 expiresIn = null;
311 }
312 } else if (expiresIn === '0' || expiresIn === 0) {
313 // 0 = Never
314 expiresIn = null;
315 } else {
316 expiresIn = +expiresIn;
317 }
318 filterResult = await masto.v2.filters.$select(id).update({
319 title,
320 context,
321 expiresIn,
322 keywordsAttributes,
323 filterAction,
324 });
325 } else {
326 expiresIn = +expiresIn || null;
327 filterResult = await masto.v2.filters.create({
328 title,
329 context,
330 expiresIn,
331 keywordsAttributes,
332 filterAction,
333 });
334 }
335 console.log({ filterResult });
336 setUIState('default');
337 onClose?.({
338 state: 'success',
339 filter: filterResult,
340 });
341 } catch (error) {
342 console.error(error);
343 setUIState('error');
344 alert(
345 editMode
346 ? t`Unable to edit filter`
347 : t`Unable to create filter`,
348 );
349 }
350 })();
351 }}
352 >
353 <div class="filter-form-row">
354 <label>
355 <b>
356 <Trans>Title</Trans>
357 </b>
358 <input
359 type="text"
360 name="title"
361 defaultValue={title}
362 disabled={uiState === 'loading'}
363 dir="auto"
364 required
365 />
366 </label>
367 </div>
368 <div class="filter-form-keywords" ref={keywordsRef}>
369 {filteredEditKeywords.length ? (
370 <ul class="filter-keywords">
371 {filteredEditKeywords.map((k) => {
372 const { id, keyword, wholeWord, _id } = k;
373 return (
374 <li key={`${id}-${_id}`}>
375 <input
376 type="hidden"
377 name="keyword_attributes[][id]"
378 value={id}
379 />
380 <input
381 name="keyword_attributes[][keyword]"
382 type="text"
383 defaultValue={keyword}
384 disabled={uiState === 'loading'}
385 required
386 dir="auto"
387 />
388 <div class="filter-keyword-actions">
389 <label>
390 <input
391 name="keyword_attributes[][whole_word]"
392 type="checkbox"
393 value={id} // Hacky way to map checkbox boolean to the keyword id
394 defaultChecked={wholeWord}
395 disabled={uiState === 'loading'}
396 />{' '}
397 <Trans>Whole word</Trans>
398 </label>
399 <button
400 type="button"
401 class="light danger small"
402 disabled={uiState === 'loading'}
403 onClick={() => {
404 if (id) {
405 removedKeywordIDs.push(id);
406 setRemovedKeywordIDs([...removedKeywordIDs]);
407 } else if (_id) {
408 removedKeyword_IDs.push(_id);
409 setRemovedKeyword_IDs([...removedKeyword_IDs]);
410 }
411 }}
412 >
413 <Icon icon="x" alt={t`Remove`} />
414 </button>
415 </div>
416 </li>
417 );
418 })}
419 </ul>
420 ) : (
421 <div class="filter-keywords">
422 <div class="insignificant">
423 <Trans>No keywords. Add one.</Trans>
424 </div>
425 </div>
426 )}
427 <footer class="filter-keywords-footer">
428 <button
429 type="button"
430 class="light"
431 onClick={() => {
432 setEditKeywords([
433 ...editKeywords,
434 {
435 _id: incID(),
436 keyword: '',
437 wholeWord: true,
438 },
439 ]);
440 setTimeout(() => {
441 // Focus last input
442 const fields =
443 keywordsRef.current.querySelectorAll(
444 'input[type="text"]',
445 );
446 fields[fields.length - 1]?.focus?.();
447 }, 10);
448 }}
449 >
450 <Trans>Add keyword</Trans>
451 </button>{' '}
452 {filteredEditKeywords?.length > 1 && (
453 <small class="insignificant">
454 <Plural
455 value={filteredEditKeywords.length}
456 one="# keyword"
457 other="# keywords"
458 />
459 </small>
460 )}
461 </footer>
462 </div>
463 <div class="filter-form-cols">
464 <div class="filter-form-col">
465 <div>
466 <b>
467 <Trans>Filter from…</Trans>
468 </b>
469 </div>
470 {FILTER_CONTEXT.map((ctx) => (
471 <div>
472 <label
473 class={
474 FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx)
475 ? 'insignificant'
476 : ''
477 }
478 >
479 <input
480 type="checkbox"
481 name="context"
482 value={ctx}
483 defaultChecked={!!context ? context.includes(ctx) : true}
484 disabled={uiState === 'loading'}
485 />{' '}
486 {_(FILTER_CONTEXT_LABELS[ctx])}
487 {FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? '*' : ''}
488 </label>{' '}
489 </div>
490 ))}
491 <p>
492 <small class="insignificant">
493 <Trans>* Not implemented yet</Trans>
494 </small>
495 </p>
496 </div>
497 <div class="filter-form-col">
498 {editMode && (
499 <Trans>
500 Status:{' '}
501 <b>
502 <ExpiryStatus expiresAt={expiresAt} showNeverExpires />
503 </b>
504 </Trans>
505 )}
506 <div>
507 <label for="filters-expires_in">
508 {editMode ? t`Change expiry` : t`Expiry`}
509 </label>
510 <select
511 id="filters-expires_in"
512 name="expires_in"
513 disabled={uiState === 'loading'}
514 defaultValue={editMode ? undefined : 0}
515 >
516 {editMode && <option></option>}
517 {EXPIRY_DURATIONS.map((v) => (
518 <option value={v}>
519 {typeof EXPIRY_DURATIONS_LABELS[v] === 'function'
520 ? EXPIRY_DURATIONS_LABELS[v]()
521 : _(EXPIRY_DURATIONS_LABELS[v])}
522 </option>
523 ))}
524 </select>
525 </div>
526 <p>
527 <Trans>Filtered post will be…</Trans>
528 <br />
529 {getAPIVersions()?.mastodon >= 5 && (
530 <label class="ib">
531 <input
532 type="radio"
533 name="filter_action"
534 value="blur"
535 defaultChecked={filterAction === 'blur'}
536 disabled={uiState === 'loading'}
537 />{' '}
538 <Trans>obscured (media only)</Trans>
539 </label>
540 )}{' '}
541 <label class="ib">
542 <input
543 type="radio"
544 name="filter_action"
545 value="warn"
546 defaultChecked={
547 (filterAction !== 'hide' && filterAction !== 'blur') ||
548 !editMode
549 }
550 disabled={uiState === 'loading'}
551 />{' '}
552 <Trans>minimized</Trans>
553 </label>{' '}
554 <label class="ib">
555 <input
556 type="radio"
557 name="filter_action"
558 value="hide"
559 defaultChecked={filterAction === 'hide'}
560 disabled={uiState === 'loading'}
561 />{' '}
562 <Trans>hidden</Trans>
563 </label>
564 </p>
565 </div>
566 </div>
567 <footer class="filter-form-footer">
568 <span>
569 <button type="submit" disabled={uiState === 'loading'}>
570 {editMode ? t`Save` : t`Create`}
571 </button>{' '}
572 <Loader abrupt hidden={uiState !== 'loading'} />
573 </span>
574 {editMode && (
575 <MenuConfirm
576 disabled={uiState === 'loading'}
577 align="end"
578 menuItemClassName="danger"
579 confirmLabel={t`Delete this filter?`}
580 onClick={() => {
581 setUIState('loading');
582 (async () => {
583 try {
584 await masto.v2.filters.$select(id).remove();
585 setUIState('default');
586 onClose?.({
587 state: 'success',
588 });
589 } catch (e) {
590 console.error(e);
591 setUIState('error');
592 alert(t`Unable to delete filter.`);
593 }
594 })();
595 }}
596 >
597 <button
598 type="button"
599 class="light danger"
600 onClick={() => {}}
601 disabled={uiState === 'loading'}
602 >
603 <Trans>Delete…</Trans>
604 </button>
605 </MenuConfirm>
606 )}
607 </footer>
608 </form>
609 </main>
610 </div>
611 );
612}
613
614function ExpiryStatus({ expiresAt, showNeverExpires }) {
615 const { t } = useLingui();
616 const hasExpiry = !!expiresAt;
617 const expiresAtDate = hasExpiry && new Date(expiresAt);
618 const expired = hasExpiry && Date.parse(expiresAt) <= Date.now();
619
620 // If less than a minute left, re-render interval every second, else every minute
621 const [_, rerender] = useReducer((c) => c + 1, 0);
622 useInterval(rerender, expired || 30_000);
623
624 return expired ? (
625 t`Expired`
626 ) : hasExpiry ? (
627 <Trans>
628 Expiring <RelativeTime datetime={expiresAtDate} />
629 </Trans>
630 ) : (
631 showNeverExpires && t`Never expires`
632 );
633}
634
635export default Filters;