import './account-info.css'; import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState, } from 'preact/hooks'; import { api } from '../utils/api'; import enhanceContent from '../utils/enhance-content'; import getHTMLText from '../utils/getHTMLText'; import handleContentLinks from '../utils/handle-content-links'; import niceDateTime from '../utils/nice-date-time'; import pmem from '../utils/pmem'; import shortenNumber from '../utils/shorten-number'; import showToast from '../utils/show-toast'; import states, { hideAllModals } from '../utils/states'; import store from '../utils/store'; import { updateAccount } from '../utils/store-utils'; import AccountBlock from './account-block'; import Avatar from './avatar'; import EmojiText from './emoji-text'; import Icon from './icon'; import Link from './link'; import ListAddEdit from './list-add-edit'; import Loader from './loader'; import MenuConfirm from './menu-confirm'; import Modal from './modal'; import TranslationBlock from './translation-block'; const MUTE_DURATIONS = [ 1000 * 60 * 5, // 5 minutes 1000 * 60 * 30, // 30 minutes 1000 * 60 * 60, // 1 hour 1000 * 60 * 60 * 6, // 6 hours 1000 * 60 * 60 * 24, // 1 day 1000 * 60 * 60 * 24 * 3, // 3 days 1000 * 60 * 60 * 24 * 7, // 1 week 0, // forever ]; const MUTE_DURATIONS_LABELS = { 0: 'Forever', 300_000: '5 minutes', 1_800_000: '30 minutes', 3_600_000: '1 hour', 21_600_000: '6 hours', 86_400_000: '1 day', 259_200_000: '3 days', 604_800_000: '1 week', }; const LIMIT = 80; const ACCOUNT_INFO_MAX_AGE = 1000 * 60 * 10; // 10 mins function fetchFamiliarFollowers(currentID, masto) { return masto.v1.accounts.familiarFollowers.fetch({ id: [currentID], }); } const memFetchFamiliarFollowers = pmem(fetchFamiliarFollowers, { maxAge: ACCOUNT_INFO_MAX_AGE, }); async function fetchPostingStats(accountID, masto) { const fetchStatuses = masto.v1.accounts .$select(accountID) .statuses.list({ limit: 20, }) .next(); const { value: statuses } = await fetchStatuses; console.log('fetched statuses', statuses); const stats = { total: statuses.length, originals: 0, replies: 0, boosts: 0, }; // Categories statuses by type // - Original posts (not replies to others) // - Threads (self-replies + 1st original post) // - Boosts (reblogs) // - Replies (not-self replies) statuses.forEach((status) => { if (status.reblog) { stats.boosts++; } else if ( !!status.inReplyToId && status.inReplyToAccountId !== status.account.id // Not self-reply ) { stats.replies++; } else { stats.originals++; } }); // Count days since last post if (statuses.length) { stats.daysSinceLastPost = Math.ceil( (Date.now() - new Date(statuses[statuses.length - 1].createdAt)) / 86400000, ); } console.log('posting stats', stats); return stats; } const memFetchPostingStats = pmem(fetchPostingStats, { maxAge: ACCOUNT_INFO_MAX_AGE, }); function AccountInfo({ account, fetchAccount = () => {}, standalone, instance, authenticated, }) { const { masto } = api({ instance, }); const { masto: currentMasto } = api(); const [uiState, setUIState] = useState('default'); const isString = typeof account === 'string'; const [info, setInfo] = useState(isString ? null : account); const isSelf = useMemo( () => account.id === store.session.get('currentAccount'), [account?.id], ); const sameCurrentInstance = useMemo( () => instance === api().instance, [instance], ); useEffect(() => { if (!isString) { setInfo(account); return; } setUIState('loading'); (async () => { try { const info = await fetchAccount(); states.accounts[`${info.id}@${instance}`] = info; setInfo(info); setUIState('default'); } catch (e) { console.error(e); setInfo(null); setUIState('error'); } })(); }, [isString, account, fetchAccount]); const { acct, avatar, avatarStatic, bot, createdAt, displayName, emojis, fields, followersCount, followingCount, group, // header, // headerStatic, id, lastStatusAt, locked, note, statusesCount, url, username, memorial, moved, roles, } = info || {}; let headerIsAvatar = false; let { header, headerStatic } = info || {}; if (!header || /missing\.png$/.test(header)) { if (avatar && !/missing\.png$/.test(avatar)) { header = avatar; headerIsAvatar = true; if (avatarStatic && !/missing\.png$/.test(avatarStatic)) { headerStatic = avatarStatic; } } } const accountInstance = useMemo(() => { if (!url) return null; const domain = new URL(url).hostname; return domain; }, [url]); const [headerCornerColors, setHeaderCornerColors] = useState([]); const followersIterator = useRef(); const familiarFollowersCache = useRef([]); async function fetchFollowers(firstLoad) { if (firstLoad || !followersIterator.current) { followersIterator.current = masto.v1.accounts.$select(id).followers.list({ limit: LIMIT, }); } const results = await followersIterator.current.next(); if (isSelf) return results; if (!sameCurrentInstance) return results; 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.familiarFollowers.fetch( { id: [id], }, ); familiarFollowersCache.current = familiarFollowers[0].accounts; newValue = [ ...familiarFollowersCache.current, ...value.filter( (account) => !familiarFollowersCache.current.some( (familiar) => familiar.id === account.id, ), ), ]; } else if (value?.length) { 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.$select(id).following.list({ limit: LIMIT, }); } const results = await followingIterator.current.next(); return results; } const LinkOrDiv = standalone ? 'div' : Link; const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`; const [familiarFollowers, setFamiliarFollowers] = useState([]); const [postingStats, setPostingStats] = useState(); const [postingStatsUIState, setPostingStatsUIState] = useState('default'); const hasPostingStats = !!postingStats?.total; const renderFamiliarFollowers = async (currentID) => { try { const followers = await memFetchFamiliarFollowers( currentID, currentMasto, ); console.log('fetched familiar followers', followers); setFamiliarFollowers( followers[0].accounts.slice(0, FAMILIAR_FOLLOWERS_LIMIT), ); } catch (e) { console.error(e); } }; const renderPostingStats = async () => { if (!id) return; setPostingStatsUIState('loading'); try { const stats = await memFetchPostingStats(id, masto); setPostingStats(stats); setPostingStatsUIState('default'); } catch (e) { console.error(e); setPostingStatsUIState('error'); } }; const onRelationshipChange = useCallback( ({ relationship, currentID }) => { if (!relationship.following) { renderFamiliarFollowers(currentID); if (!standalone) { renderPostingStats(); } } }, [standalone, id], ); return (
{uiState === 'error' && (

Unable to load account.

Go to account page

)} {uiState === 'loading' ? ( <>

████████ ███████

███████████████ ███████████████

██ Followers
██ Following
██ Posts
Joined ██
) : ( info && ( <> {!!moved && (

{displayName} has indicated that their new account is now:

{ e.stopPropagation(); states.showAccount = moved; }} />
)} {header && !/missing\.png$/.test(header) && ( { if (e.target.crossOrigin) { if (e.target.src !== headerStatic) { e.target.src = headerStatic; } else { e.target.removeAttribute('crossorigin'); e.target.src = header; } } else if (e.target.src !== headerStatic) { e.target.src = headerStatic; } else { e.target.remove(); } }} crossOrigin="anonymous" onLoad={(e) => { e.target.classList.add('loaded'); try { // Get color from four corners of image const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', { willReadFrequently: true, }); canvas.width = e.target.width; canvas.height = e.target.height; ctx.drawImage(e.target, 0, 0); // const colors = [ // ctx.getImageData(0, 0, 1, 1).data, // ctx.getImageData(e.target.width - 1, 0, 1, 1).data, // ctx.getImageData(0, e.target.height - 1, 1, 1).data, // ctx.getImageData( // e.target.width - 1, // e.target.height - 1, // 1, // 1, // ).data, // ]; // Get 10x10 pixels from corners, get average color from each const pixelDimension = 10; const colors = [ ctx.getImageData(0, 0, pixelDimension, pixelDimension) .data, ctx.getImageData( e.target.width - pixelDimension, 0, pixelDimension, pixelDimension, ).data, ctx.getImageData( 0, e.target.height - pixelDimension, pixelDimension, pixelDimension, ).data, ctx.getImageData( e.target.width - pixelDimension, e.target.height - pixelDimension, pixelDimension, pixelDimension, ).data, ].map((data) => { let r = 0; let g = 0; let b = 0; let a = 0; for (let i = 0; i < data.length; i += 4) { r += data[i]; g += data[i + 1]; b += data[i + 2]; a += data[i + 3]; } const dataLength = data.length / 4; return [ r / dataLength, g / dataLength, b / dataLength, a / dataLength, ]; }); const rgbColors = colors.map((color) => { const [r, g, b, a] = lightenRGB(color); return `rgba(${r}, ${g}, ${b}, ${a})`; }); setHeaderCornerColors(rgbColors); console.log({ colors, rgbColors }); } catch (e) { // Silently fail } }} /> )}
{!!memorial && In Memoriam} {!!bot && ( Automated )} {!!group && ( Group )} {roles?.map((role) => ( {role.name} {!!accountInstance && ( <> {' '} {accountInstance} )} ))}
{!!postingStats && (
) )}
); } const FAMILIAR_FOLLOWERS_LIMIT = 3; function RelatedActions({ info, instance, authenticated, onRelationshipChange = () => {}, }) { if (!info) return null; const { masto: currentMasto, instance: currentInstance, authenticated: currentAuthenticated, } = api(); const sameInstance = instance === currentInstance; const [relationshipUIState, setRelationshipUIState] = useState('default'); const [relationship, setRelationship] = useState(null); const { id, acct, url, username, locked, lastStatusAt, note, fields, moved } = info; const accountID = useRef(id); const { following, showingReblogs, notifying, followedBy, blocking, blockedBy, muting, mutingNotifications, requested, domainBlocking, endorsed, note: privateNote, } = relationship || {}; const [currentInfo, setCurrentInfo] = useState(null); const [isSelf, setIsSelf] = useState(false); useEffect(() => { if (info) { const currentAccount = store.session.get('currentAccount'); let currentID; (async () => { if (sameInstance && authenticated) { currentID = id; } else if (!sameInstance && currentAuthenticated) { // Grab this account from my logged-in instance const acctHasInstance = info.acct.includes('@'); try { const results = await currentMasto.v2.search.fetch({ q: acctHasInstance ? info.acct : `${info.username}@${instance}`, type: 'accounts', limit: 1, resolve: true, }); console.log('🥏 Fetched account from logged-in instance', results); if (results.accounts.length) { currentID = results.accounts[0].id; setCurrentInfo(results.accounts[0]); } } catch (e) { console.error(e); } } if (!currentID) return; if (currentAccount === currentID) { // It's myself! setIsSelf(true); return; } accountID.current = currentID; if (moved) return; setRelationshipUIState('loading'); const fetchRelationships = currentMasto.v1.accounts.relationships.fetch( { id: [currentID], }, ); try { const relationships = await fetchRelationships; console.log('fetched relationship', relationships); setRelationshipUIState('default'); if (relationships.length) { const relationship = relationships[0]; setRelationship(relationship); onRelationshipChange({ relationship, currentID }); } } catch (e) { console.error(e); setRelationshipUIState('error'); } })(); } }, [info, authenticated]); useEffect(() => { if (info && isSelf) { updateAccount(info); } }, [info, isSelf]); const loading = relationshipUIState === 'loading'; const menuInstanceRef = useRef(null); const [showTranslatedBio, setShowTranslatedBio] = useState(false); const [showAddRemoveLists, setShowAddRemoveLists] = useState(false); const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false); return ( <>
{followedBy ? ( Following you ) : !!lastStatusAt ? ( Last post:{' '} {niceDateTime(lastStatusAt, { hideTime: true, })} ) : ( )} {muting && Muted} {blocking && Blocked} {' '} {!!privateNote && ( )} { if (e.target === e.currentTarget) { menuInstanceRef.current?.closeMenu?.(); } }, }} align="center" position="anchor" overflow="auto" boundingBoxPadding="8 8 8 8" menuButton={ } > {currentAuthenticated && !isSelf && ( <> { states.showCompose = { draftStatus: { status: `@${currentInfo?.acct || acct} `, }, }; }} > Mention @{username} { setShowTranslatedBio(true); }} > Translate bio { setShowPrivateNoteModal(true); }} > {privateNote ? 'Edit private note' : 'Add private note'} {/* Add/remove from lists is only possible if following the account */} {following && ( { setShowAddRemoveLists(true); }} > Add/remove from Lists )} )} {niceAccountURL(url)} {!!relationship && ( <> {muting ? ( { setRelationshipUIState('loading'); (async () => { try { const newRelationship = await currentMasto.v1.accounts .$select(currentInfo?.id || id) .unmute(); console.log('unmuting', newRelationship); setRelationship(newRelationship); setRelationshipUIState('default'); showToast(`Unmuted @${username}`); states.reloadGenericAccounts.id = 'mute'; states.reloadGenericAccounts.counter++; } catch (e) { console.error(e); setRelationshipUIState('error'); } })(); }} > Unmute @{username} ) : ( Mute @{username}… } > )} Block @{username}? } menuItemClassName="danger" onClick={() => { // if (!blocking && !confirm(`Block @${username}?`)) { // return; // } setRelationshipUIState('loading'); (async () => { try { if (blocking) { const newRelationship = await currentMasto.v1.accounts .$select(currentInfo?.id || id) .unblock(); console.log('unblocking', newRelationship); setRelationship(newRelationship); setRelationshipUIState('default'); showToast(`Unblocked @${username}`); } else { const newRelationship = await currentMasto.v1.accounts .$select(currentInfo?.id || id) .block(); console.log('blocking', newRelationship); setRelationship(newRelationship); setRelationshipUIState('default'); showToast(`Blocked @${username}`); } states.reloadGenericAccounts.id = 'block'; states.reloadGenericAccounts.counter++; } catch (e) { console.error(e); setRelationshipUIState('error'); if (blocking) { showToast(`Unable to unblock @${username}`); } else { showToast(`Unable to block @${username}`); } } })(); }} > {blocking ? ( <> Unblock @{username} ) : ( <> Block @{username}… )} {/* Report @{username}… */} )} {!relationship && relationshipUIState === 'loading' && ( )} {!!relationship && ( {requested ? 'Withdraw follow request?' : `Unfollow @${info.acct || info.username}?`} } menuItemClassName="danger" align="end" disabled={loading} onClick={() => { setRelationshipUIState('loading'); (async () => { try { let newRelationship; if (following || requested) { // const yes = confirm( // requested // ? 'Withdraw follow request?' // : `Unfollow @${info.acct || info.username}?`, // ); // if (yes) { newRelationship = await currentMasto.v1.accounts .$select(accountID.current) .unfollow(); // } } else { newRelationship = await currentMasto.v1.accounts .$select(accountID.current) .follow(); } if (newRelationship) setRelationship(newRelationship); setRelationshipUIState('default'); } catch (e) { alert(e); setRelationshipUIState('error'); } })(); }} > )}
{!!showTranslatedBio && ( { setShowTranslatedBio(false); }} > setShowTranslatedBio(false)} /> )} {!!showAddRemoveLists && ( { setShowAddRemoveLists(false); }} > setShowAddRemoveLists(false)} /> )} {!!showPrivateNoteModal && ( { setShowPrivateNoteModal(false); }} > { setRelationship(relationship); // onRelationshipChange({ relationship, currentID: accountID.current }); }} onClose={() => setShowPrivateNoteModal(false)} /> )} ); } // Apply more alpha if high luminence function lightenRGB([r, g, b]) { const luminence = 0.2126 * r + 0.7152 * g + 0.0722 * b; console.log('luminence', luminence); let alpha; if (luminence >= 220) { alpha = 1; } else if (luminence <= 50) { alpha = 0.1; } else { alpha = luminence / 255; } alpha = Math.min(1, alpha); return [r, g, b, alpha]; } function niceAccountURL(url) { if (!url) return; const urlObj = new URL(url); const { host, pathname } = urlObj; const path = pathname.replace(/\/$/, '').replace(/^\//, ''); return ( <> {host}/ {path} ); } function TranslatedBioSheet({ note, fields, onClose }) { const fieldsText = fields ?.map(({ name, value }) => `${name}\n${getHTMLText(value)}`) .join('\n\n') || ''; const text = getHTMLText(note) + (fieldsText ? `\n\n${fieldsText}` : ''); return (
{!!onClose && ( )}

Translated Bio

{text}

); } function AddRemoveListsSheet({ accountID, onClose }) { const { masto } = api(); const [uiState, setUIState] = useState('default'); const [lists, setLists] = useState([]); const [listsContainingAccount, setListsContainingAccount] = useState([]); const [reloadCount, reload] = useReducer((c) => c + 1, 0); useEffect(() => { setUIState('loading'); (async () => { try { const lists = await masto.v1.lists.list(); const listsContainingAccount = await masto.v1.accounts .$select(accountID) .lists.list(); console.log({ lists, listsContainingAccount }); setLists(lists); setListsContainingAccount(listsContainingAccount); setUIState('default'); } catch (e) { console.error(e); setUIState('error'); } })(); }, [reloadCount]); const [showListAddEditModal, setShowListAddEditModal] = useState(false); return (
{!!onClose && ( )}

Add/Remove from Lists

{lists.length > 0 ? ( ) : uiState === 'loading' ? (

) : uiState === 'error' ? (

Unable to load lists.

) : (

No lists.

)}
{showListAddEditModal && ( { if (e.target === e.currentTarget) { setShowListAddEditModal(false); } }} > { if (result.state === 'success') { reload(); } setShowListAddEditModal(false); }} /> )}
); } function PrivateNoteSheet({ account, note: initialNote, onRelationshipChange = () => {}, onClose = () => {}, }) { const { masto } = api(); const [uiState, setUIState] = useState('default'); const textareaRef = useRef(null); useEffect(() => { let timer; if (textareaRef.current && !initialNote) { timer = setTimeout(() => { textareaRef.current.focus?.(); }, 100); } return () => { clearTimeout(timer); }; }, []); return (
{!!onClose && ( )}
Private note for @{account?.acct}
{ e.preventDefault(); const formData = new FormData(e.target); const note = formData.get('note'); if (note?.trim() !== initialNote?.trim()) { setUIState('loading'); (async () => { try { const newRelationship = await masto.v1.accounts .$select(account?.id) .note.create({ comment: note, }); console.log('updated relationship', newRelationship); setUIState('default'); onRelationshipChange(newRelationship); onClose(); } catch (e) { console.error(e); setUIState('error'); alert(e?.message || 'Unable to update private note.'); } })(); } }} >
); } export default AccountInfo;