From f6a9f7807efcf36f4ce0add218ee9b9222903f60 Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Sat, 23 Mar 2024 23:52:05 +0800 Subject: [PATCH] Allow Lists to be in Shortcuts (except columns) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …and all various Lists-related improvements --- src/app.css | 4 +- src/components/account-info.jsx | 6 +- src/components/columns.jsx | 2 + src/components/list-add-edit.jsx | 12 +++ src/components/nav-menu.jsx | 55 +++++++++--- src/components/shortcuts-settings.jsx | 31 +++---- src/components/shortcuts.jsx | 120 +++++++++++++++++--------- src/pages/list.jsx | 39 +++++++-- src/pages/lists.jsx | 6 +- src/utils/lists.js | 114 ++++++++++++++++++++++++ 10 files changed, 302 insertions(+), 87 deletions(-) create mode 100644 src/utils/lists.js diff --git a/src/app.css b/src/app.css index c55b9a2f..6f5e2a68 100644 --- a/src/app.css +++ b/src/app.css @@ -2288,10 +2288,10 @@ ul.link-list li a .icon { filter: none !important; } .nav-menu-button .avatar { - transition: box-shadow 0.3s ease-out; + box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color) !important; } .nav-menu-button:is(:hover, :focus, .active) .avatar { - box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color); + box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-color) !important; } .nav-menu-button.with-avatar .icon { position: absolute; diff --git a/src/components/account-info.jsx b/src/components/account-info.jsx index 0128be01..13402ff6 100644 --- a/src/components/account-info.jsx +++ b/src/components/account-info.jsx @@ -14,6 +14,7 @@ import { api } from '../utils/api'; import enhanceContent from '../utils/enhance-content'; import getHTMLText from '../utils/getHTMLText'; import handleContentLinks from '../utils/handle-content-links'; +import { getLists } from '../utils/lists'; import niceDateTime from '../utils/nice-date-time'; import pmem from '../utils/pmem'; import shortenNumber from '../utils/shorten-number'; @@ -1558,13 +1559,12 @@ function AddRemoveListsSheet({ accountID, onClose }) { setUIState('loading'); (async () => { try { - const lists = await masto.v1.lists.list(); - lists.sort((a, b) => a.title.localeCompare(b.title)); + const lists = await getLists(); + setLists(lists); const listsContainingAccount = await masto.v1.accounts .$select(accountID) .lists.list(); console.log({ lists, listsContainingAccount }); - setLists(lists); setListsContainingAccount(listsContainingAccount); setUIState('default'); } catch (e) { diff --git a/src/components/columns.jsx b/src/components/columns.jsx index 47036dce..f21e1165 100644 --- a/src/components/columns.jsx +++ b/src/components/columns.jsx @@ -39,6 +39,8 @@ function Columns() { if (!Component) return null; // Don't show Search column with no query, for now if (type === 'search' && !params.query) return null; + // Don't show List column with no list, for now + if (type === 'list' && !params.id) return null; return ( ); diff --git a/src/components/list-add-edit.jsx b/src/components/list-add-edit.jsx index 3bf6ff03..7360a7f2 100644 --- a/src/components/list-add-edit.jsx +++ b/src/components/list-add-edit.jsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'preact/hooks'; import { api } from '../utils/api'; +import { addListStore, deleteListStore, updateListStore } from '../utils/lists'; import supports from '../utils/supports'; import Icon from './icon'; @@ -75,6 +76,14 @@ function ListAddEdit({ list, onClose }) { state: 'success', list: listResult, }); + + setTimeout(() => { + if (editMode) { + updateListStore(listResult); + } else { + addListStore(listResult); + } + }, 1); } catch (e) { console.error(e); setUIState('error'); @@ -146,6 +155,9 @@ function ListAddEdit({ list, onClose }) { onClose?.({ state: 'deleted', }); + setTimeout(() => { + deleteListStore(list.id); + }, 1); } catch (e) { console.error(e); setUIState('error'); diff --git a/src/components/nav-menu.jsx b/src/components/nav-menu.jsx index 0d4fe98c..ba40dd94 100644 --- a/src/components/nav-menu.jsx +++ b/src/components/nav-menu.jsx @@ -7,11 +7,12 @@ import { SubMenu, } from '@szhsin/react-menu'; import { memo } from 'preact/compat'; -import { useEffect, useRef, useState } from 'preact/hooks'; +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useLongPress } from 'use-long-press'; import { useSnapshot } from 'valtio'; import { api } from '../utils/api'; +import { getLists } from '../utils/lists'; import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; import states from '../utils/states'; import store from '../utils/store'; @@ -24,16 +25,12 @@ function NavMenu(props) { const snapStates = useSnapshot(states); const { masto, instance, authenticated } = api(); - const [currentAccount, setCurrentAccount] = useState(); - const [moreThanOneAccount, setMoreThanOneAccount] = useState(false); - - useEffect(() => { + const [currentAccount, moreThanOneAccount] = useMemo(() => { const accounts = store.local.getJSON('accounts') || []; const acc = accounts.find( (account) => account.info.id === store.session.get('currentAccount'), ); - if (acc) setCurrentAccount(acc); - setMoreThanOneAccount(accounts.length > 1); + return [acc, accounts.length > 1]; }, []); // Home = Following @@ -89,6 +86,13 @@ function NavMenu(props) { return results; } + const [lists, setLists] = useState([]); + useEffect(() => { + if (menuState === 'open') { + getLists().then(setLists); + } + }, [menuState === 'open']); + const buttonClickTS = useRef(); return ( <> @@ -97,7 +101,7 @@ function NavMenu(props) { type="button" class={`button plain nav-menu-button ${ moreThanOneAccount ? 'with-avatar' : '' - } ${open ? 'active' : ''}`} + } ${menuState === 'open' ? 'active' : ''}`} style={{ position: 'relative' }} onClick={() => { buttonClickTS.current = Date.now(); @@ -203,9 +207,38 @@ function NavMenu(props) { Profile )} - - Lists - + {lists?.length > 0 ? ( + + + Lists + + + } + > + + All Lists + + {lists?.length > 0 && ( + <> + + {lists.map((list) => ( + + {list.title} + + ))} + + )} + + ) : ( + + + Lists + + )} Bookmarks diff --git a/src/components/shortcuts-settings.jsx b/src/components/shortcuts-settings.jsx index 94162dff..5ccfdb4c 100644 --- a/src/components/shortcuts-settings.jsx +++ b/src/components/shortcuts-settings.jsx @@ -14,6 +14,7 @@ import tabMenuBarUrl from '../assets/tab-menu-bar.svg'; import { api } from '../utils/api'; import { fetchFollowedTags } from '../utils/followed-tags'; +import { getLists, getListTitle } from '../utils/lists'; import pmem from '../utils/pmem'; import showToast from '../utils/show-toast'; import states from '../utils/states'; @@ -43,7 +44,7 @@ const TYPES = [ const TYPE_TEXT = { following: 'Home / Following', notifications: 'Notifications', - list: 'List', + list: 'Lists', public: 'Public (Local / Federated)', search: 'Search', 'account-statuses': 'Account', @@ -58,6 +59,7 @@ const TYPE_PARAMS = { { text: 'List ID', name: 'id', + notRequired: true, }, ], public: [ @@ -122,10 +124,6 @@ const TYPE_PARAMS = { }, ], }; -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; @@ -150,10 +148,11 @@ export const SHORTCUTS_META = { icon: 'notification', }, list: { - id: 'list', - title: fetchListTitle, - path: ({ id }) => `/l/${id}`, + id: ({ id }) => (id ? 'list' : 'lists'), + title: ({ id }) => (id ? getListTitle(id) : 'Lists'), + path: ({ id }) => (id ? `/l/${id}` : '/l'), icon: 'list', + excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []), }, public: { id: 'public', @@ -496,18 +495,8 @@ function ShortcutsSettings({ onClose }) { ); } -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 = { + list: `Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`, search: `For multi-column mode, search term is required, else the column will not be shown.`, hashtag: 'Multiple hashtags are supported. Space-separated.', }; @@ -532,8 +521,7 @@ function ShortcutForm({ if (currentType !== 'list') return; try { setUIState('loading'); - const lists = await fetchLists(); - lists.sort((a, b) => a.title.localeCompare(b.title)); + const lists = await getLists(); setLists(lists); setUIState('default'); } catch (e) { @@ -644,6 +632,7 @@ function ShortcutForm({ disabled={disabled || uiState === 'loading'} defaultValue={editMode ? shortcut.id : undefined} > + {lists.map((list) => ( ))} diff --git a/src/components/shortcuts.jsx b/src/components/shortcuts.jsx index fbb3520d..c4645c7b 100644 --- a/src/components/shortcuts.jsx +++ b/src/components/shortcuts.jsx @@ -1,14 +1,15 @@ import './shortcuts.css'; -import { Menu, MenuItem } from '@szhsin/react-menu'; +import { MenuDivider, SubMenu } from '@szhsin/react-menu'; import { memo } from 'preact/compat'; -import { useMemo, useRef } from 'preact/hooks'; +import { useRef, useState } 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 { api } from '../utils/api'; +import { getLists } from '../utils/lists'; import states from '../utils/states'; import AsyncText from './AsyncText'; @@ -34,47 +35,48 @@ function Shortcuts() { const menuRef = useRef(); - const formattedShortcuts = useMemo( - () => - shortcuts - .map((pin, i) => { - const { type, ...data } = pin; - if (!SHORTCUTS_META[type]) return null; - let { id, path, title, subtitle, icon } = SHORTCUTS_META[type]; + const hasLists = useRef(false); + const formattedShortcuts = shortcuts + .map((pin, i) => { + const { type, ...data } = pin; + if (!SHORTCUTS_META[type]) return null; + let { id, path, title, subtitle, icon } = SHORTCUTS_META[type]; - if (typeof id === 'function') { - id = id(data, i); - } - if (typeof path === 'function') { - path = path( - { - ...data, - instance: data.instance || instance, - }, - i, - ); - } - if (typeof title === 'function') { - title = title(data, i); - } - if (typeof subtitle === 'function') { - subtitle = subtitle(data, i); - } - if (typeof icon === 'function') { - icon = icon(data, i); - } + if (typeof id === 'function') { + id = id(data, i); + } + if (typeof path === 'function') { + path = path( + { + ...data, + instance: data.instance || instance, + }, + i, + ); + } + if (typeof title === 'function') { + title = title(data, i); + } + if (typeof subtitle === 'function') { + subtitle = subtitle(data, i); + } + if (typeof icon === 'function') { + icon = icon(data, i); + } - return { - id, - path, - title, - subtitle, - icon, - }; - }) - .filter(Boolean), - [shortcuts], - ); + if (id === 'lists') { + hasLists.current = true; + } + + return { + id, + path, + title, + subtitle, + icon, + }; + }) + .filter(Boolean); const navigate = useNavigate(); useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => { @@ -88,6 +90,8 @@ function Shortcuts() { } }); + const [lists, setLists] = useState([]); + return (
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? ( @@ -147,6 +151,11 @@ function Shortcuts() { menuClassName="glass-menu shortcuts-menu" gap={8} position="anchor" + onMenuChange={(e) => { + if (e.open && hasLists.current) { + getLists().then(setLists); + } + }} menuButton={ + } + > + + All Lists + + {lists?.length > 0 && ( + <> + + {lists.map((list) => ( + + {list.title} + + ))} + + )} + } headerEnd={ { try { - const lists = await masto.v1.lists.list(); - lists.sort((a, b) => a.title.localeCompare(b.title)); + const lists = await fetchLists(); console.log(lists); setLists(lists); setUIState('default'); diff --git a/src/utils/lists.js b/src/utils/lists.js new file mode 100644 index 00000000..1edc000c --- /dev/null +++ b/src/utils/lists.js @@ -0,0 +1,114 @@ +import { api } from './api'; +import pmem from './pmem'; +import store from './store'; + +const FETCH_MAX_AGE = 1000 * 60; // 1 minute +const MAX_AGE = 24 * 60 * 60 * 1000; // 1 day + +export const fetchLists = pmem( + async () => { + const { masto } = api(); + const lists = await masto.v1.lists.list(); + lists.sort((a, b) => a.title.localeCompare(b.title)); + + if (lists.length) { + setTimeout(() => { + // Save to local storage, with saved timestamp + store.account.set('lists', { + lists, + updatedAt: Date.now(), + }); + }, 1); + } + + return lists; + }, + { + maxAge: FETCH_MAX_AGE, + }, +); + +export async function getLists() { + try { + const { lists, updatedAt } = store.account.get('lists') || {}; + if (!lists?.length) return await fetchLists(); + if (Date.now() - updatedAt > MAX_AGE) { + // Stale-while-revalidate + fetchLists(); + return lists; + } + return lists; + } catch (e) { + return []; + } +} + +export const fetchList = pmem( + (id) => { + const { masto } = api(); + return masto.v1.lists.$select(id).fetch(); + }, + { + maxAge: FETCH_MAX_AGE, + }, +); + +export async function getList(id) { + const { lists } = store.account.get('lists') || {}; + console.log({ lists }); + if (lists?.length) { + const theList = lists.find((l) => l.id === id); + if (theList) return theList; + } + try { + return fetchList(id); + } catch (e) { + return null; + } +} + +export async function getListTitle(id) { + const list = await getList(id); + return list?.title || ''; +} + +export function addListStore(list) { + const { lists } = store.account.get('lists') || {}; + if (lists?.length) { + lists.push(list); + lists.sort((a, b) => a.title.localeCompare(b.title)); + store.account.set('lists', { + lists, + updatedAt: Date.now(), + }); + } +} + +export function updateListStore(list) { + const { lists } = store.account.get('lists') || {}; + if (lists?.length) { + const index = lists.findIndex((l) => l.id === list.id); + if (index !== -1) { + lists[index] = list; + lists.sort((a, b) => a.title.localeCompare(b.title)); + store.account.set('lists', { + lists, + updatedAt: Date.now(), + }); + } + } +} + +export function deleteListStore(listID) { + const { lists } = store.account.get('lists') || {}; + if (lists?.length) { + const index = lists.findIndex((l) => l.id === listID); + if (index !== -1) { + lists.splice(index, 1); + store.account.set('lists', { + lists, + updatedAt: Date.now(), + }); + } + } +}