diff --git a/src/app.css b/src/app.css
index e5daa596..20ca557d 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1011,17 +1011,21 @@ button.carousel-dot:is(.active, [disabled].active) {
/* MENU POPUP */
.szh-menu {
- padding: 8px 0 !important;
+ padding: 8px 0;
margin: 0;
font-size: 16px;
- background-color: var(--bg-color) !important;
- border: 1px solid var(--outline-color) !important;
+ background-color: var(--bg-color);
+ border: 1px solid var(--outline-color);
border-radius: 8px;
box-shadow: 0 3px 6px var(--drop-shadow-color);
text-align: left;
animation: appear 0.15s ease-in-out;
width: 16em;
max-width: 90vw;
+ overflow: hidden;
+}
+.szh-menu__item--focusable {
+ background-color: transparent;
}
.szh-menu .szh-menu__item {
padding: 8px 16px !important;
@@ -1036,11 +1040,18 @@ button.carousel-dot:is(.active, [disabled].active) {
.szh-menu .szh-menu__item a {
overflow: hidden;
text-overflow: ellipsis;
- display: block;
+ display: flex;
color: inherit;
text-decoration: none;
padding: 8px 16px !important;
margin: -8px -16px !important;
+ gap: 8px;
+}
+.szh-menu .szh-menu__item a.is-active {
+ font-weight: bold;
+}
+.szh-menu .szh-menu__item .icon {
+ opacity: 0.5;
}
.szh-menu
.szh-menu__item:not(.szh-menu__item--disabled, .szh-menu__item--hover) {
@@ -1053,6 +1064,25 @@ button.carousel-dot:is(.active, [disabled].active) {
.szh-menu__divider {
background-color: var(--divider-color);
}
+.szh-menu .szh-menu__item .menu-grow {
+ flex-grow: 1;
+}
+.szh-menu .szh-menu__item .menu-shortcut {
+ opacity: 0.5;
+ font-weight: normal;
+}
+
+/* GLASS MENU */
+
+.glass-menu {
+ background-color: var(--bg-blur-color);
+ backdrop-filter: blur(8px) saturate(3);
+ border: 0;
+ box-shadow: 0 3px 8px -1px var(--drop-shadow-color);
+}
+.glass-menu .szh-menu__item--hover {
+ background-color: var(--button-bg-blur-color);
+}
/* DONUT METER */
diff --git a/src/app.jsx b/src/app.jsx
index b2caa839..cf7c414f 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -25,6 +25,8 @@ import Link from './components/link';
import Loader from './components/loader';
import MediaModal from './components/media-modal';
import Modal from './components/modal';
+import Shortcuts from './components/shortcuts';
+import ShortcutsSettings from './components/shortcuts-settings';
import NotFound from './pages/404';
import AccountStatuses from './pages/account-statuses';
import Bookmarks from './pages/bookmarks';
@@ -146,25 +148,15 @@ function App() {
return () => clearTimeout(timer);
};
useEffect(focusDeck, [location]);
- const showModal = useMemo(() => {
- return (
- snapStates.showCompose ||
- snapStates.showSettings ||
- snapStates.showAccount ||
- snapStates.showDrafts ||
- snapStates.showMediaModal
- );
- }, [
- snapStates.showCompose,
- snapStates.showSettings,
- snapStates.showAccount,
- snapStates.showDrafts,
- snapStates.showMediaModal,
- ]);
+ const showModal =
+ snapStates.showCompose ||
+ snapStates.showSettings ||
+ snapStates.showAccount ||
+ snapStates.showDrafts ||
+ snapStates.showMediaModal ||
+ snapStates.showShortcutsSettings;
useEffect(() => {
- if (!showModal) {
- focusDeck();
- }
+ if (!showModal) focusDeck();
}, [showModal]);
// useEffect(() => {
@@ -306,6 +298,7 @@ function App() {
+
{!!snapStates.showCompose && (
)}
+ {!!snapStates.showShortcutsSettings && (
+ {
+ if (e.target === e.currentTarget) {
+ states.showShortcutsSettings = false;
+ }
+ }}
+ >
+
+
+ )}
>
);
}
diff --git a/src/components/AsyncText.jsx b/src/components/AsyncText.jsx
new file mode 100644
index 00000000..7d10346a
--- /dev/null
+++ b/src/components/AsyncText.jsx
@@ -0,0 +1,12 @@
+import { useEffect, useState } from 'preact/hooks';
+
+function AsyncText({ children }) {
+ if (typeof children === 'string') return children;
+ const [text, setText] = useState('');
+ useEffect(() => {
+ Promise.resolve(children).then(setText);
+ }, [children]);
+ return text;
+}
+
+export default AsyncText;
diff --git a/src/components/MenuLink.jsx b/src/components/MenuLink.jsx
new file mode 100644
index 00000000..ed799844
--- /dev/null
+++ b/src/components/MenuLink.jsx
@@ -0,0 +1,21 @@
+import { FocusableItem } from '@szhsin/react-menu';
+
+import Link from './link';
+
+function MenuLink(props) {
+ return (
+
+ {({ ref, closeMenu }) => (
+
+ closeMenu(detail === 0 ? 'Enter' : undefined)
+ }
+ />
+ )}
+
+ );
+}
+
+export default MenuLink;
diff --git a/src/components/icon.jsx b/src/components/icon.jsx
index aee772e9..7701a743 100644
--- a/src/components/icon.jsx
+++ b/src/components/icon.jsx
@@ -53,6 +53,9 @@ const ICONS = {
search: 'mingcute:search-2-line',
hashtag: 'mingcute:hashtag-line',
info: 'mingcute:information-line',
+ shortcut: 'mingcute:lightning-line',
+ user: 'mingcute:user-4-line',
+ following: 'mingcute:walk-line',
};
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');
diff --git a/src/components/menu.jsx b/src/components/menu.jsx
index adee356a..5271f47b 100644
--- a/src/components/menu.jsx
+++ b/src/components/menu.jsx
@@ -1,11 +1,11 @@
-import { FocusableItem, Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
+import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useSnapshot } from 'valtio';
import { api } from '../utils/api';
import states from '../utils/states';
import Icon from './icon';
-import Link from './link';
+import MenuLink from './MenuLink';
function NavMenu(props) {
const snapStates = useSnapshot(states);
@@ -67,12 +67,20 @@ function NavMenu(props) {
{authenticated && (
<>
+
>
)}
@@ -80,20 +88,4 @@ function NavMenu(props) {
);
}
-function MenuLink(props) {
- return (
-
- {({ ref, closeMenu }) => (
-
- closeMenu(detail === 0 ? 'Enter' : undefined)
- }
- />
- )}
-
- );
-}
-
export default NavMenu;
diff --git a/src/components/shortcuts-settings.css b/src/components/shortcuts-settings.css
new file mode 100644
index 00000000..e35cd1dc
--- /dev/null
+++ b/src/components/shortcuts-settings.css
@@ -0,0 +1,69 @@
+#shortcuts-settings-container .shortcuts-list {
+ line-height: 1.5;
+ padding: 0;
+ margin: 8px 0 0;
+ counter-reset: index;
+ border-radius: 8px;
+ overflow: hidden;
+}
+#shortcuts-settings-container .shortcuts-list li {
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ gap: 4px;
+ background-color: var(--bg-faded-color);
+}
+#shortcuts-settings-container .shortcuts-list li::before {
+ content: counter(index);
+ counter-increment: index;
+ display: inline-block;
+ width: 1.2em;
+ text-align: right;
+ margin-right: 8px;
+ color: var(--text-insignificant-color);
+ font-size: 90%;
+}
+#shortcuts-settings-container .shortcuts-list li .shortcut-text {
+ flex-grow: 1;
+}
+
+#shortcuts-settings-container form {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+ padding: 16px;
+ background-color: var(--bg-faded-color);
+ border-radius: 16px;
+}
+
+#shortcuts-settings-container form header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+#shortcuts-settings-container form > * {
+ flex-basis: max(320px, 100%);
+ margin: 0;
+ padding: 0;
+}
+
+#shortcuts-settings-container form label {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ align-items: center;
+}
+#shortcuts-settings-container form label > span:first-child {
+ flex-basis: 5em;
+ text-align: right;
+}
+#shortcuts-settings-container form :is(input[type='text'], select) {
+ flex-grow: 1;
+ flex-basis: 70%;
+ flex-shrink: 1;
+ /* width: calc(100% - 32px); */
+ min-width: 0;
+ max-width: 320px;
+}
diff --git a/src/components/shortcuts-settings.jsx b/src/components/shortcuts-settings.jsx
new file mode 100644
index 00000000..31ada528
--- /dev/null
+++ b/src/components/shortcuts-settings.jsx
@@ -0,0 +1,351 @@
+import './shortcuts-settings.css';
+
+import { useEffect, useState } from 'preact/hooks';
+import { useSnapshot } from 'valtio';
+
+import { api } from '../utils/api';
+import states from '../utils/states';
+
+import AsyncText from './AsyncText';
+import Icon from './icon';
+
+const TYPES = [
+ 'following',
+ 'notifications',
+ 'list',
+ 'public',
+ 'search',
+ // NOTE: Hide for now, can't think of a good way to handle this
+ // 'account-statuses',
+ 'bookmarks',
+ 'favourites',
+ 'hashtag',
+];
+const TYPE_TEXT = {
+ following: 'Home',
+ notifications: 'Notifications',
+ list: 'List',
+ public: 'Public',
+ search: 'Search',
+ 'account-statuses': 'Account',
+ bookmarks: 'Bookmarks',
+ favourites: 'Favourites',
+ hashtag: 'Hashtag',
+};
+const TYPE_PARAMS = {
+ list: [
+ {
+ text: 'List ID',
+ name: 'id',
+ },
+ ],
+ public: [
+ {
+ text: 'Local only',
+ name: 'local',
+ type: 'checkbox',
+ },
+ {
+ text: 'Instance',
+ name: 'instance',
+ type: 'text',
+ placeholder: 'e.g. mastodon.social',
+ },
+ ],
+ search: [
+ {
+ text: 'Search term',
+ name: 'query',
+ type: 'text',
+ },
+ ],
+ 'account-statuses': [
+ {
+ text: '@',
+ name: 'id',
+ type: 'text',
+ placeholder: 'cheeaun@mastodon.social',
+ },
+ ],
+ hashtag: [
+ {
+ text: '#',
+ name: 'hashtag',
+ type: 'text',
+ placeholder: 'e.g PixelArt',
+ },
+ ],
+};
+export const SHORTCUTS_META = {
+ following: {
+ title: 'Home',
+ path: (_, index) => (index === 0 ? '/' : '/l/f'),
+ icon: 'home',
+ },
+ notifications: {
+ title: 'Notifications',
+ path: '/notifications',
+ icon: 'notification',
+ },
+ list: {
+ title: async ({ id }) => {
+ const list = await api().masto.v1.lists.fetch(id);
+ return list.title;
+ },
+ path: ({ id }) => `/l/${id}`,
+ icon: 'list',
+ },
+ public: {
+ title: ({ local, instance }) =>
+ `${local ? 'Local' : 'Federated'} (${instance})`,
+ path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`,
+ icon: ({ local }) => (local ? 'group' : 'earth'),
+ },
+ search: {
+ title: ({ query }) => query,
+ path: ({ query }) => `/search?q=${query}`,
+ icon: 'search',
+ },
+ 'account-statuses': {
+ title: async ({ id }) => {
+ const account = await api().masto.v1.accounts.fetch(id);
+ return account.username || account.acct || account.displayName;
+ },
+ path: ({ id }) => `/a/${id}`,
+ icon: 'user',
+ },
+ bookmarks: {
+ title: 'Bookmarks',
+ path: '/b',
+ icon: 'bookmark',
+ },
+ favourites: {
+ title: 'Favourites',
+ path: '/f',
+ icon: 'heart',
+ },
+ hashtag: {
+ title: ({ hashtag }) => hashtag,
+ path: ({ hashtag }) => `/t/${hashtag}`,
+ icon: 'hashtag',
+ },
+};
+
+function ShortcutsSettings() {
+ const snapStates = useSnapshot(states);
+ const { masto } = api();
+
+ const [lists, setLists] = useState([]);
+ const [followedHashtags, setFollowedHashtags] = useState([]);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const lists = await masto.v1.lists.list();
+ setLists(lists);
+ } catch (e) {
+ console.error(e);
+ }
+ })();
+
+ (async () => {
+ try {
+ const iterator = masto.v1.followedTags.list();
+ const tags = [];
+ do {
+ const { value, done } = await iterator.next();
+ if (done || value?.length === 0) break;
+ tags.push(...value);
+ } while (true);
+ setFollowedHashtags(tags);
+ } catch (e) {
+ console.error(e);
+ }
+ })();
+ }, []);
+
+ return (
+
+
+
+ Shortcuts{' '}
+
+ beta
+
+
+
+
+
+ Specify a list of shortcuts that'll appear in the floating Shortcuts
+ button.
+
+ {snapStates.shortcuts.length > 0 ? (
+
+ {snapStates.shortcuts.map((shortcut, i) => {
+ const key = i + Object.values(shortcut);
+ const { type } = shortcut;
+ let { icon, title } = SHORTCUTS_META[type];
+ if (typeof title === 'function') {
+ title = title(shortcut);
+ }
+ if (typeof icon === 'function') {
+ icon = icon(shortcut);
+ }
+ return (
+ -
+
+
+ {title}
+
+
+
+
+
+
+
+ );
+ })}
+
+ ) : (
+
+ No shortcuts yet. Add one from the form below.
+
+ )}
+
+ {
+ console.log('onSubmit', data);
+ states.shortcuts.push(data);
+ }}
+ />
+
+
+ );
+}
+
+export default ShortcutsSettings;
+function ShortcutForm({ type, lists, followedHashtags, onSubmit }) {
+ const [currentType, setCurrentType] = useState(type);
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/src/components/shortcuts.css b/src/components/shortcuts.css
new file mode 100644
index 00000000..c572ea47
--- /dev/null
+++ b/src/components/shortcuts.css
@@ -0,0 +1,39 @@
+#shortcuts-button {
+ position: fixed;
+ bottom: 16px;
+ bottom: max(16px, env(safe-area-inset-bottom));
+ left: 16px;
+ left: max(16px, env(safe-area-inset-left));
+ padding: 16px;
+ background-color: var(--bg-faded-blur-color);
+ z-index: 101;
+ box-shadow: 0 3px 8px -1px var(--drop-shadow-color);
+ transition: all 0.3s ease-in-out;
+}
+#shortcuts-button .icon {
+ transform: translateY(2px); /* Balance the icon's vertical alignment */
+}
+#app:has(header[hidden]) #shortcuts-button,
+#shortcuts-button[hidden] {
+ transform: translateY(200%);
+ pointer-events: none;
+ user-select: none;
+}
+#shortcuts-button:is(:hover, :focus) {
+ background-color: var(--button-color);
+ filter: none;
+}
+#shortcuts-button:active {
+ filter: brightness(0.75);
+}
+
+@media (min-width: calc(40em + 56px + 8px)) {
+ #shortcuts-button {
+ right: 16px;
+ right: max(16px, env(safe-area-inset-right));
+ left: auto;
+ top: 16px;
+ top: max(16px, env(safe-area-inset-top));
+ bottom: auto;
+ }
+}
diff --git a/src/components/shortcuts.jsx b/src/components/shortcuts.jsx
new file mode 100644
index 00000000..afefc822
--- /dev/null
+++ b/src/components/shortcuts.jsx
@@ -0,0 +1,103 @@
+import './shortcuts.css';
+
+import { Menu, MenuItem } from '@szhsin/react-menu';
+import { useRef } from 'preact/hooks';
+import { useHotkeys } from 'react-hotkeys-hook';
+import { useNavigate } from 'react-router-dom';
+import { useSnapshot } from 'valtio';
+
+import { SHORTCUTS_META } from '../components/shortcuts-settings';
+import states from '../utils/states';
+
+import AsyncText from './AsyncText';
+import Icon from './icon';
+import MenuLink from './MenuLink';
+
+function Shortcuts() {
+ const snapStates = useSnapshot(states);
+ const { shortcuts } = snapStates;
+
+ if (!shortcuts.length) {
+ return null;
+ }
+
+ const menuRef = useRef();
+
+ const formattedShortcuts = shortcuts.map((pin, i) => {
+ const { type, ...data } = pin;
+ let { path, title, icon } = SHORTCUTS_META[type];
+
+ if (typeof path === 'function') {
+ path = path(data, i);
+ }
+ if (typeof title === 'function') {
+ title = title(data);
+ }
+ if (typeof icon === 'function') {
+ icon = icon(data);
+ }
+
+ return {
+ path,
+ title,
+ icon,
+ };
+ });
+
+ const navigate = useNavigate();
+ useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
+ const index = parseInt(handler.keys[0], 10) - 1;
+ if (index < formattedShortcuts.length) {
+ const { path } = formattedShortcuts[index];
+ if (path) {
+ navigate(path);
+ }
+ }
+ });
+
+ return (
+
+
+
+ );
+}
+
+export default Shortcuts;
diff --git a/src/pages/home.jsx b/src/pages/home.jsx
index 3a756051..65982744 100644
--- a/src/pages/home.jsx
+++ b/src/pages/home.jsx
@@ -62,7 +62,7 @@ function Home() {
}
}}
>
-
+
>
);
diff --git a/src/utils/states.js b/src/utils/states.js
index ecccdf0f..3ff753ee 100644
--- a/src/utils/states.js
+++ b/src/utils/states.js
@@ -1,4 +1,4 @@
-import { proxy } from 'valtio';
+import { proxy, subscribe } from 'valtio';
import { subscribeKey } from 'valtio/utils';
import { api } from './api';
@@ -30,6 +30,9 @@ const states = proxy({
showAccount: false,
showDrafts: false,
showMediaModal: false,
+ showShortcutsSettings: false,
+ // Shortcuts
+ shortcuts: store.account.get('shortcuts') ?? [],
// Settings
settings: {
boostsCarousel: store.account.get('settings-boostCarousel') ?? true,
@@ -45,6 +48,12 @@ subscribeKey(states, 'notificationsLast', (v) => {
subscribeKey(states, 'settings-boostCarousel', (v) => {
store.account.set('settings-boostCarousel', !!v);
});
+subscribe(states, (v) => {
+ const [action, path, value] = v[0];
+ if (path?.[0] === 'shortcuts') {
+ store.account.set('shortcuts', states.shortcuts);
+ }
+});
export function hideAllModals() {
states.showCompose = false;