kopia lustrzana https://github.com/gabipurcaru/followgraph
284 wiersze
11 KiB
TypeScript
284 wiersze
11 KiB
TypeScript
import React, { useState } from "react";
|
|
import sanitizeHtml from 'sanitize-html';
|
|
import debounce from 'debounce'
|
|
import { updateCommaList } from "typescript";
|
|
|
|
type AccountDetails = {
|
|
id: string, // IMPORTANT: this is int64 so will overflow Javascript's number type
|
|
acct: string,
|
|
followed_by: Set<string>, // list of handles
|
|
};
|
|
|
|
async function usernameToId(handle: string): Promise<{ id: number, 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}`);
|
|
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 = 200): Promise<Array<AccountDetails>> {
|
|
let id, domain: string;
|
|
try {
|
|
({ id, domain } = await usernameToId(handle));
|
|
} catch (e) {
|
|
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);
|
|
page = await response.json();
|
|
} catch (e) {
|
|
console.log(e);
|
|
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): Promise<void> {
|
|
const directFollows = await accountFollows(handle, 2000);
|
|
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
|
|
({ acct }) => !directFollowIds.has(acct)
|
|
).map(account => {
|
|
const acct = account.acct;
|
|
if (indirectFollowMap.has(acct)) {
|
|
const otherAccount = indirectFollowMap.get(acct);
|
|
account.followed_by = new Set([...account.followed_by, ...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);
|
|
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;
|
|
}
|
|
|
|
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]);
|
|
|
|
async function search(handle: string) {
|
|
if (!/@/.test(handle)) {
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setDone(false);
|
|
setFollows([]);
|
|
setProgress([0, 0]);
|
|
setDomain(getDomain(handle));
|
|
await accountFofs(handle, setProgress, setFollows);
|
|
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 lg:px-6">
|
|
<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
|
|
{isLoading ?
|
|
<svg className="w-4 h-4 ml-2 fill-white animate-spin inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">{/*! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. */}<path d="M304 48c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zm0 416c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zM48 304c26.5 0 48-21.5 48-48s-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48zm464-48c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zM142.9 437c18.7-18.7 18.7-49.1 0-67.9s-49.1-18.7-67.9 0s-18.7 49.1 0 67.9s49.1 18.7 67.9 0zm0-294.2c18.7-18.7 18.7-49.1 0-67.9S93.7 56.2 75 75s-18.7 49.1 0 67.9s49.1 18.7 67.9 0zM369.1 437c18.7 18.7 49.1 18.7 67.9 0s18.7-49.1 0-67.9s-49.1-18.7-67.9 0s-18.7 49.1 0 67.9z" /></svg>
|
|
: null}
|
|
</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 ?
|
|
<div className="flex-col lg:flex items-center justify-center">
|
|
<div className="max-w-4xl 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">
|
|
<ul role="list" className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{follows.slice(0, 500).map(account => <AccountDetails key={account.acct} account={account} mainDomain={domain} />)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
: null}
|
|
</div>
|
|
</section>;
|
|
}
|
|
|
|
function AccountDetails({ account, mainDomain }) {
|
|
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="py-3 sm:py-4">
|
|
<div className="flex flex-col sm:flex-row items-center space-x-4">
|
|
<div className="flex-shrink-0">
|
|
<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>
|
|
<p className="text-sm text-gray-500 truncate dark:text-gray-400">
|
|
{acct} | {numFollowers} followers
|
|
</p>
|
|
<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(followed_by.values()).map((handle, idx) => (
|
|
<><span className="font-semibold">{handle.replace(/@.+/, '')}</span>{idx === followed_by.size - 1 ? '.' : ', '}</>
|
|
))
|
|
: <>
|
|
<button onClick={setExpandedFollowers} className="font-semibold">{followed_by.size} of your contacts</button>.
|
|
</>}
|
|
</small>
|
|
</div>
|
|
<div className="inline-flex items-center text-base font-semibold text-gray-900 dark:text-white my-4 sm:my-0">
|
|
<a href={`https://${mainDomain}/@${acct}`} 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>
|
|
);
|
|
} |