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 (
{!!onClose && (
)}
Specify a list of shortcuts that'll appear as:
{[
{
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 (
{
states.settings.shortcutsViewMode = e.target.value;
}}
/>{' '}
{' '}
{label}
);
})}
{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);
}
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 (
{title}
{subtitle && (
<>
{' '}
{subtitle}
>
)}
{excludedViewMode && (
Not available in current view mode
)}
{
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;
}
}}
>
{
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;
}
}}
>
{
setShowForm({
shortcut,
shortcutIndex: i,
});
}}
>
{/* {
states.shortcuts.splice(i, 1);
}}
>
*/}
);
})}
{shortcuts.length === 1 &&
snapStates.settings.shortcutsViewMode !== 'float-button' && (
{' '}
Add more than one shortcut/column to make this work.
)}
>
) : (
)}
{shortcuts.length >= SHORTCUTS_LIMIT &&
(snapStates.settings.shortcutsViewMode === 'multi-column'
? `Max ${SHORTCUTS_LIMIT} columns`
: `Max ${SHORTCUTS_LIMIT} shortcuts`)}
setShowImportExport(true)}
>
Import/export
= SHORTCUTS_LIMIT}
onClick={() => setShowForm(true)}
>
{' '}
{snapStates.settings.shortcutsViewMode === 'multi-column'
? 'Add column…'
: 'Add shortcut…'}
{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 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 (
);
}
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 (
{!!onClose && (
)}
{' '}
Import
{
setImportShortcutStr(e.target.value);
}}
/>
{states.settings.shortcutSettingsCloudImportExport && (
{
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>/.test(
note,
)
) {
const settings = note.match(
/(.*)<\/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"
>
)}
{!!parsedImportShortcutStr &&
Array.isArray(parsedImportShortcutStr) && (
<>
{parsedImportShortcutStr.length} shortcut
{parsedImportShortcutStr.length > 1 ? 's' : ''}{' '}
({importShortcutStr.length} characters)
{parsedImportShortcutStr.map((shortcut) => (
// 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] ? (
<>
{text}:{' '}
{type === 'checkbox'
? shortcut[name] === 'on'
? '✅'
: '❌'
: shortcut[name]}
{' '}
>
) : null,
)}
))}
* Exists in current shortcuts
⚠️ List may not work if it's from a different account.
>
)}
{importUIState === 'error' && (
⚠️ Invalid settings format
)}
{hasCurrentSettings && (
<>
}
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?.();
}}
>
Import & append…
{' '}
>
)}
{
states.shortcuts = parsedImportShortcutStr;
showToast('Shortcuts imported');
onClose?.();
}}
>
{hasCurrentSettings ? 'or override…' : 'Import…'}
{' '}
Export
{
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');
}
}}
/>
{
try {
navigator.clipboard.writeText(shortcutsStr);
showToast('Shortcut settings copied');
} catch (e) {
console.error(e);
showToast('Unable to copy shortcut settings');
}
}}
>
Copy
{' '}
{navigator?.share &&
navigator?.canShare?.({
text: shortcutsStr,
}) && (
{
try {
navigator.share({
text: shortcutsStr,
});
} catch (e) {
console.error(e);
alert("Sharing doesn't seem to work.");
}
}}
>
Share
)}{' '}
{states.settings.shortcutSettingsCloudImportExport && (
{
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${shortcutsStr} `;
let newNote = '';
if (
/(.*)<\/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>/,
`${settingsJSON} `,
);
} else {
newNote = `${note}\n\n\n${settingsJSON} `;
}
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"
>
)}{' '}
{shortcutsStr.length > 0 && (
{shortcutsStr.length} characters
)}
{!!shortcutsStr && (
Raw Shortcuts JSON
)}
{states.settings.shortcutSettingsCloudImportExport && (
)}
);
}
export default ShortcutsSettings;