diff --git a/src/app.css b/src/app.css index e0bed0a5..bb360a5c 100644 --- a/src/app.css +++ b/src/app.css @@ -1578,6 +1578,13 @@ body:has(.media-modal-container + .status-deck) .media-post-link { .tag.danger { background-color: var(--red-color); } +.tag.minimal { + margin: 0; + color: var(--text-insignificant-color); + background-color: var(--bg-faded-color); + text-shadow: 0 1px var(--bg-color); + line-height: 1; +} /* MENU POPUP */ diff --git a/src/components/account-block.css b/src/components/account-block.css index 0780f244..14309fb9 100644 --- a/src/components/account-block.css +++ b/src/components/account-block.css @@ -4,6 +4,10 @@ gap: 8px; color: var(--text-color); text-decoration: none; + + .account-block-acct { + display: inline-block; + } } .account-block:hover b { text-decoration: underline; @@ -13,44 +17,54 @@ color: var(--bg-faded-color); } -.account-block .short-desc { - max-height: 1.2em; /* just in case clamping ain't working */ -} -.account-block .short-desc, -.account-block .short-desc > * { - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; -} -.account-block .short-desc > * + * { - display: none; -} -.account-block .short-desc * { - margin: 0; - padding: 0; - color: inherit; - pointer-events: none; -} - .account-block .verified-field { - color: var(--green-color); display: inline-flex; - align-items: center; + align-items: baseline; gap: 2px; -} -.account-block .verified-field .icon { -} -.account-block .verified-field .invisible { - display: none; + + * { + -webkit-box-orient: vertical; + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + text-overflow: ellipsis; + overflow: hidden; + } + + a { + pointer-events: none; + color: color-mix( + in lch, + var(--green-color) 20%, + var(--text-insignificant-color) 80% + ) !important; + } + + .icon { + color: var(--green-color); + transform: translateY(1px); + } + + .invisible { + display: none; + } + .ellipsis:after { + content: '…'; + } } .account-block .account-block-stats { + line-height: 1.25; margin-top: 2px; font-size: 0.9em; color: var(--text-insignificant-color); -} -.account-block .account-block-stats a { - color: inherit; - text-decoration: none; + display: flex; + flex-wrap: wrap; + align-items: center; + column-gap: 4px; + + a { + color: inherit; + text-decoration: none; + } } diff --git a/src/components/account-block.jsx b/src/components/account-block.jsx index 08bbe4fb..dde1b110 100644 --- a/src/components/account-block.jsx +++ b/src/components/account-block.jsx @@ -3,6 +3,7 @@ import './account-block.css'; // import { useNavigate } from 'react-router-dom'; import enhanceContent from '../utils/enhance-content'; import niceDateTime from '../utils/nice-date-time'; +import shortenNumber from '../utils/shorten-number'; import states from '../utils/states'; import Avatar from './avatar'; @@ -22,6 +23,8 @@ function AccountBlock({ showStats = false, accountInstance, hideDisplayName = false, + relationship = {}, + excludeRelationshipAttrs = [], }) { if (skeleton) { return ( @@ -53,6 +56,7 @@ function AccountBlock({ fields, note, group, + followersCount, } = account; let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct]; if (accountInstance) { @@ -61,6 +65,17 @@ function AccountBlock({ const verifiedField = fields?.find((f) => !!f.verifiedAt && !!f.value); + const excludedRelationship = {}; + for (const r in relationship) { + if (!excludeRelationshipAttrs.includes(r)) { + excludedRelationship[r] = relationship[r]; + } + } + const hasRelationship = + excludedRelationship.following || + excludedRelationship.followedBy || + excludedRelationship.requested; + return ( <a class="account-block" @@ -97,9 +112,8 @@ function AccountBlock({ ) : ( <b>{username}</b> )} - <br /> </> - )} + )}{' '} <span class="account-block-acct"> @{acct1} <wbr /> @@ -124,28 +138,44 @@ function AccountBlock({ )} {showStats && ( <div class="account-block-stats"> - <div - class="short-desc" - dangerouslySetInnerHTML={{ - __html: enhanceContent(note, { emojis }), - }} - /> {bot && ( <> - <span class="tag"> + <span class="tag collapsed"> <Icon icon="bot" /> Automated </span> </> )} {!!group && ( <> - <span class="tag"> + <span class="tag collapsed"> <Icon icon="group" /> Group </span> </> )} + {hasRelationship && ( + <div key={relationship.id} class="shazam-container-horizontal"> + <div class="shazam-container-inner"> + {excludedRelationship.following && + excludedRelationship.followedBy ? ( + <span class="tag minimal">Mutual</span> + ) : excludedRelationship.requested ? ( + <span class="tag minimal">Requested</span> + ) : excludedRelationship.following ? ( + <span class="tag minimal">Following</span> + ) : excludedRelationship.followedBy ? ( + <span class="tag minimal">Follows you</span> + ) : null} + </div> + </div> + )} + {!!followersCount && ( + <span class="ib"> + {shortenNumber(followersCount)}{' '} + {followersCount === 1 ? 'follower' : 'followers'} + </span> + )} {!!verifiedField && ( - <span class="verified-field ib"> + <span class="verified-field"> <Icon icon="check-circle" size="s" />{' '} <span dangerouslySetInnerHTML={{ diff --git a/src/components/account-info.css b/src/components/account-info.css index e5c00324..e480878d 100644 --- a/src/components/account-info.css +++ b/src/components/account-info.css @@ -177,6 +177,7 @@ } .account-container .account-block .account-block-acct { + display: block; opacity: 0.7; } diff --git a/src/components/account-info.jsx b/src/components/account-info.jsx index 5112bb48..010a4f13 100644 --- a/src/components/account-info.jsx +++ b/src/components/account-info.jsx @@ -604,6 +604,8 @@ function AccountInfo({ states.showGenericAccounts = { heading: 'Followers', fetchAccounts: fetchFollowers, + instance, + excludeRelationshipAttrs: ['followedBy'], }; }, 0); }} @@ -637,6 +639,8 @@ function AccountInfo({ states.showGenericAccounts = { heading: 'Following', fetchAccounts: fetchFollowing, + instance, + excludeRelationshipAttrs: ['following'], }; }, 0); }} diff --git a/src/components/generic-accounts.css b/src/components/generic-accounts.css index 2cf7e654..ad33900a 100644 --- a/src/components/generic-accounts.css +++ b/src/components/generic-accounts.css @@ -1,5 +1,6 @@ #generic-accounts-container { .accounts-list { + --list-gap: 16px; list-style: none; margin: 0; padding: 8px 0; @@ -7,29 +8,46 @@ flex-wrap: wrap; flex-direction: row; column-gap: 1.5em; - row-gap: 16px; + row-gap: var(--list-gap); li { display: flex; flex-grow: 1; flex-basis: 16em; - align-items: center; + /* align-items: center; */ margin: 0; padding: 0; gap: 8px; + + position: relative; + + &:before { + content: ''; + display: block; + border-top: var(--hairline-width) solid var(--divider-color); + position: absolute; + bottom: calc(-1 * var(--list-gap) / 2); + left: 40px; + right: 0; + } + + &:has(.reactions-block):before { + /* avatar + reactions + gap */ + left: calc(40px + 16px + 8px); + } } .account-block-acct { - font-size: 80%; + font-size: 0.9em; color: var(--text-insignificant-color); - display: block; + /* display: block; */ } } .reactions-block { display: flex; flex-direction: column; - align-self: center; + /* align-self: center; */ .favourite-icon { color: var(--favourite-color); @@ -38,5 +56,21 @@ .reblog-icon { color: var(--reblog-color); } + + > .icon:only-child { + margin-top: 8px; /* half of icon dimension */ + } + } + + .account-relationships { + flex-grow: 1; + + .tag { + animation: appear 0.3s ease-out; + } + } + + .account-block { + align-items: flex-start; } } diff --git a/src/components/generic-accounts.jsx b/src/components/generic-accounts.jsx index 0f38e472..0fdd557e 100644 --- a/src/components/generic-accounts.jsx +++ b/src/components/generic-accounts.jsx @@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from 'preact/hooks'; import { InView } from 'react-intersection-observer'; import { useSnapshot } from 'valtio'; +import { api } from '../utils/api'; +import { fetchRelationships } from '../utils/relationships'; import states from '../utils/states'; import useLocationChange from '../utils/useLocationChange'; @@ -11,8 +13,15 @@ import AccountBlock from './account-block'; import Icon from './icon'; import Loader from './loader'; -export default function GenericAccounts({ onClose = () => {} }) { +export default function GenericAccounts({ + instance, + excludeRelationshipAttrs = [], + onClose = () => {}, +}) { + const { masto, instance: currentInstance } = api(); + const isCurrentInstance = instance ? instance === currentInstance : true; const snapStates = useSnapshot(states); + ``; const [uiState, setUIState] = useState('default'); const [accounts, setAccounts] = useState([]); const [showMore, setShowMore] = useState(false); @@ -31,6 +40,20 @@ export default function GenericAccounts({ onClose = () => {} }) { showReactions, } = snapStates.showGenericAccounts; + const [relationshipsMap, setRelationshipsMap] = useState({}); + + const loadRelationships = async (accounts) => { + if (!accounts?.length) return; + if (!isCurrentInstance) return; + const relationships = await fetchRelationships(accounts, relationshipsMap); + if (relationships) { + setRelationshipsMap({ + ...relationshipsMap, + ...relationships, + }); + } + }; + const loadAccounts = (firstLoad) => { if (!fetchAccounts) return; if (firstLoad) setAccounts([]); @@ -40,11 +63,41 @@ export default function GenericAccounts({ onClose = () => {} }) { const { done, value } = await fetchAccounts(firstLoad); if (Array.isArray(value)) { if (firstLoad) { - setAccounts(value); + const accounts = []; + for (let i = 0; i < value.length; i++) { + const account = value[i]; + const theAccount = accounts.find( + (a, j) => a.id === account.id && i !== j, + ); + if (!theAccount) { + accounts.push({ + _types: [], + ...account, + }); + } else { + theAccount._types.push(...account._types); + } + } + setAccounts(accounts); } else { - setAccounts((prev) => [...prev, ...value]); + // setAccounts((prev) => [...prev, ...value]); + // Merge accounts by id and _types + setAccounts((prev) => { + const newAccounts = prev; + for (const account of value) { + const theAccount = newAccounts.find((a) => a.id === account.id); + if (!theAccount) { + newAccounts.push(account); + } else { + theAccount._types.push(...account._types); + } + } + return newAccounts; + }); } setShowMore(!done); + + loadRelationships(value); } else { setShowMore(false); } @@ -60,6 +113,7 @@ export default function GenericAccounts({ onClose = () => {} }) { useEffect(() => { if (staticAccounts?.length > 0) { setAccounts(staticAccounts); + loadRelationships(staticAccounts); } else { loadAccounts(true); firstLoad.current = false; @@ -87,26 +141,37 @@ export default function GenericAccounts({ onClose = () => {} }) { {accounts.length > 0 ? ( <> <ul class="accounts-list"> - {accounts.map((account) => ( - <li key={account.id + (account._types || '')}> - {showReactions && account._types?.length > 0 && ( - <div class="reactions-block"> - {account._types.map((type) => ( - <Icon - icon={ - { - reblog: 'rocket', - favourite: 'heart', - }[type] - } - class={`${type}-icon`} - /> - ))} + {accounts.map((account) => { + const relationship = relationshipsMap[account.id]; + const key = `${account.id}-${account._types?.length || ''}`; + return ( + <li key={key}> + {showReactions && account._types?.length > 0 && ( + <div class="reactions-block"> + {account._types.map((type) => ( + <Icon + icon={ + { + reblog: 'rocket', + favourite: 'heart', + }[type] + } + class={`${type}-icon`} + /> + ))} + </div> + )} + <div class="account-relationships"> + <AccountBlock + account={account} + showStats + relationship={relationship} + excludeRelationshipAttrs={excludeRelationshipAttrs} + /> </div> - )} - <AccountBlock account={account} /> - </li> - ))} + </li> + ); + })} </ul> {uiState === 'default' ? ( showMore ? ( diff --git a/src/components/modals.jsx b/src/components/modals.jsx index 0d35a381..c4ae37c8 100644 --- a/src/components/modals.jsx +++ b/src/components/modals.jsx @@ -176,6 +176,10 @@ export default function Modals() { }} > <GenericAccounts + instance={snapStates.showGenericAccounts.instance} + excludeRelationshipAttrs={ + snapStates.showGenericAccounts.excludeRelationshipAttrs + } onClose={() => (states.showGenericAccounts = false)} /> </Modal> diff --git a/src/components/nav-menu.jsx b/src/components/nav-menu.jsx index 96b9ce46..df609958 100644 --- a/src/components/nav-menu.jsx +++ b/src/components/nav-menu.jsx @@ -233,6 +233,7 @@ function NavMenu(props) { id: 'mute', heading: 'Muted users', fetchAccounts: fetchMutes, + excludeRelationshipAttrs: ['muting'], }; }} > @@ -244,6 +245,7 @@ function NavMenu(props) { id: 'block', heading: 'Blocked users', fetchAccounts: fetchBlocks, + excludeRelationshipAttrs: ['blocking'], }; }} > diff --git a/src/components/notification.jsx b/src/components/notification.jsx index a4120ace..e5722061 100644 --- a/src/components/notification.jsx +++ b/src/components/notification.jsx @@ -158,6 +158,7 @@ function Notification({ heading: genericAccountsHeading, accounts: _accounts, showReactions: type === 'favourite+reblog', + excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [], }; }; diff --git a/src/components/status.jsx b/src/components/status.jsx index dc700eef..b1e609f9 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -88,6 +88,8 @@ const isIOS = window.ontouchstart !== undefined && /iPad|iPhone|iPod/.test(navigator.userAgent); +const REACTIONS_LIMIT = 80; + function Status({ statusID, status, @@ -380,7 +382,6 @@ function Status({ ]); const [showEdited, setShowEdited] = useState(false); - const [showReactions, setShowReactions] = useState(false); const spoilerContentRef = useTruncated(); const contentRef = useTruncated(); @@ -560,6 +561,55 @@ function Status({ (l) => language === l || localeMatch([language], [l]), ); + const reblogIterator = useRef(); + const favouriteIterator = useRef(); + async function fetchBoostedLikedByAccounts(firstLoad) { + if (firstLoad) { + reblogIterator.current = masto.v1.statuses + .$select(statusID) + .rebloggedBy.list({ + limit: REACTIONS_LIMIT, + }); + favouriteIterator.current = masto.v1.statuses + .$select(statusID) + .favouritedBy.list({ + limit: REACTIONS_LIMIT, + }); + } + const [{ value: reblogResults }, { value: favouriteResults }] = + await Promise.allSettled([ + reblogIterator.current.next(), + favouriteIterator.current.next(), + ]); + if (reblogResults.value?.length || favouriteResults.value?.length) { + const accounts = []; + if (reblogResults.value?.length) { + accounts.push( + ...reblogResults.value.map((a) => { + a._types = ['reblog']; + return a; + }), + ); + } + if (favouriteResults.value?.length) { + accounts.push( + ...favouriteResults.value.map((a) => { + a._types = ['favourite']; + return a; + }), + ); + } + return { + value: accounts, + done: reblogResults.done && favouriteResults.done, + }; + } + return { + value: [], + done: true, + }; + } + const menuInstanceRef = useRef(); const StatusMenuItems = ( <> @@ -620,7 +670,16 @@ function Status({ )} {(!isSizeLarge || !!editedAt) && <MenuDivider />} {isSizeLarge && ( - <MenuItem onClick={() => setShowReactions(true)}> + <MenuItem + onClick={() => { + states.showGenericAccounts = { + heading: 'Boosted/Liked by…', + fetchAccounts: fetchBoostedLikedByAccounts, + instance, + showReactions: true, + }; + }} + > <Icon icon="react" /> <span> Boosted/Liked by<span class="more-insignificant">…</span> @@ -1759,22 +1818,6 @@ function Status({ /> </Modal> )} - {showReactions && ( - <Modal - class="light" - onClick={(e) => { - if (e.target === e.currentTarget) { - setShowReactions(false); - } - }} - > - <ReactionsModal - statusID={id} - instance={instance} - onClose={() => setShowReactions(false)} - /> - </Modal> - )} </article> ); } @@ -2046,160 +2089,6 @@ function EditedAtModal({ ); } -const REACTIONS_LIMIT = 80; -function ReactionsModal({ statusID, instance, onClose }) { - const { masto } = api({ instance }); - const [uiState, setUIState] = useState('default'); - const [accounts, setAccounts] = useState([]); - const [showMore, setShowMore] = useState(false); - - const reblogIterator = useRef(); - const favouriteIterator = useRef(); - - async function fetchAccounts(firstLoad) { - setShowMore(false); - setUIState('loading'); - (async () => { - try { - if (firstLoad) { - reblogIterator.current = masto.v1.statuses - .$select(statusID) - .rebloggedBy.list({ - limit: REACTIONS_LIMIT, - }); - favouriteIterator.current = masto.v1.statuses - .$select(statusID) - .favouritedBy.list({ - limit: REACTIONS_LIMIT, - }); - } - const [{ value: reblogResults }, { value: favouriteResults }] = - await Promise.allSettled([ - reblogIterator.current.next(), - favouriteIterator.current.next(), - ]); - if (reblogResults.value?.length || favouriteResults.value?.length) { - if (reblogResults.value?.length) { - for (const account of reblogResults.value) { - const theAccount = accounts.find((a) => a.id === account.id); - if (!theAccount) { - accounts.push({ - ...account, - _types: ['reblog'], - }); - } else { - theAccount._types.push('reblog'); - } - } - } - if (favouriteResults.value?.length) { - for (const account of favouriteResults.value) { - const theAccount = accounts.find((a) => a.id === account.id); - if (!theAccount) { - accounts.push({ - ...account, - _types: ['favourite'], - }); - } else { - theAccount._types.push('favourite'); - } - } - } - setAccounts(accounts); - setShowMore(!reblogResults.done || !favouriteResults.done); - } else { - setShowMore(false); - } - setUIState('default'); - } catch (e) { - console.error(e); - setUIState('error'); - } - })(); - } - - useEffect(() => { - fetchAccounts(true); - }, []); - - return ( - <div id="reactions-container" class="sheet"> - {!!onClose && ( - <button type="button" class="sheet-close" onClick={onClose}> - <Icon icon="x" /> - </button> - )} - <header> - <h2>Boosted/Liked by…</h2> - </header> - <main> - {accounts.length > 0 ? ( - <> - <ul class="reactions-list"> - {accounts.map((account) => { - const { _types } = account; - return ( - <li key={account.id + _types}> - <div class="reactions-block"> - {_types.map((type) => ( - <Icon - icon={ - { - reblog: 'rocket', - favourite: 'heart', - }[type] - } - class={`${type}-icon`} - /> - ))} - </div> - <AccountBlock account={account} instance={instance} /> - </li> - ); - })} - </ul> - {uiState === 'default' ? ( - showMore ? ( - <InView - onChange={(inView) => { - if (inView) { - fetchAccounts(); - } - }} - > - <button - type="button" - class="plain block" - onClick={() => fetchAccounts()} - > - Show more… - </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">Unable to load accounts</p> - ) : ( - <p class="ui-state insignificant">No one yet.</p> - )} - </main> - </div> - ); -} - function StatusButton({ checked, count, diff --git a/src/pages/search.css b/src/pages/search.css index a1b830a8..e05a7c94 100644 --- a/src/pages/search.css +++ b/src/pages/search.css @@ -24,8 +24,12 @@ display: flex; padding: 8px 16px; gap: 8px; - align-items: center; + /* align-items: center; */ flex-grow: 1; + + .account-block { + align-items: flex-start; + } } ul.link-list.hashtag-list { diff --git a/src/pages/search.jsx b/src/pages/search.jsx index e8cc9da5..1ff2a0c5 100644 --- a/src/pages/search.jsx +++ b/src/pages/search.jsx @@ -14,6 +14,7 @@ import NavMenu from '../components/nav-menu'; import SearchForm from '../components/search-form'; import Status from '../components/status'; import { api } from '../utils/api'; +import { fetchRelationships } from '../utils/relationships'; import shortenNumber from '../utils/shorten-number'; import useTitle from '../utils/useTitle'; @@ -72,6 +73,18 @@ function Search(props) { hashtags: setHashtagResults, }; + const [relationshipsMap, setRelationshipsMap] = useState({}); + const loadRelationships = async (accounts) => { + if (!accounts?.length) return; + const relationships = await fetchRelationships(accounts, relationshipsMap); + if (relationships) { + setRelationshipsMap({ + ...relationshipsMap, + ...relationships, + }); + } + }; + function loadResults(firstLoad) { if (!firstLoad && !authenticated) { // Search results pagination is only available to authenticated users @@ -119,6 +132,8 @@ function Search(props) { offsetRef.current = 0; setShowMore(false); } + loadRelationships(results.accounts); + setUIState('default'); } catch (err) { console.error(err); @@ -216,6 +231,7 @@ function Search(props) { account={account} instance={instance} showStats + relationship={relationshipsMap[account.id]} /> </li> ))} diff --git a/src/utils/relationships.js b/src/utils/relationships.js new file mode 100644 index 00000000..da0ba266 --- /dev/null +++ b/src/utils/relationships.js @@ -0,0 +1,37 @@ +import { api } from './api'; +import store from './store'; + +export async function fetchRelationships(accounts, relationshipsMap = {}) { + if (!accounts?.length) return; + const { masto } = api(); + + const currentAccount = store.session.get('currentAccount'); + const uniqueAccountIds = accounts.reduce((acc, a) => { + // 1. Ignore duplicate accounts + // 2. Ignore accounts that are already inside relationshipsMap + // 3. Ignore currently logged in account + if ( + !acc.includes(a.id) && + !relationshipsMap[a.id] && + a.id !== currentAccount + ) { + acc.push(a.id); + } + return acc; + }, []); + + try { + const relationships = await masto.v1.accounts.relationships.fetch({ + id: uniqueAccountIds, + }); + const newRelationshipsMap = relationships.reduce((acc, r) => { + acc[r.id] = r; + return acc; + }, {}); + return newRelationshipsMap; + } catch (e) { + console.error(e); + // It's okay to fail + return null; + } +}