this repo has no description
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 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;