import { Spinner } from './Spinner' import React, { useState, memo, useRef } from 'react' import sanitizeHtml from 'sanitize-html' import debounce from 'debounce' type AccountDetails = { /** * // IMPORTANT * Mastodon uses int64 so will overflow Javascript's number type * Pleroma uses 128-bit ids. However just like Mastodon's ids they are lexically sortable strings */ id: string acct: string followed_by: Set // list of handles followers_count: number discoverable: boolean display_name: string note: string avatar_static: string } async function usernameToId( handle: string ): Promise<{ id: string; domain: string }> { const match = handle.match(/^(.+)@(.+)$/) if (!match || match.length < 2) { throw new Error(`Incorrect handle: ${handle}`) } const domain = match[2] const username = match[1] let response = await fetch( `https://${domain}/api/v1/accounts/lookup?acct=${username}` ) if (response.status !== 200) { throw new Error('HTTP request failed') } const { id } = await response.json() return { id, domain } } function getDomain(handle: string) { const match = handle.match(/^(.+)@(.+)$/) if (!match || match.length < 2) { throw new Error(`Incorrect handle: ${handle}`) } const domain = match[2] return domain } async function accountFollows( handle: string, limit: number, logError: (x: string) => void ): Promise> { let id, domain: string try { ;({ id, domain } = await usernameToId(handle)) } catch (e) { logError(`Cannot find handle ${handle}.`) return [] } let nextPage: | string | null = `https://${domain}/api/v1/accounts/${id}/following` let data: Array = [] while (nextPage && data.length <= limit) { console.log(`Get page: ${nextPage}`) let response let page try { response = await fetch(nextPage) if (response.status !== 200) { throw new Error('HTTP request failed') } page = await response.json() } catch (e) { logError(`Error while retrieving followers for ${handle}.`) break } if (!page.map) { break } page = page.map((entry: AccountDetails) => { if (entry.acct && !/@/.test(entry.acct)) { // make sure the domain is always there entry.acct = `${entry.acct}@${domain}` } return entry }) data = [...data, ...page] nextPage = getNextPage(response.headers.get('Link')) } return data } async function accountFofs( handle: string, setProgress: (x: Array) => void, setFollows: (x: Array) => void, logError: (x: string) => void ): Promise { const directFollows = await accountFollows(handle, 2000, logError) setProgress([0, directFollows.length]) let progress = 0 const directFollowIds = new Set(directFollows.map(({ acct }) => acct)) directFollowIds.add(handle.replace(/^@/, '')) const indirectFollowLists: Array> = [] const updateList = debounce(() => { let indirectFollows: Array = [].concat( [], ...indirectFollowLists ) const indirectFollowMap = new Map() indirectFollows .filter( // exclude direct follows and accounts who choose not to be discovered ({ acct, discoverable }) => !directFollowIds.has(acct) && discoverable ) .map((account) => { const acct = account.acct if (indirectFollowMap.has(acct)) { const otherAccount = indirectFollowMap.get(acct) account.followed_by = new Set([ ...Array.from(account.followed_by.values()), ...otherAccount.followed_by, ]) } indirectFollowMap.set(acct, account) }) const list = Array.from(indirectFollowMap.values()).sort((a, b) => { if (a.followed_by.size != b.followed_by.size) { return b.followed_by.size - a.followed_by.size } return b.followers_count - a.followers_count }) setFollows(list) }, 2000) await Promise.all( directFollows.map(async ({ acct }) => { const follows = await accountFollows(acct, 200, logError) progress++ setProgress([progress, directFollows.length]) indirectFollowLists.push( follows.map((account) => ({ ...account, followed_by: new Set([acct]) })) ) updateList() }) ) updateList.flush() } function getNextPage(linkHeader: string | null): string | null { if (!linkHeader) { return null } // Example header: // Link: ; rel="next", ; rel="prev" const match = linkHeader.match(/<(.+)>; rel="next"/) if (match && match.length > 0) { return match[1] } return null } function matchesSearch(account: AccountDetails, search: string): boolean { if (/^\s*$/.test(search)) { return true } const sanitizedSearch = search.replace(/^\s+|\s+$/, '').toLocaleLowerCase() if (account.acct.toLocaleLowerCase().includes(sanitizedSearch)) { return true } if (account.display_name.toLocaleLowerCase().includes(sanitizedSearch)) { return true } if (account.note.toLocaleLowerCase().includes(sanitizedSearch)) { return true } return false } export function Content({}) { const [handle, setHandle] = useState('') const [follows, setFollows] = useState>([]) const [isLoading, setLoading] = useState(false) const [isDone, setDone] = useState(false) const [domain, setDomain] = useState('') const [[numLoaded, totalToLoad], setProgress] = useState>([ 0, 0, ]) const [errors, setErrors] = useState>([]) async function search(handle: string) { if (!/@/.test(handle)) { return } setErrors([]) setLoading(true) setDone(false) setFollows([]) setProgress([0, 0]) setDomain(getDomain(handle)) await accountFofs(handle, setProgress, setFollows, (error) => setErrors((e) => [...e, error]) ) setLoading(false) setDone(true) } return (
{ search(handle) e.preventDefault() return false }} >
setHandle(e.target.value)} className="form-control block w-80 px-3 py-1.5 text-base font-normal text-gray-700 bg-white bg-clip-padding border border-solid border-gray-300 rounded transition ease-in-out m-0 focus:text-gray-900 focus:bg-white focus:border-green-600 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-gray-200 dark:focus:bg-gray-900 dark:focus:text-gray-200 " id="mastodonHandle" aria-describedby="mastodonHandleHelp" placeholder="johnmastodon@mas.to" /> Be sure to include the full handle, including the domain. {isLoading ? (

Loaded {numLoaded} of {totalToLoad}...

) : null} {isDone && follows.length === 0 ? (
Info
No results found. Please double check for typos in the handle, and ensure that you follow at least a few people to seed the search. Otherwise, try again later as Mastodon may throttle requests.
) : null}
{isDone || follows.length > 0 ? ( ) : null}
) } const AccountDetails = memo( ({ account, mainDomain, }: { account: AccountDetails mainDomain: string }) => { const { avatar_static, display_name, acct, note, followers_count, followed_by, } = account let formatter = Intl.NumberFormat('en', { notation: 'compact' }) let numFollowers = formatter.format(followers_count) const [expandedFollowers, setExpandedFollowers] = useState(false) return (
  • {/* eslint-disable-next-line @next/next/no-img-element */} {display_name}

    {display_name}

    {acct} | {numFollowers} followers


    Followed by{' '} {followed_by.size < 9 || expandedFollowers ? ( Array.from(followed_by.values()).map((handle, idx) => ( {handle.replace(/@.+/, '')} {idx === followed_by.size - 1 ? '.' : ', '} )) ) : ( <> . )}
  • ) } ) AccountDetails.displayName = 'AccountDetails' function ErrorLog({ errors }: { errors: Array }) { const [expanded, setExpanded] = useState(false) return ( <> {errors.length > 0 ? (
    Found{' '} {expanded ? ':' : '.'} {expanded ? errors.map((err) => (

    {err}

    )) : null}
    ) : null} ) } function Results({ domain, follows, }: { domain: string follows: Array }) { let [search, setSearch] = useState('') const [isLoading, setLoading] = useState(false) const updateSearch = useRef( debounce((s: string) => { setLoading(false) setSearch(s) }, 1500) ).current follows = follows.filter((acc) => matchesSearch(acc, search)).slice(0, 500) return (
    {follows.length === 0 ? (

    No results found.

    ) : null}
      {follows.map((account) => ( ))}
    ) } function SearchInput({ onChange }: { onChange: (s: string) => void }) { let [search, setSearchInputValue] = useState('') return ( { setSearchInputValue(e.target.value) onChange(e.target.value) }} className=" form-control block w-80 px-3 py-1.5 text-base font-normal text-gray-700 bg-white bg-clip-padding border border-solid border-gray-300 rounded transition ease-in-out m-0 focus:text-gray-900 focus:bg-white focus:border-green-600 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-gray-200 dark:focus:bg-gray-900 dark:focus:text-gray-200" /> ) }