Add Generic Accounts modal

Also refactored whole bunch of stuff
This commit is contained in:
Lim Chee Aun 2023-09-12 11:27:54 +08:00
parent dd2ca7bf35
commit b57d8adf18
10 changed files with 506 additions and 194 deletions

View file

@ -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' && (
<Shortcuts />
)}
{!!snapStates.showCompose && (
<Modal>
<Compose
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
: window.__COMPOSE__?.replyToStatus || null
}
editStatus={
states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
}
onClose={(results) => {
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}`,
);
},
});
}
}}
/>
</Modal>
)}
{!!snapStates.showSettings && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showSettings = false;
}
}}
>
<Settings
onClose={() => {
states.showSettings = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccounts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showAccounts = false;
}
}}
>
<Accounts
onClose={() => {
states.showAccounts = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccount && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showAccount = false;
}
}}
>
<AccountSheet
account={snapStates.showAccount?.account || snapStates.showAccount}
instance={snapStates.showAccount?.instance}
onClose={({ destination } = {}) => {
states.showAccount = false;
if (destination) {
states.showAccounts = false;
}
}}
/>
</Modal>
)}
{!!snapStates.showDrafts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showDrafts = false;
}
}}
>
<Drafts onClose={() => (states.showDrafts = false)} />
</Modal>
)}
{!!snapStates.showMediaModal && (
<Modal
onClick={(e) => {
if (
e.target === e.currentTarget ||
e.target.classList.contains('media')
) {
states.showMediaModal = false;
}
}}
>
<MediaModal
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
instance={snapStates.showMediaModal.instance}
index={snapStates.showMediaModal.index}
statusID={snapStates.showMediaModal.statusID}
onClose={() => {
states.showMediaModal = false;
}}
/>
</Modal>
)}
{!!snapStates.showShortcutsSettings && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showShortcutsSettings = false;
}
}}
>
<ShortcutsSettings
onClose={() => (states.showShortcutsSettings = false)}
/>
</Modal>
)}
<Modals />
<NotificationService />
<BackgroundService isLoggedIn={isLoggedIn} />
<SearchCommand onClose={focusDeck} />

View file

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

View file

@ -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 (
<div
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
@ -312,13 +368,30 @@ function AccountInfo({
</div>
)}
<p class="stats">
<div>
<div
tabIndex={0}
onClick={() => {
states.showGenericAccounts = {
heading: 'Followers',
fetchAccounts: fetchFollowers,
};
}}
>
<span title={followersCount}>
{shortenNumber(followersCount)}
</span>{' '}
Followers
</div>
<div class="insignificant">
<div
class="insignificant"
tabIndex={0}
onClick={() => {
states.showGenericAccounts = {
heading: 'Following',
fetchAccounts: fetchFollowing,
};
}}
>
<span title={followingCount}>
{shortenNumber(followingCount)}
</span>{' '}

View file

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

View file

@ -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 (
<div id="generic-accounts-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<h2>{heading || 'Accounts'}</h2>
</header>
<main>
{accounts.length > 0 ? (
<>
<ul class="accounts-list">
{accounts.map((account) => (
<li key={account.id}>
{showReactions && account._types?.length > 0 && (
<div class="reactions-block">
{account._types.map((type) => (
<Icon
icon={
{
reblog: 'rocket',
favourite: 'heart',
}[type]
}
class={`${type}-icon`}
/>
))}
</div>
)}
<AccountBlock account={account} />
</li>
))}
</ul>
{uiState === 'default' ? (
showMore ? (
<InView
onChange={(inView) => {
if (inView) {
loadAccounts();
}
}}
>
<button
type="button"
class="plain block"
onClick={() => loadAccounts()}
>
Show more&hellip;
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
)
) : (
uiState === 'loading' && (
<p class="ui-state">
<Loader abrupt />
</p>
)
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Error loading accounts</p>
) : (
<p class="ui-state insignificant">Nothing to show</p>
)}
</main>
</div>
);
}

View file

@ -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',

190
src/components/modals.jsx Normal file
View file

@ -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 && (
<Modal>
<Compose
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
: window.__COMPOSE__?.replyToStatus || null
}
editStatus={
states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
}
onClose={(results) => {
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}`;
},
});
}
}}
/>
</Modal>
)}
{!!snapStates.showSettings && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showSettings = false;
}
}}
>
<Settings
onClose={() => {
states.showSettings = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccounts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showAccounts = false;
}
}}
>
<Accounts
onClose={() => {
states.showAccounts = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccount && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showAccount = false;
}
}}
>
<AccountSheet
account={snapStates.showAccount?.account || snapStates.showAccount}
instance={snapStates.showAccount?.instance}
onClose={({ destination } = {}) => {
states.showAccount = false;
if (destination) {
states.showAccounts = false;
}
}}
/>
</Modal>
)}
{!!snapStates.showDrafts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showDrafts = false;
}
}}
>
<Drafts onClose={() => (states.showDrafts = false)} />
</Modal>
)}
{!!snapStates.showMediaModal && (
<Modal
onClick={(e) => {
if (
e.target === e.currentTarget ||
e.target.classList.contains('media')
) {
states.showMediaModal = false;
}
}}
>
<MediaModal
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
instance={snapStates.showMediaModal.instance}
index={snapStates.showMediaModal.index}
statusID={snapStates.showMediaModal.statusID}
onClose={() => {
states.showMediaModal = false;
}}
/>
</Modal>
)}
{!!snapStates.showShortcutsSettings && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showShortcutsSettings = false;
}
}}
>
<ShortcutsSettings
onClose={() => (states.showShortcutsSettings = false)}
/>
</Modal>
)}
{!!snapStates.showGenericAccounts && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showGenericAccounts = false;
}
}}
>
<GenericAccounts
onClose={() => (states.showGenericAccounts = false)}
/>
</Modal>
)}
</>
);
}

View file

@ -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 (
<div class={`notification notification-${type}`} tabIndex="0">
<div
@ -153,7 +168,9 @@ function Notification({ notification, instance, reload, isStatic }) {
<>
{_accounts?.length > 1 ? (
<>
<b>{_accounts.length} people</b>{' '}
<b tabIndex="0" onClick={handleOpenGenericAccounts}>
{_accounts.length} people
</b>{' '}
</>
) : (
<>
@ -228,6 +245,13 @@ function Notification({ notification, instance, reload, isStatic }) {
</a>{' '}
</>
))}
<button
type="button"
class="small plain"
onClick={handleOpenGenericAccounts}
>
<Icon icon="chevron-down" />
</button>
</p>
)}
{_statuses?.length > 1 && (

View file

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

22
src/utils/focus-deck.jsx Normal file
View file

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