kopia lustrzana https://github.com/gabipurcaru/followgraph
246 wiersze
8.9 KiB
TypeScript
246 wiersze
8.9 KiB
TypeScript
|
import React, { useState } from "react";
|
||
|
import sanitizeHtml from 'sanitize-html';
|
||
|
|
||
|
type AccountDetails = {
|
||
|
id: string, // IMPORTANT: this is int64 so will overflow Javascript's number type
|
||
|
acct: string,
|
||
|
};
|
||
|
|
||
|
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) {
|
||
|
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): Promise<Array<AccountDetails>> {
|
||
|
let id, domain;
|
||
|
try {
|
||
|
({ id, domain } = await usernameToId(handle));
|
||
|
} catch (e) {
|
||
|
return [];
|
||
|
}
|
||
|
|
||
|
let nextPage: string | undefined = `https://${domain}/api/v1/accounts/${id}/following`;
|
||
|
let data: Array<AccountDetails> = [];
|
||
|
while (nextPage && data.length <= 50) {
|
||
|
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 => {
|
||
|
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): Promise<Array<AccountDetails>> {
|
||
|
console.log('Start');
|
||
|
const directFollows = await accountFollows(handle);
|
||
|
setProgress([0, directFollows.length]);
|
||
|
console.log(`Direct follows: ${directFollows.length}`);
|
||
|
let progress = 0;
|
||
|
const indirectFollowLists = await Promise.all(
|
||
|
directFollows.map(
|
||
|
async ({ acct }) => {
|
||
|
const follows = await accountFollows(acct);
|
||
|
progress++;
|
||
|
setProgress([progress, directFollows.length]);
|
||
|
return follows.map(account => ({...account, followed_by: [acct]}));
|
||
|
}
|
||
|
),
|
||
|
);
|
||
|
|
||
|
let indirectFollows = [].concat([], ...indirectFollowLists);
|
||
|
const indirectFollowMap = new Map();
|
||
|
|
||
|
const directFollowIds = new Set(directFollows.map(({ acct }) => acct));
|
||
|
|
||
|
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 = [...account.followed_by, ...otherAccount.followed_by];
|
||
|
}
|
||
|
indirectFollowMap.set(acct, account);
|
||
|
});
|
||
|
|
||
|
return Array.from(indirectFollowMap.values()).sort((a, b) => {
|
||
|
if (a.followed_by.length != b.followed_by.length) {
|
||
|
return b.followed_by.length - a.followed_by.length;
|
||
|
}
|
||
|
return b.followers_count - a.followers_count;
|
||
|
});
|
||
|
}
|
||
|
|
||
|
function getNextPage(linkHeader: string | undefined): string | undefined {
|
||
|
if (!linkHeader) {
|
||
|
return undefined;
|
||
|
}
|
||
|
// 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 undefined;
|
||
|
}
|
||
|
|
||
|
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();
|
||
|
const [[numLoaded, totalToLoad], setProgress] = useState([0, 0]);
|
||
|
|
||
|
console.log(follows.length);
|
||
|
|
||
|
async function search(handle: string) {
|
||
|
if (!/@/.test(handle)) {
|
||
|
return;
|
||
|
}
|
||
|
setLoading(true);
|
||
|
setDomain(getDomain(handle));
|
||
|
setfollows(await accountFofs(handle, setProgress));
|
||
|
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={() => search(handle)}>
|
||
|
<div className="form-group mb-6 text-4xl ml-8">
|
||
|
<label htmlFor="mastodonHandle" className="form-label inline-block mb-2 text-gray-700">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-700 focus:bg-white focus:border-green-600 focus:outline-none" id="mastodonHandle"
|
||
|
aria-describedby="mastodonHandleHelp" placeholder="johnmastodon@mas.to" />
|
||
|
<small id="mastodonHandleHelp" className="block mt-1 text-xs text-gray-600">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">Loaded {numLoaded} from {totalToLoad}...</p>
|
||
|
: null}
|
||
|
</div>
|
||
|
</form>
|
||
|
|
||
|
|
||
|
{isDone ?
|
||
|
<div className="w-9/12 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, 100).map(account => <AccountDetails key={account.acct} account={account} mainDomain={domain} />)}
|
||
|
</ul>
|
||
|
</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 numfollows = formatter.format(followers_count);
|
||
|
console.log(account);
|
||
|
|
||
|
return (
|
||
|
<li className="py-3 sm:py-4">
|
||
|
<div className="flex items-center space-x-4">
|
||
|
<div className="flex-shrink-0">
|
||
|
<img className="w-8 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} | {numfollows} follows
|
||
|
</p>
|
||
|
<br />
|
||
|
<small className="text-sm" dangerouslySetInnerHTML={{ __html: sanitizeHtml(note) }}></small>
|
||
|
<br />
|
||
|
<small className="text-xs text-gray-800">
|
||
|
Followed by{' '}
|
||
|
{followed_by.map((handle, idx) => (
|
||
|
<><span className="font-semibold">{handle.replace(/@.+/, '')}</span>{idx === followed_by.length - 1 ? '' : ', '}</>
|
||
|
))}
|
||
|
</small>
|
||
|
</div>
|
||
|
<div className="inline-flex items-center text-base font-semibold text-gray-900 dark:text-white">
|
||
|
<a href={`https://${mainDomain}/@${acct}`} className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||
|
Follow
|
||
|
</a>
|
||
|
</div>
|
||
|
</div>
|
||
|
</li>
|
||
|
);
|
||
|
}
|