this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 635 lines 22 kB view raw
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;