phanpy/src/components/shortcuts-settings.jsx

1180 lines
38 KiB
React
Raw Normal View History

2023-02-16 17:51:54 +08:00
import './shortcuts-settings.css';
2023-10-23 08:42:40 +08:00
import { useAutoAnimate } from '@formkit/auto-animate/preact';
2024-08-13 15:26:23 +08:00
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';
2023-02-16 17:51:54 +08:00
import { useSnapshot } from 'valtio';
2023-03-09 23:37:25 +08:00
import floatingButtonUrl from '../assets/floating-button.svg';
import multiColumnUrl from '../assets/multi-column.svg';
import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
2023-10-03 15:07:47 +08:00
2023-02-16 17:51:54 +08:00
import { api } from '../utils/api';
2023-12-15 01:58:29 +08:00
import { fetchFollowedTags } from '../utils/followed-tags';
import { getLists, getListTitle } from '../utils/lists';
2023-10-14 20:33:40 +08:00
import pmem from '../utils/pmem';
import showToast from '../utils/show-toast';
2023-02-16 17:51:54 +08:00
import states from '../utils/states';
import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
2023-02-16 17:51:54 +08:00
import AsyncText from './AsyncText';
import Icon from './icon';
import MenuConfirm from './menu-confirm';
import Modal from './modal';
2023-02-16 17:51:54 +08:00
2023-11-01 10:00:05 +08:00
export const SHORTCUTS_LIMIT = 9;
2023-02-16 17:51:54 +08:00
const TYPES = [
'following',
2023-04-06 19:32:26 +08:00
'mentions',
2023-02-16 17:51:54 +08:00
'notifications',
'list',
'public',
2023-04-06 19:32:26 +08:00
'trending',
2023-12-22 18:01:41 +08:00
'search',
2023-04-06 19:32:26 +08:00
'hashtag',
2023-02-16 17:51:54 +08:00
'bookmarks',
'favourites',
2023-12-22 18:01:41 +08:00
// NOTE: Hide for now
// 'account-statuses', // Need @acct search first
2023-02-16 17:51:54 +08:00
];
const TYPE_TEXT = {
2024-08-13 15:26:23 +08:00
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`,
2023-02-16 17:51:54 +08:00
};
const TYPE_PARAMS = {
list: [
{
2024-08-13 15:26:23 +08:00
text: msg`List ID`,
2023-02-16 17:51:54 +08:00
name: 'id',
notRequired: true,
2023-02-16 17:51:54 +08:00
},
],
public: [
{
2024-08-13 15:26:23 +08:00
text: msg`Local only`,
2023-02-16 17:51:54 +08:00
name: 'local',
type: 'checkbox',
},
{
2024-08-13 15:26:23 +08:00
text: msg`Instance`,
2023-02-16 17:51:54 +08:00
name: 'instance',
type: 'text',
2024-08-13 15:26:23 +08:00
placeholder: msg`Optional, e.g. mastodon.social`,
2023-04-14 11:13:14 +08:00
notRequired: true,
2023-02-16 17:51:54 +08:00
},
],
trending: [
{
2024-08-13 15:26:23 +08:00
text: msg`Instance`,
name: 'instance',
type: 'text',
2024-08-13 15:26:23 +08:00
placeholder: msg`Optional, e.g. mastodon.social`,
2023-04-14 11:13:14 +08:00
notRequired: true,
},
],
2023-02-16 17:51:54 +08:00
search: [
{
2024-08-13 15:26:23 +08:00
text: msg`Search term`,
2023-02-16 17:51:54 +08:00
name: 'query',
type: 'text',
2024-08-13 15:26:23 +08:00
placeholder: msg`Optional, unless for multi-column mode`,
2023-12-22 18:01:41 +08:00
notRequired: true,
2023-02-16 17:51:54 +08:00
},
],
'account-statuses': [
{
text: '@',
name: 'id',
type: 'text',
placeholder: 'cheeaun@mastodon.social',
},
],
hashtag: [
{
text: '#',
name: 'hashtag',
type: 'text',
2024-08-13 15:26:23 +08:00
placeholder: msg`e.g. PixelArt (Max 5, space-separated)`,
2023-02-25 10:04:30 +08:00
pattern: '[^#]+',
2023-02-16 17:51:54 +08:00
},
2023-10-29 21:41:03 +08:00
{
2024-08-13 15:26:23 +08:00
text: msg`Media only`,
2023-10-29 21:41:03 +08:00
name: 'media',
type: 'checkbox',
},
{
2024-08-13 15:26:23 +08:00
text: msg`Instance`,
name: 'instance',
type: 'text',
2024-08-13 15:26:23 +08:00
placeholder: msg`Optional, e.g. mastodon.social`,
notRequired: true,
},
2023-02-16 17:51:54 +08:00
],
};
const fetchAccountTitle = pmem(async ({ id }) => {
const account = await api().masto.v1.accounts.$select(id).fetch();
return account.username || account.acct || account.displayName;
});
2023-02-16 17:51:54 +08:00
export const SHORTCUTS_META = {
following: {
2023-02-28 00:35:07 +08:00
id: 'home',
2024-08-13 15:26:23 +08:00
title: (_, index) => (index === 0 ? t`Home` : t`Following`),
2023-02-27 23:59:41 +08:00
path: '/',
2023-02-16 17:51:54 +08:00
icon: 'home',
},
2023-04-06 19:32:26 +08:00
mentions: {
id: 'mentions',
2024-08-13 15:26:23 +08:00
title: msg`Mentions`,
2023-04-06 19:32:26 +08:00
path: '/mentions',
icon: 'at',
},
2023-02-16 17:51:54 +08:00
notifications: {
2023-02-27 23:59:41 +08:00
id: 'notifications',
2024-08-13 15:26:23 +08:00
title: msg`Notifications`,
2023-02-16 17:51:54 +08:00
path: '/notifications',
icon: 'notification',
},
list: {
id: ({ id }) => (id ? 'list' : 'lists'),
2024-08-13 15:26:23 +08:00
title: ({ id }) => (id ? getListTitle(id) : t`Lists`),
path: ({ id }) => (id ? `/l/${id}` : '/l'),
2023-02-16 17:51:54 +08:00
icon: 'list',
excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []),
2023-02-16 17:51:54 +08:00
},
public: {
2023-02-27 23:59:41 +08:00
id: 'public',
2024-08-13 15:26:23 +08:00
title: ({ local }) => (local ? t`Local` : t`Federated`),
2023-04-17 17:38:53 +08:00
subtitle: ({ instance }) => instance || api().instance,
2023-02-16 17:51:54 +08:00
path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`,
2023-12-28 11:57:48 +08:00
icon: ({ local }) => (local ? 'building' : 'earth'),
2023-02-16 17:51:54 +08:00
},
trending: {
id: 'trending',
2024-08-13 15:26:23 +08:00
title: msg`Trending`,
2023-04-17 17:38:53 +08:00
subtitle: ({ instance }) => instance || api().instance,
path: ({ instance }) => `/${instance}/trending`,
icon: 'chart',
},
2023-02-16 17:51:54 +08:00
search: {
2023-02-27 23:59:41 +08:00
id: 'search',
2024-08-13 15:26:23 +08:00
title: ({ query }) => (query ? `${query}` : t`Search`),
2023-12-22 18:01:41 +08:00
path: ({ query }) =>
2024-01-04 22:00:27 +08:00
query
? `/search?q=${encodeURIComponent(query)}&type=statuses`
: '/search',
2023-02-16 17:51:54 +08:00
icon: 'search',
2023-12-22 18:01:41 +08:00
excludeViewMode: ({ query }) => (!query ? ['multi-column'] : []),
2023-02-16 17:51:54 +08:00
},
'account-statuses': {
2023-02-27 23:59:41 +08:00
id: 'account-statuses',
title: fetchAccountTitle,
2023-02-16 17:51:54 +08:00
path: ({ id }) => `/a/${id}`,
icon: 'user',
},
bookmarks: {
2023-02-27 23:59:41 +08:00
id: 'bookmarks',
2024-08-13 15:26:23 +08:00
title: msg`Bookmarks`,
2023-02-16 17:51:54 +08:00
path: '/b',
icon: 'bookmark',
},
favourites: {
2023-02-27 23:59:41 +08:00
id: 'favourites',
2024-08-13 15:26:23 +08:00
title: msg`Likes`,
2023-02-16 17:51:54 +08:00
path: '/f',
icon: 'heart',
},
hashtag: {
2023-02-27 23:59:41 +08:00
id: 'hashtag',
2023-02-16 17:51:54 +08:00
title: ({ hashtag }) => hashtag,
2023-04-17 17:38:53 +08:00
subtitle: ({ instance }) => instance || api().instance,
2023-10-29 21:41:03 +08:00
path: ({ hashtag, instance, media }) =>
`${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}${
media ? '?media=1' : ''
}`,
2023-02-16 17:51:54 +08:00
icon: 'hashtag',
},
};
2023-04-20 16:10:57 +08:00
function ShortcutsSettings({ onClose }) {
2024-08-13 15:26:23 +08:00
const { _ } = useLingui();
2023-02-16 17:51:54 +08:00
const snapStates = useSnapshot(states);
const { shortcuts } = snapStates;
const [showForm, setShowForm] = useState(false);
const [showImportExport, setShowImportExport] = useState(false);
2023-02-16 17:51:54 +08:00
2023-10-23 08:42:40 +08:00
const [shortcutsListParent] = useAutoAnimate();
2023-02-16 17:51:54 +08:00
return (
<div id="shortcuts-settings-container" class="sheet" tabindex="-1">
2023-04-20 16:10:57 +08:00
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
2024-08-13 15:26:23 +08:00
<Icon icon="x" alt={t`Close`} />
2023-04-20 16:10:57 +08:00
</button>
)}
2023-02-16 17:51:54 +08:00
<header>
<h2>
2024-08-13 15:26:23 +08:00
<Icon icon="shortcut" /> <Trans>Shortcuts</Trans>{' '}
2023-02-16 17:51:54 +08:00
<sup
style={{
fontSize: 12,
opacity: 0.5,
textTransform: 'uppercase',
}}
>
2024-08-13 15:26:23 +08:00
<Trans>beta</Trans>
2023-02-16 17:51:54 +08:00
</sup>
</h2>
</header>
<main>
2024-08-13 15:26:23 +08:00
<p>
<Trans>Specify a list of shortcuts that'll appear&nbsp;as:</Trans>
</p>
2023-10-19 20:50:32 +08:00
<div class="shortcuts-view-mode">
{[
{
value: 'float-button',
2024-08-13 15:26:23 +08:00
label: t`Floating button`,
2023-10-19 20:50:32 +08:00
imgURL: floatingButtonUrl,
},
{
value: 'tab-menu-bar',
2024-08-13 15:26:23 +08:00
label: t`Tab/Menu bar`,
2023-10-19 20:50:32 +08:00
imgURL: tabMenuBarUrl,
},
{
value: 'multi-column',
2024-08-13 15:26:23 +08:00
label: t`Multi-column`,
2023-10-19 20:50:32 +08:00
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>
);
})}
2023-10-19 20:50:32 +08:00
</div>
2023-02-18 20:48:24 +08:00
{shortcuts.length > 0 ? (
2024-01-10 14:48:29 +08:00
<>
<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);
2024-08-13 15:26:23 +08:00
} else {
title = _(title);
2024-01-10 14:48:29 +08:00
}
if (typeof subtitle === 'function') {
subtitle = subtitle(shortcut, i);
2024-08-13 15:26:23 +08:00
} else {
subtitle = _(subtitle);
2024-01-10 14:48:29 +08:00
}
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">
2024-08-13 15:26:23 +08:00
<Trans>Not available in current view mode</Trans>
2024-01-10 14:48:29 +08:00
</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;
}
}}
>
2024-08-13 15:26:23 +08:00
<Icon icon="arrow-up" alt={t`Move up`} />
2024-01-10 14:48:29 +08:00
</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;
}
}}
>
2024-08-13 15:26:23 +08:00
<Icon icon="arrow-down" alt={t`Move down`} />
2024-01-10 14:48:29 +08:00
</button>
<button
type="button"
class="plain small"
onClick={() => {
setShowForm({
shortcut,
shortcutIndex: i,
});
}}
>
2024-08-13 15:26:23 +08:00
<Icon icon="pencil" alt={t`Edit`} />
2024-01-10 14:48:29 +08:00
</button>
{/* <button
2023-02-16 17:51:54 +08:00
type="button"
class="plain small"
onClick={() => {
states.shortcuts.splice(i, 1);
}}
>
<Icon icon="x" alt="Remove" />
2023-04-08 22:16:13 +08:00
</button> */}
2024-01-10 14:48:29 +08:00
</span>
</li>
);
})}
</ol>
{shortcuts.length === 1 &&
snapStates.settings.shortcutsViewMode !== 'float-button' && (
<div class="ui-state insignificant">
<Icon icon="info" />{' '}
<small>
2024-08-13 15:26:23 +08:00
<Trans>
Add more than one shortcut/column to make this work.
</Trans>
2024-01-10 14:48:29 +08:00
</small>
</div>
)}
</>
2023-02-16 17:51:54 +08:00
) : (
<div class="ui-state insignificant">
2024-03-02 10:08:10 +08:00
<p>
{snapStates.settings.shortcutsViewMode === 'multi-column'
2024-08-13 15:26:23 +08:00
? t`No columns yet. Tap on the Add column button.`
: t`No shortcuts yet. Tap on the Add shortcut button.`}
2024-03-02 10:08:10 +08:00
</p>
<p>
2024-08-13 15:26:23 +08:00
<Trans>
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.
</Trans>
</p>
</div>
2023-02-16 17:51:54 +08:00
)}
<p class="insignificant">
{shortcuts.length >= SHORTCUTS_LIMIT &&
2024-03-02 10:08:10 +08:00
(snapStates.settings.shortcutsViewMode === 'multi-column'
2024-08-13 15:26:23 +08:00
? t`Max ${SHORTCUTS_LIMIT} columns`
: t`Max ${SHORTCUTS_LIMIT} shortcuts`)}
</p>
<p
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
2023-02-16 17:51:54 +08:00
}}
>
<button
type="button"
class="light"
onClick={() => setShowImportExport(true)}
>
2024-08-13 15:26:23 +08:00
<Trans>Import/export</Trans>
</button>
<button
type="button"
disabled={shortcuts.length >= SHORTCUTS_LIMIT}
onClick={() => setShowForm(true)}
>
2024-01-10 14:48:08 +08:00
<Icon icon="plus" />{' '}
<span>
{snapStates.settings.shortcutsViewMode === 'multi-column'
2024-08-13 15:26:23 +08:00
? t`Add column…`
: t`Add shortcut…`}
2024-01-10 14:48:08 +08:00
</span>
</button>
</p>
2023-02-16 17:51:54 +08:00
</main>
{showForm && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowForm(false);
}
}}
>
<ShortcutForm
2023-04-08 22:16:13 +08:00
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
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowImportExport(false);
}
}}
>
<ImportExport
shortcuts={shortcuts}
onClose={() => setShowImportExport(false)}
/>
</Modal>
)}
2023-02-16 17:51:54 +08:00
</div>
);
}
2023-12-22 18:01:41 +08:00
const FORM_NOTES = {
2024-08-13 15:26:23 +08:00
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.`,
2023-12-22 18:01:41 +08:00
};
function ShortcutForm({
onSubmit,
disabled,
2023-04-08 22:16:13 +08:00
shortcut,
shortcutIndex,
2023-04-20 16:10:57 +08:00
onClose,
}) {
2024-08-13 15:26:23 +08:00
const { _ } = useLingui();
2023-04-08 22:16:13 +08:00
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 {
2023-12-15 01:58:29 +08:00
const tags = await fetchFollowedTags();
setFollowedHashtags(tags);
} catch (e) {
console.error(e);
}
})();
}, [currentType]);
2023-04-08 22:16:13 +08:00
const formRef = useRef();
useEffect(() => {
if (editMode && currentType && TYPE_PARAMS[currentType]) {
// Populate form
const form = formRef.current;
2023-04-08 22:37:05 +08:00
TYPE_PARAMS[currentType].forEach(({ name, type }) => {
2023-04-08 22:16:13 +08:00
const input = form.querySelector(`[name="${name}"]`);
if (input && shortcut[name]) {
2023-04-08 22:37:05 +08:00
if (type === 'checkbox') {
input.checked = shortcut[name] === 'on' ? true : false;
} else {
input.value = shortcut[name];
}
2023-04-08 22:16:13 +08:00
}
});
}
}, [editMode, currentType]);
2023-02-16 17:51:54 +08:00
return (
<div id="shortcut-settings-form" class="sheet">
2023-04-20 16:10:57 +08:00
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
2024-08-13 15:26:23 +08:00
<Icon icon="x" alt={t`Close`} />
2023-04-20 16:10:57 +08:00
</button>
)}
<header>
2024-08-13 15:26:23 +08:00
<h2>{editMode ? t`Edit shortcut` : t`Add shortcut`}</h2>
</header>
<main tabindex="-1">
<form
2023-04-08 22:16:13 +08:00
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(/^@?[^@]+@/, '');
}
});
2023-04-08 22:16:13 +08:00
console.log('result', result);
if (!result.type) return;
2023-04-08 22:16:13 +08:00
onSubmit({
result,
mode: editMode ? 'edit' : 'add',
});
// Reset
e.target.reset();
setCurrentType(null);
2023-04-20 16:10:57 +08:00
onClose?.();
}}
>
<p>
<label>
2024-08-13 15:26:23 +08:00
<span>
<Trans>Timeline</Trans>
</span>
<select
required
disabled={disabled}
onChange={(e) => {
setCurrentType(e.target.value);
}}
2023-04-08 22:16:13 +08:00
defaultValue={editMode ? shortcut.type : undefined}
name="type"
2024-08-04 13:32:30 +08:00
dir="auto"
>
<option></option>
{TYPES.map((type) => (
2024-08-13 15:26:23 +08:00
<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>
2024-08-13 15:26:23 +08:00
<span>
<Trans>List</Trans>
</span>
<select
name="id"
required={!notRequired}
disabled={disabled || uiState === 'loading'}
defaultValue={editMode ? shortcut.id : undefined}
2024-08-04 13:32:30 +08:00
dir="auto"
>
<option value=""></option>
{lists.map((list) => (
<option value={list.id}>{list.title}</option>
))}
</select>
</label>
</p>
);
}
2023-02-16 17:51:54 +08:00
return (
<p>
<label>
2024-08-13 15:26:23 +08:00
<span>{_(text)}</span>{' '}
<input
type={type}
2023-12-22 18:01:41 +08:00
switch={type === 'checkbox' || undefined}
name={name}
2024-08-13 15:26:23 +08:00
placeholder={_(placeholder)}
required={type === 'text' && !notRequired}
disabled={disabled}
list={
currentType === 'hashtag'
? 'followed-hashtags-datalist'
: null
}
autocorrect="off"
autocapitalize="off"
spellCheck={false}
pattern={pattern}
2024-08-04 13:32:30 +08:00
dir="auto"
/>
{currentType === 'hashtag' &&
followedHashtags.length > 0 && (
<datalist id="followed-hashtags-datalist">
{followedHashtags.map((tag) => (
<option value={tag.name} />
))}
</datalist>
)}
2023-02-16 17:51:54 +08:00
</label>
</p>
);
},
)}
2023-12-22 18:01:41 +08:00
{!!FORM_NOTES[currentType] && (
<p class="form-note insignificant">
<Icon icon="info" />
2024-08-13 15:26:23 +08:00
{_(FORM_NOTES[currentType])}
2023-12-22 18:01:41 +08:00
</p>
)}
2023-04-08 22:16:13 +08:00
<footer>
<button
type="submit"
class="block"
disabled={disabled || uiState === 'loading'}
>
2024-08-13 15:26:23 +08:00
{editMode ? t`Save` : t`Add`}
2023-04-08 22:16:13 +08:00
</button>
{editMode && (
<button
type="button"
class="light danger"
onClick={() => {
states.shortcuts.splice(shortcutIndex, 1);
2023-04-20 16:10:57 +08:00
onClose?.();
2023-04-08 22:16:13 +08:00
}}
>
2024-08-13 15:26:23 +08:00
<Trans>Remove</Trans>
2023-04-08 22:16:13 +08:00
</button>
)}
</footer>
</form>
</main>
</div>
2023-02-16 17:51:54 +08:00
);
}
function ImportExport({ shortcuts, onClose }) {
2024-08-13 15:26:23 +08:00
const { _ } = useLingui();
const { masto } = api();
2023-08-16 16:39:22 +08:00
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}>
2024-08-13 15:26:23 +08:00
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<h2>
2024-08-13 15:26:23 +08:00
<Trans>
Import/Export <small class="ib insignificant">Shortcuts</small>
</Trans>
</h2>
</header>
<main tabindex="-1">
<section>
<h3>
<Icon icon="arrow-down-circle" size="l" class="insignificant" />{' '}
2024-08-13 15:26:23 +08:00
<span>
<Trans>Import</Trans>
</span>
</h3>
<p class="field-button">
<input
ref={shortcutsImportFieldRef}
type="text"
name="import"
2024-08-13 15:26:23 +08:00
placeholder={t`Paste shortcuts here`}
class="block"
onInput={(e) => {
setImportShortcutStr(e.target.value);
}}
2024-08-04 13:32:30 +08:00
dir="auto"
/>
{states.settings.shortcutSettingsCloudImportExport && (
<button
type="button"
class="plain2 small"
disabled={importUIState === 'cloud-downloading'}
onClick={async () => {
setImportUIState('cloud-downloading');
const currentAccount = getCurrentAccountID();
showToast(
2024-08-13 15:26:23 +08:00
t`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');
2024-08-13 15:26:23 +08:00
showToast(t`Unable to download shortcuts`);
}
}}
2024-08-13 15:26:23 +08:00
title={t`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>
2024-08-13 15:26:23 +08:00
{_(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>
2024-08-13 15:26:23 +08:00
<small>
<Trans>* Exists in current shortcuts</Trans>
</small>
<br />
<small>
2024-08-13 15:26:23 +08:00
{' '}
<Trans>
List may not work if it's from a different account.
</Trans>
</small>
</p>
</>
)}
{importUIState === 'error' && (
<p class="error">
2024-08-13 15:26:23 +08:00
<small>
<Trans>Invalid settings format</Trans>
</small>
</p>
)}
<p>
{hasCurrentSettings && (
<>
<MenuConfirm
2024-08-13 15:26:23 +08:00
confirmLabel={t`Append to current shortcuts?`}
menuFooter={
<div class="footer">
2024-08-13 15:26:23 +08:00
<Trans>
Only shortcuts that dont exist in current shortcuts
will be appended.
</Trans>
</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) {
2024-08-13 15:26:23 +08:00
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
2024-08-13 15:26:23 +08:00
? t`Shortcuts imported. Exceeded max ${SHORTCUTS_LIMIT}, so the rest are not imported.`
: t`Shortcuts imported`,
);
onClose?.();
}}
>
<button
type="button"
class="plain2"
disabled={!parsedImportShortcutStr}
>
2024-08-13 15:26:23 +08:00
<Trans>Import & append</Trans>
</button>
</MenuConfirm>{' '}
</>
)}
<MenuConfirm
confirmLabel={
hasCurrentSettings
2024-08-13 15:26:23 +08:00
? t`Override current shortcuts?`
: t`Import shortcuts?`
}
menuItemClassName={hasCurrentSettings ? 'danger' : undefined}
onClick={() => {
states.shortcuts = parsedImportShortcutStr;
2024-08-13 15:26:23 +08:00
showToast(t`Shortcuts imported`);
onClose?.();
}}
>
<button
type="button"
class="plain2"
disabled={!parsedImportShortcutStr}
>
2024-08-13 15:26:23 +08:00
{hasCurrentSettings ? t`or override…` : t`Import…`}
</button>
</MenuConfirm>
</p>
</section>
<section>
<h3>
<Icon icon="arrow-up-circle" size="l" class="insignificant" />{' '}
2024-08-13 15:26:23 +08:00
<span>
<Trans>Export</Trans>
</span>
</h3>
<p>
<input
style={{ width: '100%' }}
type="text"
value={shortcutsStr}
readOnly
onClick={(e) => {
2023-08-16 16:39:22 +08:00
if (!e.target.value) return;
e.target.select();
// Copy url to clipboard
try {
navigator.clipboard.writeText(e.target.value);
2024-08-13 15:26:23 +08:00
showToast(t`Shortcuts copied`);
} catch (e) {
console.error(e);
2024-08-13 15:26:23 +08:00
showToast(t`Unable to copy shortcuts`);
}
}}
2024-08-04 13:32:30 +08:00
dir="auto"
/>
</p>
<p>
<button
type="button"
class="plain2"
2023-08-16 16:39:22 +08:00
disabled={!shortcutsStr}
onClick={() => {
try {
navigator.clipboard.writeText(shortcutsStr);
2024-08-13 15:26:23 +08:00
showToast(t`Shortcut settings copied`);
} catch (e) {
console.error(e);
2024-08-13 15:26:23 +08:00
showToast(t`Unable to copy shortcut settings`);
}
}}
>
2024-08-13 15:26:23 +08:00
<Icon icon="clipboard" />{' '}
<span>
<Trans>Copy</Trans>
</span>
</button>{' '}
{navigator?.share &&
navigator?.canShare?.({
text: shortcutsStr,
}) && (
<button
type="button"
class="plain2"
2023-08-16 16:39:22 +08:00
disabled={!shortcutsStr}
onClick={() => {
try {
navigator.share({
text: shortcutsStr,
});
} catch (e) {
console.error(e);
2024-08-13 15:26:23 +08:00
alert(t`Sharing doesn't seem to work.`);
}
}}
>
2024-08-13 15:26:23 +08:00
<Icon icon="share" />{' '}
<span>
<Trans>Share</Trans>
</span>
</button>
)}{' '}
{states.settings.shortcutSettingsCloudImportExport && (
<button
type="button"
class="plain2"
disabled={importUIState === 'cloud-uploading'}
onClick={async () => {
setImportUIState('cloud-uploading');
const currentAccount = getCurrentAccountID();
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 = '';
const settingsJSON = JSON.stringify({
v: '1', // version
dt: Date.now(), // datetime stamp
data: shortcutsStr, // shortcuts settings string
});
if (
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test(
note,
)
) {
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>`;
}
2024-08-13 15:26:23 +08:00
showToast(t`Saving shortcuts to instance server…`);
await masto.v1.accounts
.$select(currentAccount)
.note.create({
comment: newNote,
});
setImportUIState('default');
2024-08-13 15:26:23 +08:00
showToast(t`Shortcuts saved`);
}
} catch (e) {
console.error(e);
setImportUIState('error');
2024-08-13 15:26:23 +08:00
showToast(t`Unable to save shortcuts`);
}
}}
2024-08-13 15:26:23 +08:00
title={t`Sync to instance server`}
>
<Icon icon="cloud" />
<Icon icon="arrow-up" />
</button>
)}{' '}
{shortcutsStr.length > 0 && (
<small class="insignificant ib">
2024-08-13 15:26:23 +08:00
<Plural
value={shortcutsStr.length}
one="# character"
other="# characters"
/>
</small>
)}
</p>
2023-08-16 16:39:22 +08:00
{!!shortcutsStr && (
<details>
<summary class="insignificant">
2024-08-13 15:26:23 +08:00
<small>
<Trans>Raw Shortcuts JSON</Trans>
</small>
2023-08-16 16:39:22 +08:00
</summary>
<textarea style={{ width: '100%' }} rows={10} readOnly>
{JSON.stringify(shortcuts.filter(Boolean), null, 2)}
</textarea>
</details>
)}
</section>
{states.settings.shortcutSettingsCloudImportExport && (
<footer>
<p>
2024-08-13 15:26:23 +08:00
<Icon icon="cloud" />{' '}
<Trans>
Import/export settings from/to instance server (Very
experimental)
</Trans>
</p>
</footer>
)}
</main>
</div>
);
}
export default ShortcutsSettings;