import './shortcuts-settings.css'; import { useAutoAnimate } from '@formkit/auto-animate/preact'; import { msg, Plural, t, Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; 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 { getLists, getListTitle } from '../utils/lists'; import pmem from '../utils/pmem'; import showToast from '../utils/show-toast'; import states from '../utils/states'; import store from '../utils/store'; import { getCurrentAccountID } from '../utils/store-utils'; 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: msg`Home / Following`, notifications: msg`Notifications`, list: msg`Lists`, public: msg`Public (Local / Federated)`, search: msg`Search`, 'account-statuses': msg`Account`, bookmarks: msg`Bookmarks`, favourites: msg`Likes`, hashtag: msg`Hashtag`, trending: msg`Trending`, mentions: msg`Mentions`, }; const TYPE_PARAMS = { list: [ { text: msg`List ID`, name: 'id', notRequired: true, }, ], public: [ { text: msg`Local only`, name: 'local', type: 'checkbox', }, { text: msg`Instance`, name: 'instance', type: 'text', placeholder: msg`Optional, e.g. mastodon.social`, notRequired: true, }, ], trending: [ { text: msg`Instance`, name: 'instance', type: 'text', placeholder: msg`Optional, e.g. mastodon.social`, notRequired: true, }, ], search: [ { text: msg`Search term`, name: 'query', type: 'text', placeholder: msg`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: msg`e.g. PixelArt (Max 5, space-separated)`, pattern: '[^#]+', }, { text: msg`Media only`, name: 'media', type: 'checkbox', }, { text: msg`Instance`, name: 'instance', type: 'text', placeholder: msg`Optional, e.g. mastodon.social`, notRequired: true, }, ], }; 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 ? t`Home` : t`Following`), path: '/', icon: 'home', }, mentions: { id: 'mentions', title: msg`Mentions`, path: '/mentions', icon: 'at', }, notifications: { id: 'notifications', title: msg`Notifications`, path: '/notifications', icon: 'notification', }, list: { id: ({ id }) => (id ? 'list' : 'lists'), title: ({ id }) => (id ? getListTitle(id) : t`Lists`), path: ({ id }) => (id ? `/l/${id}` : '/l'), icon: 'list', excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []), }, public: { id: 'public', title: ({ local }) => (local ? t`Local` : t`Federated`), subtitle: ({ instance }) => instance || api().instance, path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`, icon: ({ local }) => (local ? 'building' : 'earth'), }, trending: { id: 'trending', title: msg`Trending`, subtitle: ({ instance }) => instance || api().instance, path: ({ instance }) => `/${instance}/trending`, icon: 'chart', }, search: { id: 'search', title: ({ query }) => (query ? `“${query}”` : t`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: msg`Bookmarks`, path: '/b', icon: 'bookmark', }, favourites: { id: 'favourites', title: msg`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 { _ } = useLingui(); const snapStates = useSnapshot(states); const { shortcuts } = snapStates; const [showForm, setShowForm] = useState(false); const [showImportExport, setShowImportExport] = useState(false); const [shortcutsListParent] = useAutoAnimate(); return (
{!!onClose && ( )}

Shortcuts{' '} beta

Specify a list of shortcuts that'll appear as:

{[ { value: 'float-button', label: t`Floating button`, imgURL: floatingButtonUrl, }, { value: 'tab-menu-bar', label: t`Tab/Menu bar`, imgURL: tabMenuBarUrl, }, { value: 'multi-column', label: t`Multi-column`, imgURL: multiColumnUrl, }, ].map(({ value, label, imgURL }) => { const checked = snapStates.settings.shortcutsViewMode === value || (value === 'float-button' && !snapStates.settings.shortcutsViewMode); return ( ); })}
{shortcuts.length > 0 ? ( <>
    {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); } else { title = _(title); } if (typeof subtitle === 'function') { subtitle = subtitle(shortcut, i); } else { subtitle = _(subtitle); } if (typeof icon === 'function') { icon = icon(shortcut, i); } if (typeof excludeViewMode === 'function') { excludeViewMode = excludeViewMode(shortcut, i); } const excludedViewMode = excludeViewMode?.includes( snapStates.settings.shortcutsViewMode, ); return (
  1. {title} {subtitle && ( <> {' '} {subtitle} )} {excludedViewMode && ( Not available in current view mode )} {/* */}
  2. ); })}
{shortcuts.length === 1 && snapStates.settings.shortcutsViewMode !== 'float-button' && (
{' '} Add more than one shortcut/column to make this work.
)} ) : (

{snapStates.settings.shortcutsViewMode === 'multi-column' ? t`No columns yet. Tap on the Add column button.` : t`No shortcuts yet. Tap on the Add shortcut button.`}

Not sure what to add?
Try adding{' '} { e.preventDefault(); states.shortcuts = [ { type: 'following', }, { type: 'notifications', }, ]; }} > Home / Following and Notifications {' '} first.

)}

{shortcuts.length >= SHORTCUTS_LIMIT && (snapStates.settings.shortcutsViewMode === 'multi-column' ? t`Max ${SHORTCUTS_LIMIT} columns` : t`Max ${SHORTCUTS_LIMIT} shortcuts`)}

{showForm && ( { if (e.target === e.currentTarget) { setShowForm(false); } }} > { console.log('onSubmit', result); if (mode === 'edit') { states.shortcuts[showForm.shortcutIndex] = result; } else { states.shortcuts.push(result); } }} onClose={() => setShowForm(false)} /> )} {showImportExport && ( { if (e.target === e.currentTarget) { setShowImportExport(false); } }} > setShowImportExport(false)} /> )}
); } const FORM_NOTES = { list: msg`Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`, search: msg`For multi-column mode, search term is required, else the column will not be shown.`, hashtag: msg`Multiple hashtags are supported. Space-separated.`, }; function ShortcutForm({ onSubmit, disabled, shortcut, shortcutIndex, onClose, }) { const { _ } = useLingui(); console.log('shortcut', shortcut); const editMode = !!shortcut; const [currentType, setCurrentType] = useState(shortcut?.type || null); 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 getLists(); 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 (
{!!onClose && ( )}

{editMode ? t`Edit shortcut` : t`Add shortcut`}

{ // 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?.(); }} >

{TYPE_PARAMS[currentType]?.map?.( ({ text, name, type, placeholder, pattern, notRequired }) => { if (currentType === 'list') { return (

); } return (

); }, )} {!!FORM_NOTES[currentType] && (

{_(FORM_NOTES[currentType])}

)}
); } function ImportExport({ shortcuts, onClose }) { const { _ } = useLingui(); 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 (
{!!onClose && ( )}

Import/Export Shortcuts

{' '} Import

{ setImportShortcutStr(e.target.value); }} dir="auto" /> {states.settings.shortcutSettingsCloudImportExport && ( )}

{!!parsedImportShortcutStr && Array.isArray(parsedImportShortcutStr) && ( <>

{parsedImportShortcutStr.length} shortcut {parsedImportShortcutStr.length > 1 ? 's' : ''}{' '} ({importShortcutStr.length} characters)

    {parsedImportShortcutStr.map((shortcut) => (
  1. // Compare all properties Object.keys(s).every( (key) => s[key] === shortcut[key], ), ) ? 1 : 0, }} > * {_(TYPE_TEXT[shortcut.type])} {shortcut.type === 'list' && ' ⚠️'}{' '} {TYPE_PARAMS[shortcut.type]?.map?.( ({ text, name, type }) => shortcut[name] ? ( <> {' '} ) : null, )}
  2. ))}

* Exists in current shortcuts
⚠️{' '} List may not work if it's from a different account.

)} {importUIState === 'error' && (

⚠️ Invalid settings format

)}

{hasCurrentSettings && ( <> Only shortcuts that don’t exist in current shortcuts will be appended.

} 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(t`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 ? t`Shortcuts imported. Exceeded max ${SHORTCUTS_LIMIT}, so the rest are not imported.` : t`Shortcuts imported`, ); onClose?.(); }} > {' '} )} { states.shortcuts = parsedImportShortcutStr; showToast(t`Shortcuts imported`); onClose?.(); }} >

{' '} Export

{ if (!e.target.value) return; e.target.select(); // Copy url to clipboard try { navigator.clipboard.writeText(e.target.value); showToast(t`Shortcuts copied`); } catch (e) { console.error(e); showToast(t`Unable to copy shortcuts`); } }} dir="auto" />

{' '} {navigator?.share && navigator?.canShare?.({ text: shortcutsStr, }) && ( )}{' '} {states.settings.shortcutSettingsCloudImportExport && ( )}{' '} {shortcutsStr.length > 0 && ( )}

{!!shortcutsStr && (
Raw Shortcuts JSON
)}
{states.settings.shortcutSettingsCloudImportExport && ( )} ); } export default ShortcutsSettings;