1
0
Fork 0

New feature: Shortcuts

This commit is contained in:
Lim Chee Aun 2023-02-16 17:51:54 +08:00
parent 75b6cddb04
commit 0bef245c83
12 changed files with 676 additions and 43 deletions

View file

@ -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 */

View file

@ -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() {
</Link>
</li>
</nav>
<Shortcuts />
{!!snapStates.showCompose && (
<Modal>
<Compose
@ -416,6 +409,17 @@ function App() {
/>
</Modal>
)}
{!!snapStates.showShortcutsSettings && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showShortcutsSettings = false;
}
}}
>
<ShortcutsSettings />
</Modal>
)}
</>
);
}

View file

@ -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;

View file

@ -0,0 +1,21 @@
import { FocusableItem } from '@szhsin/react-menu';
import Link from './link';
function MenuLink(props) {
return (
<FocusableItem>
{({ ref, closeMenu }) => (
<Link
{...props}
ref={ref}
onClick={({ detail }) =>
closeMenu(detail === 0 ? 'Enter' : undefined)
}
/>
)}
</FocusableItem>
);
}
export default MenuLink;

View file

@ -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');

View file

@ -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 && (
<>
<MenuDivider />
<MenuItem
onClick={() => {
states.showShortcutsSettings = true;
}}
>
<Icon icon="shortcut" size="l" />{' '}
<span>Shortcuts Settings&hellip;</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" alt="Settings" /> <span>Settings</span>
<Icon icon="gear" size="l" /> <span>Settings&hellip;</span>
</MenuItem>
</>
)}
@ -80,20 +88,4 @@ function NavMenu(props) {
);
}
function MenuLink(props) {
return (
<FocusableItem>
{({ ref, closeMenu }) => (
<Link
{...props}
ref={ref}
onClick={({ detail }) =>
closeMenu(detail === 0 ? 'Enter' : undefined)
}
/>
)}
</FocusableItem>
);
}
export default NavMenu;

View file

@ -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;
}

View file

@ -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 (
<div id="shortcuts-settings-container" class="sheet" tabindex="-1">
<header>
<h2>
<Icon icon="shortcut" /> Shortcuts{' '}
<sup
style={{
fontSize: 12,
opacity: 0.5,
textTransform: 'uppercase',
}}
>
beta
</sup>
</h2>
</header>
<main>
<p>
Specify a list of shortcuts that'll appear in the floating Shortcuts
button.
</p>
{snapStates.shortcuts.length > 0 ? (
<ol class="shortcuts-list">
{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 (
<li key={key}>
<Icon icon={icon} />
<span class="shortcut-text">
<AsyncText>{title}</AsyncText>
</span>
<span>
<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;
}
}}
>
<Icon icon="arrow-up" alt="Move up" />
</button>
<button
type="button"
class="plain small"
disabled={i === snapStates.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;
}
}}
>
<Icon icon="arrow-down" alt="Move down" />
</button>
<button
type="button"
class="plain small"
onClick={() => {
states.shortcuts.splice(i, 1);
}}
>
<Icon icon="x" alt="Remove" />
</button>
</span>
</li>
);
})}
</ol>
) : (
<p class="ui-state insignificant">
No shortcuts yet. Add one from the form below.
</p>
)}
<hr />
<ShortcutForm
lists={lists}
followedHashtags={followedHashtags}
onSubmit={(data) => {
console.log('onSubmit', data);
states.shortcuts.push(data);
}}
/>
</main>
</div>
);
}
export default ShortcutsSettings;
function ShortcutForm({ type, lists, followedHashtags, onSubmit }) {
const [currentType, setCurrentType] = useState(type);
return (
<>
<form
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;
});
if (!result.type) return;
onSubmit(result);
// Reset
e.target.reset();
setCurrentType(null);
}}
>
<header>
<h3>Add a shortcut</h3>
<button type="submit">Add</button>
</header>
<p>
<label>
<span>Timeline</span>
<select
onChange={(e) => {
setCurrentType(e.target.value);
}}
name="type"
>
<option></option>
{TYPES.map((type) => (
<option value={type}>{TYPE_TEXT[type]}</option>
))}
</select>
</label>
</p>
{TYPE_PARAMS[currentType]?.map?.(
({ text, name, type, placeholder }) => {
if (currentType === 'list') {
return (
<p>
<label>
<span>List</span>
<select name="id">
{lists.map((list) => (
<option value={list.id}>{list.title}</option>
))}
</select>
</label>
</p>
);
}
return (
<p>
<label>
<span>{text}</span>{' '}
<input type={type} name={name} placeholder={placeholder} />
{currentType === 'hashtag' && followedHashtags.length > 0 && (
<datalist>
{followedHashtags.map((tag) => (
<option value={tag.name} />
))}
</datalist>
)}
</label>
</p>
);
},
)}
<footer></footer>
</form>
</>
);
}

View file

@ -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;
}
}

View file

@ -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 (
<div id="shortcuts">
<Menu
instanceRef={menuRef}
overflow="auto"
viewScroll="close"
boundingBoxPadding="8 8 8 8"
menuClassName="glass-menu shortcuts-menu"
offsetY={4}
position="anchor"
menuButton={
<button
type="button"
id="shortcuts-button"
class="plain"
onTransitionStart={(e) => {
// Close menu if the button disappears
try {
const { target } = e;
if (getComputedStyle(target).pointerEvents === 'none') {
menuRef.current?.closeMenu?.();
}
} catch (e) {}
}}
>
<Icon icon="shortcut" size="xl" alt="Shortcuts" />
</button>
}
>
{formattedShortcuts.map(({ path, title, icon }, i) => {
return (
<MenuLink to={path} key={i + title} class="glass-menu-item">
<Icon icon={icon} size="l" />{' '}
<span class="menu-grow">
<AsyncText>{title}</AsyncText>
</span>
<span class="menu-shortcut">{i + 1}</span>
</MenuLink>
);
})}
</Menu>
</div>
);
}
export default Shortcuts;

View file

@ -62,7 +62,7 @@ function Home() {
}
}}
>
<Icon icon="quill" size="xxl" alt="Compose" />
<Icon icon="quill" size="xl" alt="Compose" />
</button>
</>
);

View file

@ -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;