From 0bef245c831c6635571098c824131ff56585d8fa Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Thu, 16 Feb 2023 17:51:54 +0800 Subject: [PATCH] New feature: Shortcuts --- src/app.css | 38 ++- src/app.jsx | 40 +-- src/components/AsyncText.jsx | 12 + src/components/MenuLink.jsx | 21 ++ src/components/icon.jsx | 3 + src/components/menu.jsx | 30 +-- src/components/shortcuts-settings.css | 69 +++++ src/components/shortcuts-settings.jsx | 351 ++++++++++++++++++++++++++ src/components/shortcuts.css | 39 +++ src/components/shortcuts.jsx | 103 ++++++++ src/pages/home.jsx | 2 +- src/utils/states.js | 11 +- 12 files changed, 676 insertions(+), 43 deletions(-) create mode 100644 src/components/AsyncText.jsx create mode 100644 src/components/MenuLink.jsx create mode 100644 src/components/shortcuts-settings.css create mode 100644 src/components/shortcuts-settings.jsx create mode 100644 src/components/shortcuts.css create mode 100644 src/components/shortcuts.jsx diff --git a/src/app.css b/src/app.css index e5daa59..20ca557 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 b2caa83..cf7c414 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 0000000..7d10346 --- /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 0000000..ed79984 --- /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 aee772e..7701a74 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 adee356..5271f47 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 && ( <> + { + states.showShortcutsSettings = true; + }} + > + {' '} + Shortcuts Settings… + { states.showSettings = true; }} > - Settings + Settings… )} @@ -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 0000000..e35cd1d --- /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 0000000..31ada52 --- /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 ( +
  1. + + + {title} + + + + + + +
  2. + ); + })} +
+ ) : ( +

+ 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 ( + <> +
{ + // Construct a nice object from form + e.preventDefault(); + const data = new FormData(e.target); + const result = {}; + data.forEach((value, key) => { + result[key] = value; + }); + if (!result.type) return; + onSubmit(result); + // Reset + e.target.reset(); + setCurrentType(null); + }} + > +
+

Add a shortcut

+ +
+

+ +

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

+ +

+ ); + } + + return ( +

+ +

+ ); + }, + )} +
+
+ + ); +} diff --git a/src/components/shortcuts.css b/src/components/shortcuts.css new file mode 100644 index 0000000..c572ea4 --- /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 0000000..afefc82 --- /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 ( +
+ { + // Close menu if the button disappears + try { + const { target } = e; + if (getComputedStyle(target).pointerEvents === 'none') { + menuRef.current?.closeMenu?.(); + } + } catch (e) {} + }} + > + + + } + > + {formattedShortcuts.map(({ path, title, icon }, i) => { + return ( + + {' '} + + {title} + + {i + 1} + + ); + })} + +
+ ); +} + +export default Shortcuts; diff --git a/src/pages/home.jsx b/src/pages/home.jsx index 3a75605..6598274 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 ecccdf0..3ff753e 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;