From b57d8adf18b019b210bf3834a503e20ee9720595 Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Tue, 12 Sep 2023 11:27:54 +0800 Subject: [PATCH] Add Generic Accounts modal Also refactored whole bunch of stuff --- src/app.jsx | 196 +--------------------------- src/components/account-info.css | 6 + src/components/account-info.jsx | 77 ++++++++++- src/components/generic-accounts.css | 42 ++++++ src/components/generic-accounts.jsx | 135 +++++++++++++++++++ src/components/icon.jsx | 1 + src/components/modals.jsx | 190 +++++++++++++++++++++++++++ src/components/notification.jsx | 26 +++- src/pages/notifications.css | 5 + src/utils/focus-deck.jsx | 22 ++++ 10 files changed, 506 insertions(+), 194 deletions(-) create mode 100644 src/components/generic-accounts.css create mode 100644 src/components/generic-accounts.jsx create mode 100644 src/components/modals.jsx create mode 100644 src/utils/focus-deck.jsx diff --git a/src/app.jsx b/src/app.jsx index 16109f4c..c6923e39 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -7,33 +7,21 @@ import { useRef, useState, } from 'preact/hooks'; -import { - matchPath, - Route, - Routes, - useLocation, - useNavigate, -} from 'react-router-dom'; +import { matchPath, Route, Routes, useLocation } from 'react-router-dom'; import 'swiped-events'; import { useSnapshot } from 'valtio'; -import AccountSheet from './components/account-sheet'; import BackgroundService from './components/background-service'; -import Compose from './components/compose'; import ComposeButton from './components/compose-button'; -import Drafts from './components/drafts'; import { ICONS } from './components/icon'; import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help'; import Loader from './components/loader'; -import MediaModal from './components/media-modal'; -import Modal from './components/modal'; +import Modals from './components/modals'; import NotificationService from './components/notification-service'; import SearchCommand from './components/search-command'; import Shortcuts from './components/shortcuts'; -import ShortcutsSettings from './components/shortcuts-settings'; import NotFound from './pages/404'; import AccountStatuses from './pages/account-statuses'; -import Accounts from './pages/accounts'; import Bookmarks from './pages/bookmarks'; import Favourites from './pages/favourites'; import FollowedHashtags from './pages/followed-hashtags'; @@ -48,7 +36,6 @@ import Mentions from './pages/mentions'; import Notifications from './pages/notifications'; import Public from './pages/public'; import Search from './pages/search'; -import Settings from './pages/settings'; import StatusRoute from './pages/status-route'; import Trending from './pages/trending'; import Welcome from './pages/welcome'; @@ -60,7 +47,7 @@ import { initPreferences, } from './utils/api'; import { getAccessToken } from './utils/auth'; -import showToast from './utils/show-toast'; +import focusDeck from './utils/focus-deck'; import states, { initStates } from './utils/states'; import store from './utils/store'; import { getCurrentAccount } from './utils/store-utils'; @@ -85,7 +72,6 @@ function App() { const snapStates = useSnapshot(states); const [isLoggedIn, setIsLoggedIn] = useState(false); const [uiState, setUIState] = useState('loading'); - const navigate = useNavigate(); useLayoutEffect(() => { const theme = store.local.get('theme'); @@ -165,41 +151,9 @@ function App() { let location = useLocation(); states.currentLocation = location.pathname; - const focusDeck = () => { - let timer = setTimeout(() => { - const columns = document.getElementById('columns'); - if (columns) { - // Focus first column - // columns.querySelector('.deck-container')?.focus?.(); - } else { - const backDrop = document.querySelector('.deck-backdrop'); - if (backDrop) return; - // Focus last deck - const pages = document.querySelectorAll('.deck-container'); - const page = pages[pages.length - 1]; // last one - if (page && page.tabIndex === -1) { - console.log('FOCUS', page); - page.focus(); - } - } - }, 100); - return () => clearTimeout(timer); - }; useEffect(focusDeck, [location, isLoggedIn]); - const showModal = - snapStates.showCompose || - snapStates.showSettings || - snapStates.showAccounts || - snapStates.showAccount || - snapStates.showDrafts || - snapStates.showMediaModal || - snapStates.showShortcutsSettings || - snapStates.showKeyboardShortcutsHelp; - useEffect(() => { - if (!showModal) focusDeck(); - }, [showModal]); - const { prevLocation } = snapStates; + const prevLocation = snapStates.prevLocation; const backgroundLocation = useRef(prevLocation || null); const isModalPage = useMemo(() => { return ( @@ -294,147 +248,7 @@ function App() { snapStates.settings.shortcutsViewMode !== 'multi-column' && ( )} - {!!snapStates.showCompose && ( - - { - const { newStatus, instance } = results || {}; - states.showCompose = false; - window.__COMPOSE__ = null; - if (newStatus) { - states.reloadStatusPage++; - showToast({ - text: 'Post published. Check it out.', - delay: 1000, - duration: 10_000, // 10 seconds - onClick: (toast) => { - toast.hideToast(); - states.prevLocation = location; - navigate( - instance - ? `/${instance}/s/${newStatus.id}` - : `/s/${newStatus.id}`, - ); - }, - }); - } - }} - /> - - )} - {!!snapStates.showSettings && ( - { - if (e.target === e.currentTarget) { - states.showSettings = false; - } - }} - > - { - states.showSettings = false; - }} - /> - - )} - {!!snapStates.showAccounts && ( - { - if (e.target === e.currentTarget) { - states.showAccounts = false; - } - }} - > - { - states.showAccounts = false; - }} - /> - - )} - {!!snapStates.showAccount && ( - { - if (e.target === e.currentTarget) { - states.showAccount = false; - } - }} - > - { - states.showAccount = false; - if (destination) { - states.showAccounts = false; - } - }} - /> - - )} - {!!snapStates.showDrafts && ( - { - if (e.target === e.currentTarget) { - states.showDrafts = false; - } - }} - > - (states.showDrafts = false)} /> - - )} - {!!snapStates.showMediaModal && ( - { - if ( - e.target === e.currentTarget || - e.target.classList.contains('media') - ) { - states.showMediaModal = false; - } - }} - > - { - states.showMediaModal = false; - }} - /> - - )} - {!!snapStates.showShortcutsSettings && ( - { - if (e.target === e.currentTarget) { - states.showShortcutsSettings = false; - } - }} - > - (states.showShortcutsSettings = false)} - /> - - )} + diff --git a/src/components/account-info.css b/src/components/account-info.css index b6e3451f..837966b8 100644 --- a/src/components/account-info.css +++ b/src/components/account-info.css @@ -148,6 +148,12 @@ overflow-x: auto; justify-content: flex-start; position: relative; + + [tabindex='0']:is(:hover, :focus) { + color: var(--text-color); + cursor: pointer; + text-decoration: underline; + } } .timeline-start .account-container .stats { flex-wrap: wrap; diff --git a/src/components/account-info.jsx b/src/components/account-info.jsx index 24d3c39a..1c761db3 100644 --- a/src/components/account-info.jsx +++ b/src/components/account-info.jsx @@ -46,6 +46,8 @@ const MUTE_DURATIONS_LABELS = { 604_800_000: '1 week', }; +const LIMIT = 80; + function AccountInfo({ account, fetchAccount = () => {}, @@ -53,6 +55,7 @@ function AccountInfo({ instance, authenticated, }) { + const { masto } = api(); const [uiState, setUIState] = useState('default'); const isString = typeof account === 'string'; const [info, setInfo] = useState(isString ? null : account); @@ -114,6 +117,59 @@ function AccountInfo({ const [headerCornerColors, setHeaderCornerColors] = useState([]); + const followersIterator = useRef(); + const familiarFollowersCache = useRef([]); + async function fetchFollowers(firstLoad) { + if (firstLoad || !followersIterator.current) { + followersIterator.current = masto.v1.accounts.listFollowers(id, { + limit: LIMIT, + }); + } + const results = await followersIterator.current.next(); + const { value } = results; + let newValue = []; + // On first load, fetch familiar followers, merge to top of results' `value` + // Remove dups on every fetch + if (firstLoad) { + const familiarFollowers = await masto.v1.accounts.fetchFamiliarFollowers( + id, + ); + familiarFollowersCache.current = familiarFollowers[0].accounts; + newValue = [ + ...familiarFollowersCache.current, + ...value.filter( + (account) => + !familiarFollowersCache.current.some( + (familiar) => familiar.id === account.id, + ), + ), + ]; + } else { + newValue = value.filter( + (account) => + !familiarFollowersCache.current.some( + (familiar) => familiar.id === account.id, + ), + ); + } + + return { + ...results, + value: newValue, + }; + } + + const followingIterator = useRef(); + async function fetchFollowing(firstLoad) { + if (firstLoad || !followingIterator.current) { + followingIterator.current = masto.v1.accounts.listFollowing(id, { + limit: LIMIT, + }); + } + const results = await followingIterator.current.next(); + return results; + } + return (
)}

-

+
{ + states.showGenericAccounts = { + heading: 'Followers', + fetchAccounts: fetchFollowers, + }; + }} + > {shortenNumber(followersCount)} {' '} Followers
-
+
{ + states.showGenericAccounts = { + heading: 'Following', + fetchAccounts: fetchFollowing, + }; + }} + > {shortenNumber(followingCount)} {' '} diff --git a/src/components/generic-accounts.css b/src/components/generic-accounts.css new file mode 100644 index 00000000..2cf7e654 --- /dev/null +++ b/src/components/generic-accounts.css @@ -0,0 +1,42 @@ +#generic-accounts-container { + .accounts-list { + list-style: none; + margin: 0; + padding: 8px 0; + display: flex; + flex-wrap: wrap; + flex-direction: row; + column-gap: 1.5em; + row-gap: 16px; + + li { + display: flex; + flex-grow: 1; + flex-basis: 16em; + align-items: center; + margin: 0; + padding: 0; + gap: 8px; + } + + .account-block-acct { + font-size: 80%; + color: var(--text-insignificant-color); + display: block; + } + } + + .reactions-block { + display: flex; + flex-direction: column; + align-self: center; + + .favourite-icon { + color: var(--favourite-color); + } + + .reblog-icon { + color: var(--reblog-color); + } + } +} diff --git a/src/components/generic-accounts.jsx b/src/components/generic-accounts.jsx new file mode 100644 index 00000000..e206f780 --- /dev/null +++ b/src/components/generic-accounts.jsx @@ -0,0 +1,135 @@ +import './generic-accounts.css'; + +import { useEffect, useState } from 'preact/hooks'; +import { InView } from 'react-intersection-observer'; +import { useSnapshot } from 'valtio'; + +import states from '../utils/states'; + +import AccountBlock from './account-block'; +import Icon from './icon'; +import Loader from './loader'; + +export default function GenericAccounts({ onClose = () => {} }) { + const snapStates = useSnapshot(states); + const [uiState, setUIState] = useState('default'); + const [accounts, setAccounts] = useState([]); + const [showMore, setShowMore] = useState(false); + + if (!snapStates.showGenericAccounts) { + return null; + } + + const { + heading, + fetchAccounts, + accounts: staticAccounts, + showReactions, + } = snapStates.showGenericAccounts; + + const loadAccounts = (firstLoad) => { + if (!fetchAccounts) return; + setUIState('loading'); + (async () => { + try { + const { done, value } = await fetchAccounts(firstLoad); + if (Array.isArray(value)) { + if (firstLoad) { + setAccounts(value); + } else { + setAccounts((prev) => [...prev, ...value]); + } + setShowMore(!done); + } else { + setShowMore(false); + } + setUIState('default'); + } catch (e) { + console.error(e); + setUIState('error'); + } + })(); + }; + + useEffect(() => { + if (staticAccounts?.length > 0) { + setAccounts(staticAccounts); + } else { + loadAccounts(true); + } + }, [staticAccounts]); + + return ( +
+ +
+

{heading || 'Accounts'}

+
+
+ {accounts.length > 0 ? ( + <> +
    + {accounts.map((account) => ( +
  • + {showReactions && account._types?.length > 0 && ( +
    + {account._types.map((type) => ( + + ))} +
    + )} + +
  • + ))} +
+ {uiState === 'default' ? ( + showMore ? ( + { + if (inView) { + loadAccounts(); + } + }} + > + + + ) : ( +

The end.

+ ) + ) : ( + uiState === 'loading' && ( +

+ +

+ ) + )} + + ) : uiState === 'loading' ? ( +

+ +

+ ) : uiState === 'error' ? ( +

Error loading accounts

+ ) : ( +

Nothing to show

+ )} +
+
+ ); +} diff --git a/src/components/icon.jsx b/src/components/icon.jsx index f26091cd..64db6829 100644 --- a/src/components/icon.jsx +++ b/src/components/icon.jsx @@ -45,6 +45,7 @@ export const ICONS = { plus: () => import('@iconify-icons/mingcute/add-circle-line'), 'chevron-left': () => import('@iconify-icons/mingcute/left-line'), 'chevron-right': () => import('@iconify-icons/mingcute/right-line'), + 'chevron-down': () => import('@iconify-icons/mingcute/down-line'), reply: [ () => import('@iconify-icons/mingcute/share-forward-line'), '180deg', diff --git a/src/components/modals.jsx b/src/components/modals.jsx new file mode 100644 index 00000000..fae68fd8 --- /dev/null +++ b/src/components/modals.jsx @@ -0,0 +1,190 @@ +import { subscribe, useSnapshot } from 'valtio'; + +import Accounts from '../pages/accounts'; +import Settings from '../pages/settings'; +import focusDeck from '../utils/focus-deck'; +import showToast from '../utils/show-toast'; +import states from '../utils/states'; + +import AccountSheet from './account-sheet'; +import Compose from './compose'; +import Drafts from './drafts'; +import GenericAccounts from './generic-accounts'; +import MediaModal from './media-modal'; +import Modal from './modal'; +import ShortcutsSettings from './shortcuts-settings'; + +subscribe(states, (changes) => { + for (const [action, path, value, prevValue] of changes) { + // When closing modal, focus on deck + if (/^show/i.test(path) && !value) { + focusDeck(); + } + } +}); + +export default function Modals() { + const snapStates = useSnapshot(states); + return ( + <> + {!!snapStates.showCompose && ( + + { + const { newStatus, instance } = results || {}; + states.showCompose = false; + window.__COMPOSE__ = null; + if (newStatus) { + states.reloadStatusPage++; + showToast({ + text: 'Post published. Check it out.', + delay: 1000, + duration: 10_000, // 10 seconds + onClick: (toast) => { + toast.hideToast(); + states.prevLocation = location; + // navigate( + // instance + // ? `/${instance}/s/${newStatus.id}` + // : `/s/${newStatus.id}`, + // ); + location.hash = instance + ? `/${instance}/s/${newStatus.id}` + : `/s/${newStatus.id}`; + }, + }); + } + }} + /> + + )} + {!!snapStates.showSettings && ( + { + if (e.target === e.currentTarget) { + states.showSettings = false; + } + }} + > + { + states.showSettings = false; + }} + /> + + )} + {!!snapStates.showAccounts && ( + { + if (e.target === e.currentTarget) { + states.showAccounts = false; + } + }} + > + { + states.showAccounts = false; + }} + /> + + )} + {!!snapStates.showAccount && ( + { + if (e.target === e.currentTarget) { + states.showAccount = false; + } + }} + > + { + states.showAccount = false; + if (destination) { + states.showAccounts = false; + } + }} + /> + + )} + {!!snapStates.showDrafts && ( + { + if (e.target === e.currentTarget) { + states.showDrafts = false; + } + }} + > + (states.showDrafts = false)} /> + + )} + {!!snapStates.showMediaModal && ( + { + if ( + e.target === e.currentTarget || + e.target.classList.contains('media') + ) { + states.showMediaModal = false; + } + }} + > + { + states.showMediaModal = false; + }} + /> + + )} + {!!snapStates.showShortcutsSettings && ( + { + if (e.target === e.currentTarget) { + states.showShortcutsSettings = false; + } + }} + > + (states.showShortcutsSettings = false)} + /> + + )} + {!!snapStates.showGenericAccounts && ( + { + if (e.target === e.currentTarget) { + states.showGenericAccounts = false; + } + }} + > + (states.showGenericAccounts = false)} + /> + + )} + + ); +} diff --git a/src/components/notification.jsx b/src/components/notification.jsx index 0ed2d85a..5392eae6 100644 --- a/src/components/notification.jsx +++ b/src/components/notification.jsx @@ -126,6 +126,21 @@ function Notification({ notification, instance, reload, isStatic }) { const formattedCreatedAt = notification.createdAt && new Date(notification.createdAt).toLocaleString(); + const genericAccountsHeading = + { + 'favourite+reblog': 'Boosted/Favourited by…', + favourite: 'Favourited by…', + reblog: 'Boosted by…', + follow: 'Followed by…', + }[type] || 'Accounts'; + const handleOpenGenericAccounts = () => { + states.showGenericAccounts = { + heading: genericAccountsHeading, + accounts: _accounts, + showReactions: type === 'favourite+reblog', + }; + }; + return (
{_accounts?.length > 1 ? ( <> - {_accounts.length} people{' '} + + {_accounts.length} people + {' '} ) : ( <> @@ -228,6 +245,13 @@ function Notification({ notification, instance, reload, isStatic }) { {' '} ))} +

)} {_statuses?.length > 1 && ( diff --git a/src/pages/notifications.css b/src/pages/notifications.css index 9eb28951..9a91833c 100644 --- a/src/pages/notifications.css +++ b/src/pages/notifications.css @@ -4,6 +4,11 @@ gap: 12px; animation: appear 0.2s ease-out; clear: both; + + b[tabindex='0']:is(:hover, :focus) { + text-decoration: underline; + cursor: pointer; + } } .notification.notification-mention { margin-top: 16px; diff --git a/src/utils/focus-deck.jsx b/src/utils/focus-deck.jsx new file mode 100644 index 00000000..3f5c3fc0 --- /dev/null +++ b/src/utils/focus-deck.jsx @@ -0,0 +1,22 @@ +const focusDeck = () => { + let timer = setTimeout(() => { + const columns = document.getElementById('columns'); + if (columns) { + // Focus first column + // columns.querySelector('.deck-container')?.focus?.(); + } else { + const backDrop = document.querySelector('.deck-backdrop'); + if (backDrop) return; + // Focus last deck + const pages = document.querySelectorAll('.deck-container'); + const page = pages[pages.length - 1]; // last one + if (page && page.tabIndex === -1) { + console.log('FOCUS', page); + page.focus(); + } + } + }, 100); + return () => clearTimeout(timer); +}; + +export default focusDeck;