this repo has no description
0
fork

Configure Feed

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

at 23e2f59033cebc3e9a956065a53d8a95cd52df61 1182 lines 39 kB view raw
1import './shortcuts-settings.css'; 2 3import { useAutoAnimate } from '@formkit/auto-animate/preact'; 4import { msg, t } from '@lingui/core/macro'; 5import { Plural, Trans, useLingui } from '@lingui/react/macro'; 6import { 7 compressToEncodedURIComponent, 8 decompressFromEncodedURIComponent, 9} from 'lz-string'; 10import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; 11import { useSnapshot } from 'valtio'; 12 13import floatingButtonUrl from '../assets/floating-button.svg'; 14import multiColumnUrl from '../assets/multi-column.svg'; 15import tabMenuBarUrl from '../assets/tab-menu-bar.svg'; 16 17import { api } from '../utils/api'; 18import { fetchFollowedTags } from '../utils/followed-tags'; 19import { getLists, getListTitle } from '../utils/lists'; 20import pmem from '../utils/pmem'; 21import showToast from '../utils/show-toast'; 22import states from '../utils/states'; 23import store from '../utils/store'; 24import { getCurrentAccountID } from '../utils/store-utils'; 25 26import AsyncText from './AsyncText'; 27import Icon from './icon'; 28import MenuConfirm from './menu-confirm'; 29import Modal from './modal'; 30 31export const SHORTCUTS_LIMIT = 9; 32 33const TYPES = [ 34 'following', 35 'mentions', 36 'notifications', 37 'list', 38 'public', 39 'trending', 40 'search', 41 'hashtag', 42 'bookmarks', 43 'favourites', 44 // NOTE: Hide for now 45 // 'account-statuses', // Need @acct search first 46]; 47const TYPE_TEXT = { 48 following: msg`Home / Following`, 49 notifications: msg`Notifications`, 50 list: msg`Lists`, 51 public: msg`Public (Local / Federated)`, 52 search: msg`Search`, 53 'account-statuses': msg`Account`, 54 bookmarks: msg`Bookmarks`, 55 favourites: msg`Likes`, 56 hashtag: msg`Hashtag`, 57 trending: msg`Trending`, 58 mentions: msg`Mentions`, 59}; 60const TYPE_PARAMS = { 61 list: [ 62 { 63 text: msg`List ID`, 64 name: 'id', 65 notRequired: true, 66 }, 67 ], 68 public: [ 69 { 70 text: msg`Local only`, 71 name: 'local', 72 type: 'checkbox', 73 }, 74 { 75 text: msg`Instance`, 76 name: 'instance', 77 type: 'text', 78 placeholder: msg`Optional, e.g. mastodon.social`, 79 notRequired: true, 80 }, 81 ], 82 trending: [ 83 { 84 text: msg`Instance`, 85 name: 'instance', 86 type: 'text', 87 placeholder: msg`Optional, e.g. mastodon.social`, 88 notRequired: true, 89 }, 90 ], 91 search: [ 92 { 93 text: msg`Search term`, 94 name: 'query', 95 type: 'text', 96 placeholder: msg`Optional, unless for multi-column mode`, 97 notRequired: true, 98 }, 99 ], 100 'account-statuses': [ 101 { 102 text: '@', 103 name: 'id', 104 type: 'text', 105 placeholder: 'cheeaun@mastodon.social', 106 }, 107 ], 108 hashtag: [ 109 { 110 text: '#', 111 name: 'hashtag', 112 type: 'text', 113 placeholder: msg`e.g. PixelArt (Max 5, space-separated)`, 114 pattern: '[^#]+', 115 }, 116 { 117 text: msg`Media only`, 118 name: 'media', 119 type: 'checkbox', 120 }, 121 { 122 text: msg`Instance`, 123 name: 'instance', 124 type: 'text', 125 placeholder: msg`Optional, e.g. mastodon.social`, 126 notRequired: true, 127 }, 128 ], 129}; 130const fetchAccountTitle = pmem(async ({ id }) => { 131 const account = await api().masto.v1.accounts.$select(id).fetch(); 132 return account.username || account.acct || account.displayName; 133}); 134export const SHORTCUTS_META = { 135 following: { 136 id: 'home', 137 title: (_, index) => 138 index === 0 139 ? t`Home` 140 : t({ id: 'following.title', message: 'Following' }), 141 path: '/', 142 icon: 'home', 143 }, 144 mentions: { 145 id: 'mentions', 146 title: msg`Mentions`, 147 path: '/mentions', 148 icon: 'at', 149 }, 150 notifications: { 151 id: 'notifications', 152 title: msg`Notifications`, 153 path: '/notifications', 154 icon: 'notification', 155 }, 156 list: { 157 id: ({ id }) => (id ? 'list' : 'lists'), 158 title: ({ id }) => (id ? getListTitle(id) : t`Lists`), 159 path: ({ id }) => (id ? `/l/${id}` : '/l'), 160 icon: 'list', 161 excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []), 162 }, 163 public: { 164 id: 'public', 165 title: ({ local }) => (local ? t`Local` : t`Federated`), 166 subtitle: ({ instance }) => instance || api().instance, 167 path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`, 168 icon: ({ local }) => (local ? 'building' : 'earth'), 169 }, 170 trending: { 171 id: 'trending', 172 title: msg`Trending`, 173 subtitle: ({ instance }) => instance || api().instance, 174 path: ({ instance }) => `/${instance}/trending`, 175 icon: 'chart', 176 }, 177 search: { 178 id: 'search', 179 title: ({ query }) => (query ? `${query}` : t`Search`), 180 path: ({ query }) => 181 query 182 ? `/search?q=${encodeURIComponent(query)}&type=statuses` 183 : '/search', 184 icon: 'search', 185 excludeViewMode: ({ query }) => (!query ? ['multi-column'] : []), 186 }, 187 'account-statuses': { 188 id: 'account-statuses', 189 title: fetchAccountTitle, 190 path: ({ id }) => `/a/${id}`, 191 icon: 'user', 192 }, 193 bookmarks: { 194 id: 'bookmarks', 195 title: msg`Bookmarks`, 196 path: '/b', 197 icon: 'bookmark', 198 }, 199 favourites: { 200 id: 'favourites', 201 title: msg`Likes`, 202 path: '/f', 203 icon: 'heart', 204 }, 205 hashtag: { 206 id: 'hashtag', 207 title: ({ hashtag }) => hashtag, 208 subtitle: ({ instance }) => instance || api().instance, 209 path: ({ hashtag, instance, media }) => 210 `${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}${ 211 media ? '?media=1' : '' 212 }`, 213 icon: 'hashtag', 214 }, 215}; 216 217function ShortcutsSettings({ onClose }) { 218 const { _ } = useLingui(); 219 const snapStates = useSnapshot(states); 220 const { shortcuts } = snapStates; 221 const [showForm, setShowForm] = useState(false); 222 const [showImportExport, setShowImportExport] = useState(false); 223 224 const [shortcutsListParent] = useAutoAnimate(); 225 226 return ( 227 <div id="shortcuts-settings-container" class="sheet" tabindex="-1"> 228 {!!onClose && ( 229 <button type="button" class="sheet-close" onClick={onClose}> 230 <Icon icon="x" alt={t`Close`} /> 231 </button> 232 )} 233 <header> 234 <h2> 235 <Icon icon="shortcut" /> <Trans>Shortcuts</Trans>{' '} 236 <sup 237 style={{ 238 fontSize: 12, 239 opacity: 0.5, 240 textTransform: 'uppercase', 241 }} 242 > 243 <Trans>beta</Trans> 244 </sup> 245 </h2> 246 </header> 247 <main> 248 <p> 249 <Trans>Specify a list of shortcuts that'll appear&nbsp;as:</Trans> 250 </p> 251 <div class="shortcuts-view-mode"> 252 {[ 253 { 254 value: 'float-button', 255 label: t`Floating button`, 256 imgURL: floatingButtonUrl, 257 }, 258 { 259 value: 'tab-menu-bar', 260 label: t`Tab/Menu bar`, 261 imgURL: tabMenuBarUrl, 262 }, 263 { 264 value: 'multi-column', 265 label: t`Multi-column`, 266 imgURL: multiColumnUrl, 267 }, 268 ].map(({ value, label, imgURL }) => { 269 const checked = 270 snapStates.settings.shortcutsViewMode === value || 271 (value === 'float-button' && 272 !snapStates.settings.shortcutsViewMode); 273 return ( 274 <label key={value} class={checked ? 'checked' : ''}> 275 <input 276 type="radio" 277 name="shortcuts-view-mode" 278 value={value} 279 checked={checked} 280 onChange={(e) => { 281 states.settings.shortcutsViewMode = e.target.value; 282 }} 283 />{' '} 284 <img src={imgURL} alt="" width="80" height="58" />{' '} 285 <span>{label}</span> 286 </label> 287 ); 288 })} 289 </div> 290 {shortcuts.length > 0 ? ( 291 <> 292 <ol class="shortcuts-list" ref={shortcutsListParent}> 293 {shortcuts.filter(Boolean).map((shortcut, i) => { 294 // const key = i + Object.values(shortcut); 295 const key = Object.values(shortcut).join('-'); 296 const { type } = shortcut; 297 if (!SHORTCUTS_META[type]) return null; 298 let { icon, title, subtitle, excludeViewMode } = 299 SHORTCUTS_META[type]; 300 if (typeof title === 'function') { 301 title = title(shortcut, i); 302 } else { 303 title = _(title); 304 } 305 if (typeof subtitle === 'function') { 306 subtitle = subtitle(shortcut, i); 307 } else { 308 subtitle = _(subtitle); 309 } 310 if (typeof icon === 'function') { 311 icon = icon(shortcut, i); 312 } 313 if (typeof excludeViewMode === 'function') { 314 excludeViewMode = excludeViewMode(shortcut, i); 315 } 316 const excludedViewMode = excludeViewMode?.includes( 317 snapStates.settings.shortcutsViewMode, 318 ); 319 return ( 320 <li key={key}> 321 <Icon icon={icon} /> 322 <span class="shortcut-text"> 323 <AsyncText>{title}</AsyncText> 324 {subtitle && ( 325 <> 326 {' '} 327 <small class="ib insignificant">{subtitle}</small> 328 </> 329 )} 330 {excludedViewMode && ( 331 <span class="tag"> 332 <Trans>Not available in current view mode</Trans> 333 </span> 334 )} 335 </span> 336 <span class="shortcut-actions"> 337 <button 338 type="button" 339 class="plain small" 340 disabled={i === 0} 341 onClick={() => { 342 const shortcutsArr = Array.from(states.shortcuts); 343 if (i > 0) { 344 const temp = states.shortcuts[i - 1]; 345 shortcutsArr[i - 1] = shortcut; 346 shortcutsArr[i] = temp; 347 states.shortcuts = shortcutsArr; 348 } 349 }} 350 > 351 <Icon icon="arrow-up" alt={t`Move up`} /> 352 </button> 353 <button 354 type="button" 355 class="plain small" 356 disabled={i === shortcuts.length - 1} 357 onClick={() => { 358 const shortcutsArr = Array.from(states.shortcuts); 359 if (i < states.shortcuts.length - 1) { 360 const temp = states.shortcuts[i + 1]; 361 shortcutsArr[i + 1] = shortcut; 362 shortcutsArr[i] = temp; 363 states.shortcuts = shortcutsArr; 364 } 365 }} 366 > 367 <Icon icon="arrow-down" alt={t`Move down`} /> 368 </button> 369 <button 370 type="button" 371 class="plain small" 372 onClick={() => { 373 setShowForm({ 374 shortcut, 375 shortcutIndex: i, 376 }); 377 }} 378 > 379 <Icon icon="pencil" alt={t`Edit`} /> 380 </button> 381 {/* <button 382 type="button" 383 class="plain small" 384 onClick={() => { 385 states.shortcuts.splice(i, 1); 386 }} 387 > 388 <Icon icon="x" alt="Remove" /> 389 </button> */} 390 </span> 391 </li> 392 ); 393 })} 394 </ol> 395 {shortcuts.length === 1 && 396 snapStates.settings.shortcutsViewMode !== 'float-button' && ( 397 <div class="ui-state insignificant"> 398 <Icon icon="info" />{' '} 399 <small> 400 <Trans> 401 Add more than one shortcut/column to make this work. 402 </Trans> 403 </small> 404 </div> 405 )} 406 </> 407 ) : ( 408 <div class="ui-state insignificant"> 409 <p> 410 {snapStates.settings.shortcutsViewMode === 'multi-column' 411 ? t`No columns yet. Tap on the Add column button.` 412 : t`No shortcuts yet. Tap on the Add shortcut button.`} 413 </p> 414 <p> 415 <Trans> 416 Not sure what to add? 417 <br /> 418 Try adding{' '} 419 <a 420 href="#" 421 onClick={(e) => { 422 e.preventDefault(); 423 states.shortcuts = [ 424 { 425 type: 'following', 426 }, 427 { 428 type: 'notifications', 429 }, 430 ]; 431 }} 432 > 433 Home / Following and Notifications 434 </a>{' '} 435 first. 436 </Trans> 437 </p> 438 </div> 439 )} 440 <p class="insignificant"> 441 {shortcuts.length >= SHORTCUTS_LIMIT && 442 (snapStates.settings.shortcutsViewMode === 'multi-column' 443 ? t`Max ${SHORTCUTS_LIMIT} columns` 444 : t`Max ${SHORTCUTS_LIMIT} shortcuts`)} 445 </p> 446 <p 447 style={{ 448 display: 'flex', 449 justifyContent: 'space-between', 450 alignItems: 'center', 451 }} 452 > 453 <button 454 type="button" 455 class="light" 456 onClick={() => setShowImportExport(true)} 457 > 458 <Trans>Import/export</Trans> 459 </button> 460 <button 461 type="button" 462 disabled={shortcuts.length >= SHORTCUTS_LIMIT} 463 onClick={() => setShowForm(true)} 464 > 465 <Icon icon="plus" />{' '} 466 <span> 467 {snapStates.settings.shortcutsViewMode === 'multi-column' 468 ? t`Add column…` 469 : t`Add shortcut…`} 470 </span> 471 </button> 472 </p> 473 </main> 474 {showForm && ( 475 <Modal 476 onClick={(e) => { 477 if (e.target === e.currentTarget) { 478 setShowForm(false); 479 } 480 }} 481 > 482 <ShortcutForm 483 shortcut={showForm.shortcut} 484 shortcutIndex={showForm.shortcutIndex} 485 onSubmit={({ result, mode }) => { 486 console.log('onSubmit', result); 487 if (mode === 'edit') { 488 states.shortcuts[showForm.shortcutIndex] = result; 489 } else { 490 states.shortcuts.push(result); 491 } 492 }} 493 onClose={() => setShowForm(false)} 494 /> 495 </Modal> 496 )} 497 {showImportExport && ( 498 <Modal 499 onClick={(e) => { 500 if (e.target === e.currentTarget) { 501 setShowImportExport(false); 502 } 503 }} 504 > 505 <ImportExport 506 shortcuts={shortcuts} 507 onClose={() => setShowImportExport(false)} 508 /> 509 </Modal> 510 )} 511 </div> 512 ); 513} 514 515const FORM_NOTES = { 516 list: msg`Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`, 517 search: msg`For multi-column mode, search term is required, else the column will not be shown.`, 518 hashtag: msg`Multiple hashtags are supported. Space-separated.`, 519}; 520 521function ShortcutForm({ 522 onSubmit, 523 disabled, 524 shortcut, 525 shortcutIndex, 526 onClose, 527}) { 528 const { _ } = useLingui(); 529 console.log('shortcut', shortcut); 530 const editMode = !!shortcut; 531 const [currentType, setCurrentType] = useState(shortcut?.type || null); 532 533 const [uiState, setUIState] = useState('default'); 534 const [lists, setLists] = useState([]); 535 const [followedHashtags, setFollowedHashtags] = useState([]); 536 useEffect(() => { 537 (async () => { 538 if (currentType !== 'list') return; 539 try { 540 setUIState('loading'); 541 const lists = await getLists(); 542 setLists(lists); 543 setUIState('default'); 544 } catch (e) { 545 console.error(e); 546 setUIState('error'); 547 } 548 })(); 549 550 (async () => { 551 if (currentType !== 'hashtag') return; 552 try { 553 const tags = await fetchFollowedTags(); 554 setFollowedHashtags(tags); 555 } catch (e) { 556 console.error(e); 557 } 558 })(); 559 }, [currentType]); 560 561 const formRef = useRef(); 562 useEffect(() => { 563 if (editMode && currentType && TYPE_PARAMS[currentType]) { 564 // Populate form 565 const form = formRef.current; 566 TYPE_PARAMS[currentType].forEach(({ name, type }) => { 567 const input = form.querySelector(`[name="${name}"]`); 568 if (input && shortcut[name]) { 569 if (type === 'checkbox') { 570 input.checked = shortcut[name] === 'on' ? true : false; 571 } else { 572 input.value = shortcut[name]; 573 } 574 } 575 }); 576 } 577 }, [editMode, currentType]); 578 579 return ( 580 <div id="shortcut-settings-form" class="sheet"> 581 {!!onClose && ( 582 <button type="button" class="sheet-close" onClick={onClose}> 583 <Icon icon="x" alt={t`Close`} /> 584 </button> 585 )} 586 <header> 587 <h2>{editMode ? t`Edit shortcut` : t`Add shortcut`}</h2> 588 </header> 589 <main tabindex="-1"> 590 <form 591 ref={formRef} 592 onSubmit={(e) => { 593 // Construct a nice object from form 594 e.preventDefault(); 595 const data = new FormData(e.target); 596 const result = {}; 597 data.forEach((value, key) => { 598 result[key] = value?.trim(); 599 if (key === 'instance') { 600 // Remove protocol and trailing slash 601 result[key] = result[key] 602 .replace(/^https?:\/\//, '') 603 .replace(/\/+$/, ''); 604 // Remove @acct@ or acct@ from instance URL 605 result[key] = result[key].replace(/^@?[^@]+@/, ''); 606 } 607 }); 608 console.log('result', result); 609 if (!result.type) return; 610 onSubmit({ 611 result, 612 mode: editMode ? 'edit' : 'add', 613 }); 614 // Reset 615 e.target.reset(); 616 setCurrentType(null); 617 onClose?.(); 618 }} 619 > 620 <p> 621 <label> 622 <span> 623 <Trans>Timeline</Trans> 624 </span> 625 <select 626 required 627 disabled={disabled} 628 onChange={(e) => { 629 setCurrentType(e.target.value); 630 }} 631 defaultValue={editMode ? shortcut.type : undefined} 632 name="type" 633 dir="auto" 634 > 635 <option></option> 636 {TYPES.map((type) => ( 637 <option value={type}>{_(TYPE_TEXT[type])}</option> 638 ))} 639 </select> 640 </label> 641 </p> 642 {TYPE_PARAMS[currentType]?.map?.( 643 ({ text, name, type, placeholder, pattern, notRequired }) => { 644 if (currentType === 'list') { 645 return ( 646 <p> 647 <label> 648 <span> 649 <Trans>List</Trans> 650 </span> 651 <select 652 name="id" 653 required={!notRequired} 654 disabled={disabled || uiState === 'loading'} 655 defaultValue={editMode ? shortcut.id : undefined} 656 dir="auto" 657 > 658 <option value=""></option> 659 {lists.map((list) => ( 660 <option value={list.id}>{list.title}</option> 661 ))} 662 </select> 663 </label> 664 </p> 665 ); 666 } 667 668 return ( 669 <p> 670 <label> 671 <span>{_(text)}</span>{' '} 672 <input 673 type={type} 674 switch={type === 'checkbox' || undefined} 675 name={name} 676 placeholder={_(placeholder)} 677 required={type === 'text' && !notRequired} 678 disabled={disabled} 679 list={ 680 currentType === 'hashtag' 681 ? 'followed-hashtags-datalist' 682 : null 683 } 684 autocorrect="off" 685 autocapitalize="off" 686 spellCheck={false} 687 pattern={pattern} 688 dir="auto" 689 /> 690 {currentType === 'hashtag' && 691 followedHashtags.length > 0 && ( 692 <datalist id="followed-hashtags-datalist"> 693 {followedHashtags.map((tag) => ( 694 <option value={tag.name} /> 695 ))} 696 </datalist> 697 )} 698 </label> 699 </p> 700 ); 701 }, 702 )} 703 {!!FORM_NOTES[currentType] && ( 704 <p class="form-note insignificant"> 705 <Icon icon="info" /> 706 {_(FORM_NOTES[currentType])} 707 </p> 708 )} 709 <footer> 710 <button 711 type="submit" 712 class="block" 713 disabled={disabled || uiState === 'loading'} 714 > 715 {editMode ? t`Save` : t`Add`} 716 </button> 717 {editMode && ( 718 <button 719 type="button" 720 class="light danger" 721 onClick={() => { 722 states.shortcuts.splice(shortcutIndex, 1); 723 onClose?.(); 724 }} 725 > 726 <Trans>Remove</Trans> 727 </button> 728 )} 729 </footer> 730 </form> 731 </main> 732 </div> 733 ); 734} 735 736function ImportExport({ shortcuts, onClose }) { 737 const { _ } = useLingui(); 738 const { masto } = api(); 739 const shortcutsStr = useMemo(() => { 740 if (!shortcuts) return ''; 741 if (!shortcuts.filter(Boolean).length) return ''; 742 return compressToEncodedURIComponent( 743 JSON.stringify(shortcuts.filter(Boolean)), 744 ); 745 }, [shortcuts]); 746 const [importShortcutStr, setImportShortcutStr] = useState(''); 747 const [importUIState, setImportUIState] = useState('default'); 748 const parsedImportShortcutStr = useMemo(() => { 749 if (!importShortcutStr) { 750 setImportUIState('default'); 751 return null; 752 } 753 try { 754 const parsed = JSON.parse( 755 decompressFromEncodedURIComponent(importShortcutStr), 756 ); 757 // Very basic validation, I know 758 if (!Array.isArray(parsed)) throw new Error('Not an array'); 759 setImportUIState('default'); 760 return parsed; 761 } catch (err) { 762 // Fallback to JSON string parsing 763 // There's a chance that someone might want to import a JSON string instead of the compressed version 764 try { 765 const parsed = JSON.parse(importShortcutStr); 766 if (!Array.isArray(parsed)) throw new Error('Not an array'); 767 setImportUIState('default'); 768 return parsed; 769 } catch (err) { 770 setImportUIState('error'); 771 return null; 772 } 773 } 774 }, [importShortcutStr]); 775 const hasCurrentSettings = states.shortcuts.length > 0; 776 777 const shortcutsImportFieldRef = useRef(); 778 779 return ( 780 <div id="import-export-container" class="sheet"> 781 {!!onClose && ( 782 <button type="button" class="sheet-close" onClick={onClose}> 783 <Icon icon="x" alt={t`Close`} /> 784 </button> 785 )} 786 <header> 787 <h2> 788 <Trans> 789 Import/Export <small class="ib insignificant">Shortcuts</small> 790 </Trans> 791 </h2> 792 </header> 793 <main tabindex="-1"> 794 <section> 795 <h3> 796 <Icon icon="arrow-down-circle" size="l" class="insignificant" />{' '} 797 <span> 798 <Trans>Import</Trans> 799 </span> 800 </h3> 801 <p class="field-button"> 802 <input 803 ref={shortcutsImportFieldRef} 804 type="text" 805 name="import" 806 placeholder={t`Paste shortcuts here`} 807 class="block" 808 onInput={(e) => { 809 setImportShortcutStr(e.target.value); 810 }} 811 dir="auto" 812 /> 813 {states.settings.shortcutSettingsCloudImportExport && ( 814 <button 815 type="button" 816 class="plain2 small" 817 disabled={importUIState === 'cloud-downloading'} 818 onClick={async () => { 819 setImportUIState('cloud-downloading'); 820 const currentAccount = getCurrentAccountID(); 821 showToast( 822 t`Downloading saved shortcuts from instance server…`, 823 ); 824 try { 825 const relationships = 826 await masto.v1.accounts.relationships.fetch({ 827 id: [currentAccount], 828 }); 829 const relationship = relationships[0]; 830 if (relationship) { 831 const { note = '' } = relationship; 832 if ( 833 /<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test( 834 note, 835 ) 836 ) { 837 const settings = note.match( 838 /<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/, 839 )[1]; 840 const { v, dt, data } = JSON.parse(settings); 841 shortcutsImportFieldRef.current.value = data; 842 shortcutsImportFieldRef.current.dispatchEvent( 843 new Event('input'), 844 ); 845 } 846 } 847 setImportUIState('default'); 848 } catch (e) { 849 console.error(e); 850 setImportUIState('error'); 851 showToast(t`Unable to download shortcuts`); 852 } 853 }} 854 title={t`Download shortcuts from instance server`} 855 > 856 <Icon icon="cloud" /> 857 <Icon icon="arrow-down" /> 858 </button> 859 )} 860 </p> 861 {!!parsedImportShortcutStr && 862 Array.isArray(parsedImportShortcutStr) && ( 863 <> 864 <p> 865 <b>{parsedImportShortcutStr.length}</b> shortcut 866 {parsedImportShortcutStr.length > 1 ? 's' : ''}{' '} 867 <small class="insignificant"> 868 ({importShortcutStr.length} characters) 869 </small> 870 </p> 871 <ol class="import-settings-list"> 872 {parsedImportShortcutStr.map((shortcut) => ( 873 <li> 874 <span 875 style={{ 876 opacity: shortcuts.some((s) => 877 // Compare all properties 878 Object.keys(s).every( 879 (key) => s[key] === shortcut[key], 880 ), 881 ) 882 ? 1 883 : 0, 884 }} 885 > 886 * 887 </span> 888 <span> 889 {_(TYPE_TEXT[shortcut.type])} 890 {shortcut.type === 'list' && ' ⚠️'}{' '} 891 {TYPE_PARAMS[shortcut.type]?.map?.( 892 ({ text, name, type }) => 893 shortcut[name] ? ( 894 <> 895 <span class="tag collapsed insignificant"> 896 {text}:{' '} 897 {type === 'checkbox' 898 ? shortcut[name] === 'on' 899 ? '✅' 900 : '❌' 901 : shortcut[name]} 902 </span>{' '} 903 </> 904 ) : null, 905 )} 906 </span> 907 </li> 908 ))} 909 </ol> 910 <p> 911 <small> 912 <Trans>* Exists in current shortcuts</Trans> 913 </small> 914 <br /> 915 <small> 916 {' '} 917 <Trans> 918 List may not work if it's from a different account. 919 </Trans> 920 </small> 921 </p> 922 </> 923 )} 924 {importUIState === 'error' && ( 925 <p class="error"> 926 <small> 927 ⚠️ <Trans>Invalid settings format</Trans> 928 </small> 929 </p> 930 )} 931 <p> 932 {hasCurrentSettings && ( 933 <> 934 <MenuConfirm 935 confirmLabel={t`Append to current shortcuts?`} 936 menuFooter={ 937 <div class="footer"> 938 <Trans> 939 Only shortcuts that don’t exist in current shortcuts 940 will be appended. 941 </Trans> 942 </div> 943 } 944 onClick={() => { 945 // states.shortcuts = [ 946 // ...states.shortcuts, 947 // ...parsedImportShortcutStr, 948 // ]; 949 // Append non-unique shortcuts only 950 const nonUniqueShortcuts = parsedImportShortcutStr.filter( 951 (shortcut) => 952 !states.shortcuts.some((s) => 953 // Compare all properties 954 Object.keys(s).every( 955 (key) => s[key] === shortcut[key], 956 ), 957 ), 958 ); 959 if (!nonUniqueShortcuts.length) { 960 showToast(t`No new shortcuts to import`); 961 return; 962 } 963 let newShortcuts = [ 964 ...states.shortcuts, 965 ...nonUniqueShortcuts, 966 ]; 967 const exceededLimit = newShortcuts.length > SHORTCUTS_LIMIT; 968 if (exceededLimit) { 969 // If exceeded, trim it 970 newShortcuts = newShortcuts.slice(0, SHORTCUTS_LIMIT); 971 } 972 states.shortcuts = newShortcuts; 973 showToast( 974 exceededLimit 975 ? t`Shortcuts imported. Exceeded max ${SHORTCUTS_LIMIT}, so the rest are not imported.` 976 : t`Shortcuts imported`, 977 ); 978 onClose?.(); 979 }} 980 > 981 <button 982 type="button" 983 class="plain2" 984 disabled={!parsedImportShortcutStr} 985 > 986 <Trans>Import & append…</Trans> 987 </button> 988 </MenuConfirm>{' '} 989 </> 990 )} 991 <MenuConfirm 992 confirmLabel={ 993 hasCurrentSettings 994 ? t`Override current shortcuts?` 995 : t`Import shortcuts?` 996 } 997 menuItemClassName={hasCurrentSettings ? 'danger' : undefined} 998 onClick={() => { 999 states.shortcuts = parsedImportShortcutStr; 1000 showToast(t`Shortcuts imported`); 1001 onClose?.(); 1002 }} 1003 > 1004 <button 1005 type="button" 1006 class="plain2" 1007 disabled={!parsedImportShortcutStr} 1008 > 1009 {hasCurrentSettings ? t`or override…` : t`Import…`} 1010 </button> 1011 </MenuConfirm> 1012 </p> 1013 </section> 1014 <section> 1015 <h3> 1016 <Icon icon="arrow-up-circle" size="l" class="insignificant" />{' '} 1017 <span> 1018 <Trans>Export</Trans> 1019 </span> 1020 </h3> 1021 <p> 1022 <input 1023 style={{ width: '100%' }} 1024 type="text" 1025 value={shortcutsStr} 1026 readOnly 1027 onClick={(e) => { 1028 if (!e.target.value) return; 1029 e.target.select(); 1030 // Copy url to clipboard 1031 try { 1032 navigator.clipboard.writeText(e.target.value); 1033 showToast(t`Shortcuts copied`); 1034 } catch (e) { 1035 console.error(e); 1036 showToast(t`Unable to copy shortcuts`); 1037 } 1038 }} 1039 dir="auto" 1040 /> 1041 </p> 1042 <p> 1043 <button 1044 type="button" 1045 class="plain2" 1046 disabled={!shortcutsStr} 1047 onClick={() => { 1048 try { 1049 navigator.clipboard.writeText(shortcutsStr); 1050 showToast(t`Shortcut settings copied`); 1051 } catch (e) { 1052 console.error(e); 1053 showToast(t`Unable to copy shortcut settings`); 1054 } 1055 }} 1056 > 1057 <Icon icon="clipboard" />{' '} 1058 <span> 1059 <Trans>Copy</Trans> 1060 </span> 1061 </button>{' '} 1062 {navigator?.share && 1063 navigator?.canShare?.({ 1064 text: shortcutsStr, 1065 }) && ( 1066 <button 1067 type="button" 1068 class="plain2" 1069 disabled={!shortcutsStr} 1070 onClick={() => { 1071 try { 1072 navigator.share({ 1073 text: shortcutsStr, 1074 }); 1075 } catch (e) { 1076 console.error(e); 1077 alert(t`Sharing doesn't seem to work.`); 1078 } 1079 }} 1080 > 1081 <Icon icon="share" />{' '} 1082 <span> 1083 <Trans>Share</Trans> 1084 </span> 1085 </button> 1086 )}{' '} 1087 {states.settings.shortcutSettingsCloudImportExport && ( 1088 <button 1089 type="button" 1090 class="plain2" 1091 disabled={importUIState === 'cloud-uploading'} 1092 onClick={async () => { 1093 setImportUIState('cloud-uploading'); 1094 const currentAccount = getCurrentAccountID(); 1095 try { 1096 const relationships = 1097 await masto.v1.accounts.relationships.fetch({ 1098 id: [currentAccount], 1099 }); 1100 const relationship = relationships[0]; 1101 if (relationship) { 1102 const { note = '' } = relationship; 1103 // const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`; 1104 let newNote = ''; 1105 const settingsJSON = JSON.stringify({ 1106 v: '1', // version 1107 dt: Date.now(), // datetime stamp 1108 data: shortcutsStr, // shortcuts settings string 1109 }); 1110 if ( 1111 /<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test( 1112 note, 1113 ) 1114 ) { 1115 newNote = note.replace( 1116 /<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/, 1117 `<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`, 1118 ); 1119 } else { 1120 newNote = `${note}\n\n\n<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`; 1121 } 1122 showToast(t`Saving shortcuts to instance server…`); 1123 await masto.v1.accounts 1124 .$select(currentAccount) 1125 .note.create({ 1126 comment: newNote, 1127 }); 1128 setImportUIState('default'); 1129 showToast(t`Shortcuts saved`); 1130 } 1131 } catch (e) { 1132 console.error(e); 1133 setImportUIState('error'); 1134 showToast(t`Unable to save shortcuts`); 1135 } 1136 }} 1137 title={t`Sync to instance server`} 1138 > 1139 <Icon icon="cloud" /> 1140 <Icon icon="arrow-up" /> 1141 </button> 1142 )}{' '} 1143 {shortcutsStr.length > 0 && ( 1144 <small class="insignificant ib"> 1145 <Plural 1146 value={shortcutsStr.length} 1147 one="# character" 1148 other="# characters" 1149 /> 1150 </small> 1151 )} 1152 </p> 1153 {!!shortcutsStr && ( 1154 <details> 1155 <summary class="insignificant"> 1156 <small> 1157 <Trans>Raw Shortcuts JSON</Trans> 1158 </small> 1159 </summary> 1160 <textarea style={{ width: '100%' }} rows={10} readOnly> 1161 {JSON.stringify(shortcuts.filter(Boolean), null, 2)} 1162 </textarea> 1163 </details> 1164 )} 1165 </section> 1166 {states.settings.shortcutSettingsCloudImportExport && ( 1167 <footer> 1168 <p> 1169 <Icon icon="cloud" />{' '} 1170 <Trans> 1171 Import/export settings from/to instance server (Very 1172 experimental) 1173 </Trans> 1174 </p> 1175 </footer> 1176 )} 1177 </main> 1178 </div> 1179 ); 1180} 1181 1182export default ShortcutsSettings;