import './shortcuts-settings.css'; import { useAutoAnimate } from '@formkit/auto-animate/preact'; import { compressToEncodedURIComponent, decompressFromEncodedURIComponent, } from 'lz-string'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useSnapshot } from 'valtio'; import floatingButtonUrl from '../assets/floating-button.svg'; import multiColumnUrl from '../assets/multi-column.svg'; import tabMenuBarUrl from '../assets/tab-menu-bar.svg'; import { api } from '../utils/api'; import { fetchFollowedTags } from '../utils/followed-tags'; import pmem from '../utils/pmem'; import showToast from '../utils/show-toast'; import states from '../utils/states'; import store from '../utils/store'; import AsyncText from './AsyncText'; import Icon from './icon'; import MenuConfirm from './menu-confirm'; import Modal from './modal'; export const SHORTCUTS_LIMIT = 9; const TYPES = [ 'following', 'mentions', 'notifications', 'list', 'public', 'trending', 'search', 'hashtag', 'bookmarks', 'favourites', // NOTE: Hide for now // 'account-statuses', // Need @acct search first ]; const TYPE_TEXT = { following: 'Home / Following', notifications: 'Notifications', list: 'List', public: 'Public (Local / Federated)', search: 'Search', 'account-statuses': 'Account', bookmarks: 'Bookmarks', favourites: 'Likes', hashtag: 'Hashtag', trending: 'Trending', mentions: 'Mentions', }; const TYPE_PARAMS = { list: [ { text: 'List ID', name: 'id', }, ], public: [ { text: 'Local only', name: 'local', type: 'checkbox', }, { text: 'Instance', name: 'instance', type: 'text', placeholder: 'Optional, e.g. mastodon.social', notRequired: true, }, ], trending: [ { text: 'Instance', name: 'instance', type: 'text', placeholder: 'Optional, e.g. mastodon.social', notRequired: true, }, ], search: [ { text: 'Search term', name: 'query', type: 'text', placeholder: 'Optional, unless for multi-column mode', notRequired: true, }, ], 'account-statuses': [ { text: '@', name: 'id', type: 'text', placeholder: 'cheeaun@mastodon.social', }, ], hashtag: [ { text: '#', name: 'hashtag', type: 'text', placeholder: 'e.g. PixelArt (Max 5, space-separated)', pattern: '[^#]+', }, { text: 'Media only', name: 'media', type: 'checkbox', }, { text: 'Instance', name: 'instance', type: 'text', placeholder: 'Optional, e.g. mastodon.social', notRequired: true, }, ], }; const fetchListTitle = pmem(async ({ id }) => { const list = await api().masto.v1.lists.$select(id).fetch(); return list.title; }); const fetchAccountTitle = pmem(async ({ id }) => { const account = await api().masto.v1.accounts.$select(id).fetch(); return account.username || account.acct || account.displayName; }); export const SHORTCUTS_META = { following: { id: 'home', title: (_, index) => (index === 0 ? 'Home' : 'Following'), path: '/', icon: 'home', }, mentions: { id: 'mentions', title: 'Mentions', path: '/mentions', icon: 'at', }, notifications: { id: 'notifications', title: 'Notifications', path: '/notifications', icon: 'notification', }, list: { id: 'list', title: fetchListTitle, path: ({ id }) => `/l/${id}`, icon: 'list', }, public: { id: 'public', title: ({ local }) => (local ? 'Local' : 'Federated'), subtitle: ({ instance }) => instance || api().instance, path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`, icon: ({ local }) => (local ? 'building' : 'earth'), }, trending: { id: 'trending', title: 'Trending', subtitle: ({ instance }) => instance || api().instance, path: ({ instance }) => `/${instance}/trending`, icon: 'chart', }, search: { id: 'search', title: ({ query }) => (query ? `“${query}”` : 'Search'), path: ({ query }) => query ? `/search?q=${encodeURIComponent(query)}&type=statuses` : '/search', icon: 'search', excludeViewMode: ({ query }) => (!query ? ['multi-column'] : []), }, 'account-statuses': { id: 'account-statuses', title: fetchAccountTitle, path: ({ id }) => `/a/${id}`, icon: 'user', }, bookmarks: { id: 'bookmarks', title: 'Bookmarks', path: '/b', icon: 'bookmark', }, favourites: { id: 'favourites', title: 'Likes', path: '/f', icon: 'heart', }, hashtag: { id: 'hashtag', title: ({ hashtag }) => hashtag, subtitle: ({ instance }) => instance || api().instance, path: ({ hashtag, instance, media }) => `${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}${ media ? '?media=1' : '' }`, icon: 'hashtag', }, }; function ShortcutsSettings({ onClose }) { const snapStates = useSnapshot(states); const { shortcuts } = snapStates; const [showForm, setShowForm] = useState(false); const [showImportExport, setShowImportExport] = useState(false); const [shortcutsListParent] = useAutoAnimate(); return ( <div id="shortcuts-settings-container" class="sheet" tabindex="-1"> {!!onClose && ( <button type="button" class="sheet-close" onClick={onClose}> <Icon icon="x" /> </button> )} <header> <h2> <Icon icon="shortcut" /> Shortcuts{' '} <sup style={{ fontSize: 12, opacity: 0.5, textTransform: 'uppercase', }} > beta </sup> </h2> </header> <main> <p>Specify a list of shortcuts that'll appear as:</p> <div class="shortcuts-view-mode"> {[ { value: 'float-button', label: 'Floating button', imgURL: floatingButtonUrl, }, { value: 'tab-menu-bar', label: 'Tab/Menu bar', imgURL: tabMenuBarUrl, }, { value: 'multi-column', label: 'Multi-column', imgURL: multiColumnUrl, }, ].map(({ value, label, imgURL }) => { const checked = snapStates.settings.shortcutsViewMode === value || (value === 'float-button' && !snapStates.settings.shortcutsViewMode); return ( <label key={value} class={checked ? 'checked' : ''}> <input type="radio" name="shortcuts-view-mode" value={value} checked={checked} onChange={(e) => { states.settings.shortcutsViewMode = e.target.value; }} />{' '} <img src={imgURL} alt="" width="80" height="58" />{' '} <span>{label}</span> </label> ); })} </div> {shortcuts.length > 0 ? ( <> <ol class="shortcuts-list" ref={shortcutsListParent}> {shortcuts.filter(Boolean).map((shortcut, i) => { // const key = i + Object.values(shortcut); const key = Object.values(shortcut).join('-'); const { type } = shortcut; if (!SHORTCUTS_META[type]) return null; let { icon, title, subtitle, excludeViewMode } = SHORTCUTS_META[type]; if (typeof title === 'function') { title = title(shortcut, i); } if (typeof subtitle === 'function') { subtitle = subtitle(shortcut, i); } if (typeof icon === 'function') { icon = icon(shortcut, i); } if (typeof excludeViewMode === 'function') { excludeViewMode = excludeViewMode(shortcut, i); } const excludedViewMode = excludeViewMode?.includes( snapStates.settings.shortcutsViewMode, ); return ( <li key={key}> <Icon icon={icon} /> <span class="shortcut-text"> <AsyncText>{title}</AsyncText> {subtitle && ( <> {' '} <small class="ib insignificant">{subtitle}</small> </> )} {excludedViewMode && ( <span class="tag"> Not available in current view mode </span> )} </span> <span class="shortcut-actions"> <button type="button" class="plain small" disabled={i === 0} onClick={() => { const shortcutsArr = Array.from(states.shortcuts); if (i > 0) { const temp = states.shortcuts[i - 1]; shortcutsArr[i - 1] = shortcut; shortcutsArr[i] = temp; states.shortcuts = shortcutsArr; } }} > <Icon icon="arrow-up" alt="Move up" /> </button> <button type="button" class="plain small" disabled={i === shortcuts.length - 1} onClick={() => { const shortcutsArr = Array.from(states.shortcuts); if (i < states.shortcuts.length - 1) { const temp = states.shortcuts[i + 1]; shortcutsArr[i + 1] = shortcut; shortcutsArr[i] = temp; states.shortcuts = shortcutsArr; } }} > <Icon icon="arrow-down" alt="Move down" /> </button> <button type="button" class="plain small" onClick={() => { setShowForm({ shortcut, shortcutIndex: i, }); }} > <Icon icon="pencil" alt="Edit" /> </button> {/* <button type="button" class="plain small" onClick={() => { states.shortcuts.splice(i, 1); }} > <Icon icon="x" alt="Remove" /> </button> */} </span> </li> ); })} </ol> {shortcuts.length === 1 && snapStates.settings.shortcutsViewMode !== 'float-button' && ( <div class="ui-state insignificant"> <Icon icon="info" />{' '} <small> Add more than one shortcut/column to make this work. </small> </div> )} </> ) : ( <div class="ui-state insignificant"> <p>No shortcuts yet. Tap on the Add shortcut button.</p> <p> Not sure what to add? <br /> Try adding{' '} <a href="#" onClick={(e) => { e.preventDefault(); states.shortcuts = [ { type: 'following', }, { type: 'notifications', }, ]; }} > Home / Following and Notifications </a>{' '} first. </p> </div> )} <p class="insignificant"> {shortcuts.length >= SHORTCUTS_LIMIT && `Max ${SHORTCUTS_LIMIT} shortcuts`} </p> <p style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', }} > <button type="button" class="light" onClick={() => setShowImportExport(true)} > Import/export </button> <button type="button" disabled={shortcuts.length >= SHORTCUTS_LIMIT} onClick={() => setShowForm(true)} > <Icon icon="plus" />{' '} <span> {snapStates.settings.shortcutsViewMode === 'multi-column' ? 'Add column…' : 'Add shortcut…'} </span> </button> </p> </main> {showForm && ( <Modal class="light" onClick={(e) => { if (e.target === e.currentTarget) { setShowForm(false); } }} > <ShortcutForm shortcut={showForm.shortcut} shortcutIndex={showForm.shortcutIndex} onSubmit={({ result, mode }) => { console.log('onSubmit', result); if (mode === 'edit') { states.shortcuts[showForm.shortcutIndex] = result; } else { states.shortcuts.push(result); } }} onClose={() => setShowForm(false)} /> </Modal> )} {showImportExport && ( <Modal class="light" onClick={(e) => { if (e.target === e.currentTarget) { setShowImportExport(false); } }} > <ImportExport shortcuts={shortcuts} onClose={() => setShowImportExport(false)} /> </Modal> )} </div> ); } const FETCH_MAX_AGE = 1000 * 60; // 1 minute const fetchLists = pmem( () => { const { masto } = api(); return masto.v1.lists.list(); }, { maxAge: FETCH_MAX_AGE, }, ); const FORM_NOTES = { search: `For multi-column mode, search term is required, else the column will not be shown.`, hashtag: 'Multiple hashtags are supported. Space-separated.', }; function ShortcutForm({ onSubmit, disabled, shortcut, shortcutIndex, onClose, }) { console.log('shortcut', shortcut); const editMode = !!shortcut; const [currentType, setCurrentType] = useState(shortcut?.type || null); const { masto } = api(); const [uiState, setUIState] = useState('default'); const [lists, setLists] = useState([]); const [followedHashtags, setFollowedHashtags] = useState([]); useEffect(() => { (async () => { if (currentType !== 'list') return; try { setUIState('loading'); const lists = await fetchLists(); lists.sort((a, b) => a.title.localeCompare(b.title)); setLists(lists); setUIState('default'); } catch (e) { console.error(e); setUIState('error'); } })(); (async () => { if (currentType !== 'hashtag') return; try { const tags = await fetchFollowedTags(); setFollowedHashtags(tags); } catch (e) { console.error(e); } })(); }, [currentType]); const formRef = useRef(); useEffect(() => { if (editMode && currentType && TYPE_PARAMS[currentType]) { // Populate form const form = formRef.current; TYPE_PARAMS[currentType].forEach(({ name, type }) => { const input = form.querySelector(`[name="${name}"]`); if (input && shortcut[name]) { if (type === 'checkbox') { input.checked = shortcut[name] === 'on' ? true : false; } else { input.value = shortcut[name]; } } }); } }, [editMode, currentType]); return ( <div id="shortcut-settings-form" class="sheet"> {!!onClose && ( <button type="button" class="sheet-close" onClick={onClose}> <Icon icon="x" /> </button> )} <header> <h2>{editMode ? 'Edit' : 'Add'} shortcut</h2> </header> <main tabindex="-1"> <form ref={formRef} onSubmit={(e) => { // Construct a nice object from form e.preventDefault(); const data = new FormData(e.target); const result = {}; data.forEach((value, key) => { result[key] = value?.trim(); if (key === 'instance') { // Remove protocol and trailing slash result[key] = result[key] .replace(/^https?:\/\//, '') .replace(/\/+$/, ''); // Remove @acct@ or acct@ from instance URL result[key] = result[key].replace(/^@?[^@]+@/, ''); } }); console.log('result', result); if (!result.type) return; onSubmit({ result, mode: editMode ? 'edit' : 'add', }); // Reset e.target.reset(); setCurrentType(null); onClose?.(); }} > <p> <label> <span>Timeline</span> <select required disabled={disabled} onChange={(e) => { setCurrentType(e.target.value); }} defaultValue={editMode ? shortcut.type : undefined} name="type" > <option></option> {TYPES.map((type) => ( <option value={type}>{TYPE_TEXT[type]}</option> ))} </select> </label> </p> {TYPE_PARAMS[currentType]?.map?.( ({ text, name, type, placeholder, pattern, notRequired }) => { if (currentType === 'list') { return ( <p> <label> <span>List</span> <select name="id" required={!notRequired} disabled={disabled || uiState === 'loading'} defaultValue={editMode ? shortcut.id : undefined} > {lists.map((list) => ( <option value={list.id}>{list.title}</option> ))} </select> </label> </p> ); } return ( <p> <label> <span>{text}</span>{' '} <input type={type} switch={type === 'checkbox' || undefined} name={name} placeholder={placeholder} required={type === 'text' && !notRequired} disabled={disabled} list={ currentType === 'hashtag' ? 'followed-hashtags-datalist' : null } autocorrect="off" autocapitalize="off" spellcheck={false} pattern={pattern} /> {currentType === 'hashtag' && followedHashtags.length > 0 && ( <datalist id="followed-hashtags-datalist"> {followedHashtags.map((tag) => ( <option value={tag.name} /> ))} </datalist> )} </label> </p> ); }, )} {!!FORM_NOTES[currentType] && ( <p class="form-note insignificant"> <Icon icon="info" /> {FORM_NOTES[currentType]} </p> )} <footer> <button type="submit" class="block" disabled={disabled || uiState === 'loading'} > {editMode ? 'Save' : 'Add'} </button> {editMode && ( <button type="button" class="light danger" onClick={() => { states.shortcuts.splice(shortcutIndex, 1); onClose?.(); }} > Remove </button> )} </footer> </form> </main> </div> ); } function ImportExport({ shortcuts, onClose }) { const { masto } = api(); const shortcutsStr = useMemo(() => { if (!shortcuts) return ''; if (!shortcuts.filter(Boolean).length) return ''; return compressToEncodedURIComponent( JSON.stringify(shortcuts.filter(Boolean)), ); }, [shortcuts]); const [importShortcutStr, setImportShortcutStr] = useState(''); const [importUIState, setImportUIState] = useState('default'); const parsedImportShortcutStr = useMemo(() => { if (!importShortcutStr) { setImportUIState('default'); return null; } try { const parsed = JSON.parse( decompressFromEncodedURIComponent(importShortcutStr), ); // Very basic validation, I know if (!Array.isArray(parsed)) throw new Error('Not an array'); setImportUIState('default'); return parsed; } catch (err) { // Fallback to JSON string parsing // There's a chance that someone might want to import a JSON string instead of the compressed version try { const parsed = JSON.parse(importShortcutStr); if (!Array.isArray(parsed)) throw new Error('Not an array'); setImportUIState('default'); return parsed; } catch (err) { setImportUIState('error'); return null; } } }, [importShortcutStr]); const hasCurrentSettings = states.shortcuts.length > 0; const shortcutsImportFieldRef = useRef(); return ( <div id="import-export-container" class="sheet"> {!!onClose && ( <button type="button" class="sheet-close" onClick={onClose}> <Icon icon="x" /> </button> )} <header> <h2> Import/Export <small class="ib insignificant">Shortcuts</small> </h2> </header> <main tabindex="-1"> <section> <h3> <Icon icon="arrow-down-circle" size="l" class="insignificant" />{' '} <span>Import</span> </h3> <p class="field-button"> <input ref={shortcutsImportFieldRef} type="text" name="import" placeholder="Paste shortcuts here" class="block" onInput={(e) => { setImportShortcutStr(e.target.value); }} /> {states.settings.shortcutSettingsCloudImportExport && ( <button type="button" class="plain2 small" disabled={importUIState === 'cloud-downloading'} onClick={async () => { setImportUIState('cloud-downloading'); const currentAccount = store.session.get('currentAccount'); showToast( 'Downloading saved shortcuts from instance server…', ); try { const relationships = await masto.v1.accounts.relationships.fetch({ id: [currentAccount], }); const relationship = relationships[0]; if (relationship) { const { note = '' } = relationship; if ( /<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test( note, ) ) { const settings = note.match( /<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/, )[1]; const { v, dt, data } = JSON.parse(settings); shortcutsImportFieldRef.current.value = data; shortcutsImportFieldRef.current.dispatchEvent( new Event('input'), ); } } setImportUIState('default'); } catch (e) { console.error(e); setImportUIState('error'); showToast('Unable to download shortcuts'); } }} title="Download shortcuts from instance server" > <Icon icon="cloud" /> <Icon icon="arrow-down" /> </button> )} </p> {!!parsedImportShortcutStr && Array.isArray(parsedImportShortcutStr) && ( <> <p> <b>{parsedImportShortcutStr.length}</b> shortcut {parsedImportShortcutStr.length > 1 ? 's' : ''}{' '} <small class="insignificant"> ({importShortcutStr.length} characters) </small> </p> <ol class="import-settings-list"> {parsedImportShortcutStr.map((shortcut) => ( <li> <span style={{ opacity: shortcuts.some((s) => // Compare all properties Object.keys(s).every( (key) => s[key] === shortcut[key], ), ) ? 1 : 0, }} > * </span> <span> {TYPE_TEXT[shortcut.type]} {shortcut.type === 'list' && ' ⚠️'}{' '} {TYPE_PARAMS[shortcut.type]?.map?.( ({ text, name, type }) => shortcut[name] ? ( <> <span class="tag collapsed insignificant"> {text}:{' '} {type === 'checkbox' ? shortcut[name] === 'on' ? '✅' : '❌' : shortcut[name]} </span>{' '} </> ) : null, )} </span> </li> ))} </ol> <p> <small>* Exists in current shortcuts</small> <br /> <small> ⚠️ List may not work if it's from a different account. </small> </p> </> )} {importUIState === 'error' && ( <p class="error"> <small>⚠️ Invalid settings format</small> </p> )} <p> {hasCurrentSettings && ( <> <MenuConfirm confirmLabel="Append to current shortcuts?" menuFooter={ <div class="footer"> Only shortcuts that don’t exist in current shortcuts will be appended. </div> } onClick={() => { // states.shortcuts = [ // ...states.shortcuts, // ...parsedImportShortcutStr, // ]; // Append non-unique shortcuts only const nonUniqueShortcuts = parsedImportShortcutStr.filter( (shortcut) => !states.shortcuts.some((s) => // Compare all properties Object.keys(s).every( (key) => s[key] === shortcut[key], ), ), ); if (!nonUniqueShortcuts.length) { showToast('No new shortcuts to import'); return; } let newShortcuts = [ ...states.shortcuts, ...nonUniqueShortcuts, ]; const exceededLimit = newShortcuts.length > SHORTCUTS_LIMIT; if (exceededLimit) { // If exceeded, trim it newShortcuts = newShortcuts.slice(0, SHORTCUTS_LIMIT); } states.shortcuts = newShortcuts; showToast( exceededLimit ? `Shortcuts imported. Exceeded max ${SHORTCUTS_LIMIT}, so the rest are not imported.` : 'Shortcuts imported', ); onClose?.(); }} > <button type="button" class="plain2" disabled={!parsedImportShortcutStr} > Import & append… </button> </MenuConfirm>{' '} </> )} <MenuConfirm confirmLabel={ hasCurrentSettings ? 'Override current shortcuts?' : 'Import shortcuts?' } menuItemClassName={hasCurrentSettings ? 'danger' : undefined} onClick={() => { states.shortcuts = parsedImportShortcutStr; showToast('Shortcuts imported'); onClose?.(); }} > <button type="button" class="plain2" disabled={!parsedImportShortcutStr} > {hasCurrentSettings ? 'or override…' : 'Import…'} </button> </MenuConfirm> </p> </section> <section> <h3> <Icon icon="arrow-up-circle" size="l" class="insignificant" />{' '} <span>Export</span> </h3> <p> <input style={{ width: '100%' }} type="text" value={shortcutsStr} readOnly onClick={(e) => { if (!e.target.value) return; e.target.select(); // Copy url to clipboard try { navigator.clipboard.writeText(e.target.value); showToast('Shortcuts copied'); } catch (e) { console.error(e); showToast('Unable to copy shortcuts'); } }} /> </p> <p> <button type="button" class="plain2" disabled={!shortcutsStr} onClick={() => { try { navigator.clipboard.writeText(shortcutsStr); showToast('Shortcut settings copied'); } catch (e) { console.error(e); showToast('Unable to copy shortcut settings'); } }} > <Icon icon="clipboard" /> <span>Copy</span> </button>{' '} {navigator?.share && navigator?.canShare?.({ text: shortcutsStr, }) && ( <button type="button" class="plain2" disabled={!shortcutsStr} onClick={() => { try { navigator.share({ text: shortcutsStr, }); } catch (e) { console.error(e); alert("Sharing doesn't seem to work."); } }} > <Icon icon="share" /> <span>Share</span> </button> )}{' '} {states.settings.shortcutSettingsCloudImportExport && ( <button type="button" class="plain2" disabled={importUIState === 'cloud-uploading'} onClick={async () => { setImportUIState('cloud-uploading'); const currentAccount = store.session.get('currentAccount'); try { const relationships = await masto.v1.accounts.relationships.fetch({ id: [currentAccount], }); const relationship = relationships[0]; if (relationship) { const { note = '' } = relationship; // const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`; let newNote = ''; if ( /<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test( note, ) ) { const settingsJSON = JSON.stringify({ v: '1', // version dt: Date.now(), // datetime stamp data: shortcutsStr, // shortcuts settings string }); newNote = note.replace( /<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/, `<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`, ); } else { newNote = `${note}\n\n\n<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`; } showToast('Saving shortcuts to instance server…'); await masto.v1.accounts .$select(currentAccount) .note.create({ comment: newNote, }); setImportUIState('default'); showToast('Shortcuts saved'); } } catch (e) { console.error(e); setImportUIState('error'); showToast('Unable to save shortcuts'); } }} title="Sync to instance server" > <Icon icon="cloud" /> <Icon icon="arrow-up" /> </button> )}{' '} {shortcutsStr.length > 0 && ( <small class="insignificant ib"> {shortcutsStr.length} characters </small> )} </p> {!!shortcutsStr && ( <details> <summary class="insignificant"> <small>Raw Shortcuts JSON</small> </summary> <textarea style={{ width: '100%' }} rows={10} readOnly> {JSON.stringify(shortcuts.filter(Boolean), null, 2)} </textarea> </details> )} </section> {states.settings.shortcutSettingsCloudImportExport && ( <footer> <p> <Icon icon="cloud" /> Import/export settings from/to instance server (Very experimental) </p> </footer> )} </main> </div> ); } export default ShortcutsSettings;