followgraph/components/Content.tsx

551 wiersze
16 KiB
TypeScript

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<string> // 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<Array<AccountDetails>> {
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<AccountDetails> = []
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<number>) => void,
setFollows: (x: Array<AccountDetails>) => void,
logError: (x: string) => void
): Promise<void> {
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<Array<AccountDetails>> = []
const updateList = debounce(() => {
let indirectFollows: Array<AccountDetails> = [].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: <https://mastodon.example/api/v1/accounts/1/follows?limit=2&max_id=7628164>; rel="next", <https://mastodon.example/api/v1/accounts/1/follows?limit=2&since_id=7628165>; 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<Array<AccountDetails>>([])
const [isLoading, setLoading] = useState(false)
const [isDone, setDone] = useState(false)
const [domain, setDomain] = useState<string>('')
const [[numLoaded, totalToLoad], setProgress] = useState<Array<number>>([
0, 0,
])
const [errors, setErrors] = useState<Array<string>>([])
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 (
<section className="bg-gray-50 dark:bg-gray-800" id="searchForm">
<div className="px-4 py-8 mx-auto space-y-12 lg:space-y-20 lg:py-24 max-w-screen-xl">
<form
onSubmit={(e) => {
search(handle)
e.preventDefault()
return false
}}
>
<div className="form-group mb-6 text-4xl lg:ml-16">
<label
htmlFor="mastodonHandle"
className="form-label inline-block mb-2 text-gray-700 dark:text-gray-200"
>
Your Mastodon handle:
</label>
<input
type="text"
value={handle}
onChange={(e) => 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"
/>
<small
id="mastodonHandleHelp"
className="block mt-1 text-xs text-gray-600 dark:text-gray-300"
>
Be sure to include the full handle, including the domain.
</small>
<button
type="submit"
className="
px-6
py-2.5
bg-green-600
text-white
font-medium
text-xs
leading-tight
uppercase
rounded
shadow-md
hover:bg-green-700 hover:shadow-lg
focus:bg-green-700 focus:shadow-lg focus:outline-none focus:ring-0
active:bg-green-800 active:shadow-lg
transition
duration-150
ease-in-out"
>
Search
<Spinner
visible={isLoading}
className="w-4 h-4 ml-2 fill-white"
/>
</button>
{isLoading ? (
<p className="text-sm dark:text-gray-400">
Loaded {numLoaded} of {totalToLoad}...
</p>
) : null}
{isDone && follows.length === 0 ? (
<div
className="flex p-4 mt-4 max-w-full sm:max-w-xl text-sm text-gray-700 bg-gray-100 rounded-lg dark:bg-gray-700 dark:text-gray-300"
role="alert"
>
<svg
aria-hidden="true"
className="flex-shrink-0 inline w-5 h-5 mr-3"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
></path>
</svg>
<span className="sr-only">Info</span>
<div>
<span className="font-medium">No results found.</span> 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.
</div>
</div>
) : null}
</div>
</form>
{isDone || follows.length > 0 ? (
<Results follows={follows} domain={domain} />
) : null}
<ErrorLog errors={errors} />
</div>
</section>
)
}
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 (
<li className="px-4 py-3 pb-7 sm:px-0 sm:py-4">
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex-shrink-0 m-auto">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="w-16 h-16 sm:w-8 sm:h-8 rounded-full"
src={avatar_static}
alt={display_name}
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate dark:text-white">
{display_name}
</p>
<div className="flex flex-col sm:flex-row text-sm text-gray-500 dark:text-gray-400">
<span className="truncate">{acct}</span>
<span className="sm:inline hidden whitespace-pre"> | </span>
<span>{numFollowers} followers</span>
</div>
<br />
<small
className="text-sm dark:text-gray-200"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(note) }}
></small>
<br />
<small className="text-xs text-gray-800 dark:text-gray-400">
Followed by{' '}
{followed_by.size < 9 || expandedFollowers ? (
Array.from<string>(followed_by.values()).map((handle, idx) => (
<React.Fragment key={handle}>
<span className="font-semibold">
{handle.replace(/@.+/, '')}
</span>
{idx === followed_by.size - 1 ? '.' : ', '}
</React.Fragment>
))
) : (
<>
<button
onClick={() => setExpandedFollowers(true)}
className="font-semibold"
>
{followed_by.size} of your contacts
</button>
.
</>
)}
</small>
</div>
<div className="inline-flex m-auto text-base font-semibold text-gray-900 dark:text-white">
<a
href={`https://${mainDomain}/@${acct.replace(
'@' + mainDomain,
''
)}`}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
target="_blank"
rel="noreferrer"
>
Follow
</a>
</div>
</div>
</li>
)
}
)
AccountDetails.displayName = 'AccountDetails'
function ErrorLog({ errors }: { errors: Array<string> }) {
const [expanded, setExpanded] = useState(false)
return (
<>
{errors.length > 0 ? (
<div className="text-sm text-gray-600 dark:text-gray-200 border border-solid border-gray-200 dark:border-gray-700 rounded p-4 max-w-4xl mx-auto">
Found{' '}
<button className="font-bold" onClick={() => setExpanded(!expanded)}>
{errors.length} warnings
</button>
{expanded ? ':' : '.'}
{expanded
? errors.map((err) => (
<p key={err} className="text-xs">
{err}
</p>
))
: null}
</div>
) : null}
</>
)
}
function Results({
domain,
follows,
}: {
domain: string
follows: Array<AccountDetails>
}) {
let [search, setSearch] = useState<string>('')
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 (
<div className="flex-col lg:flex items-center justify-center">
<div className="max-w-4xl">
<div className="w-full mb-4 dark:text-gray-200">
<label>
<div className="mb-2">
<Spinner
visible={isLoading}
className="w-4 h-4 mr-1 fill-gray-400"
/>
Search:
</div>
<SearchInput
onChange={(s) => {
setLoading(true)
updateSearch(s)
}}
/>
</label>
</div>
<div className="content-center px-2 sm:px-8 py-4 bg-white border rounded-lg shadow-md dark:bg-gray-800 dark:border-gray-700">
<div className="flow-root">
{follows.length === 0 ? (
<p className="text-gray-700 dark:text-gray-200">
No results found.
</p>
) : null}
<ul
role="list"
className="divide-y divide-gray-200 dark:divide-gray-700"
>
{follows.map((account) => (
<AccountDetails
key={account.acct}
account={account}
mainDomain={domain}
/>
))}
</ul>
</div>
</div>
</div>
</div>
)
}
function SearchInput({ onChange }: { onChange: (s: string) => void }) {
let [search, setSearchInputValue] = useState<string>('')
return (
<input
type="text"
placeholder="London"
value={search}
onChange={(e) => {
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"
/>
)
}