kopia lustrzana https://github.com/gabipurcaru/followgraph
added expand link
rodzic
842f169f1e
commit
039e80dd8e
|
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
|
|
|
@ -1,21 +1,29 @@
|
|||
import React, { useState, memo } from 'react';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { AccountDetails } from './api';
|
||||
import { AccountExpandedDetails } from './AccountExpandedDetails'
|
||||
import React, { useState, memo } from 'react'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import { AccountDetails } from './api'
|
||||
|
||||
export const AccountDetailsRow = memo(
|
||||
({
|
||||
account, mainDomain,
|
||||
account,
|
||||
mainDomain,
|
||||
}: {
|
||||
account: AccountDetails;
|
||||
mainDomain: string;
|
||||
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);
|
||||
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);
|
||||
const [expandedFollowers, setExpandedFollowers] = useState(false)
|
||||
const [expandedDetails, setExpandedDetails] = useState(false)
|
||||
|
||||
return (
|
||||
<li className="px-4 py-3 pb-7 sm:px-0 sm:py-4">
|
||||
|
@ -25,7 +33,8 @@ export const AccountDetailsRow = memo(
|
|||
<img
|
||||
className="w-16 h-16 sm:w-8 sm:h-8 rounded-full"
|
||||
src={avatar_static}
|
||||
alt={display_name} />
|
||||
alt={display_name}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||
|
@ -64,6 +73,22 @@ export const AccountDetailsRow = memo(
|
|||
.
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<button
|
||||
onClick={() => setExpandedDetails((d) => !d)}
|
||||
className="text-blue-500 hover:text-blue-700 fill-blue-500 hover:fill-blue-700 inline-block"
|
||||
>
|
||||
See more details
|
||||
<svg
|
||||
className="w-3 h-3 inline ml-1 mb-0.5"
|
||||
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="M470.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 256 265.4 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160zm-352 160l160-160c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L210.7 256 73.4 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
{expandedDetails && <AccountExpandedDetails account={account} />}
|
||||
</small>
|
||||
</div>
|
||||
<div className="inline-flex m-auto text-base font-semibold text-gray-900 dark:text-white">
|
||||
|
@ -81,7 +106,7 @@ export const AccountDetailsRow = memo(
|
|||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
)
|
||||
}
|
||||
);
|
||||
AccountDetailsRow.displayName = 'AccountDetailsRow';
|
||||
)
|
||||
AccountDetailsRow.displayName = 'AccountDetailsRow'
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import { AccountDetails } from './api'
|
||||
|
||||
export function AccountExpandedDetails({
|
||||
account,
|
||||
}: {
|
||||
account: AccountDetails
|
||||
}) {
|
||||
return <>expanded! {account.display_name}</>
|
||||
}
|
|
@ -21,7 +21,7 @@ function matchesSearch(account: AccountDetails, search: string): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
export function Content({ }) {
|
||||
export function Content({}) {
|
||||
const [handle, setHandle] = useState('')
|
||||
const [follows, setFollows] = useState<Array<AccountDetails>>([])
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
|
@ -184,10 +184,10 @@ function ErrorLog({ errors }: { errors: Array<string> }) {
|
|||
{expanded ? ':' : '.'}
|
||||
{expanded
|
||||
? errors.map((err) => (
|
||||
<p key={err} className="text-xs">
|
||||
{err}
|
||||
</p>
|
||||
))
|
||||
<p key={err} className="text-xs">
|
||||
{err}
|
||||
</p>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import debounce from 'debounce';
|
||||
import debounce from 'debounce'
|
||||
|
||||
export type AccountDetails = {
|
||||
/**
|
||||
|
@ -6,41 +6,41 @@ export type AccountDetails = {
|
|||
* 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;
|
||||
};
|
||||
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(/^(.+)@(.+)$/);
|
||||
): Promise<{ id: string; domain: string }> {
|
||||
const match = handle.match(/^(.+)@(.+)$/)
|
||||
if (!match || match.length < 2) {
|
||||
throw new Error(`Incorrect handle: ${handle}`);
|
||||
throw new Error(`Incorrect handle: ${handle}`)
|
||||
}
|
||||
const domain = match[2];
|
||||
const username = match[1];
|
||||
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');
|
||||
throw new Error('HTTP request failed')
|
||||
}
|
||||
const { id } = await response.json();
|
||||
return { id, domain };
|
||||
const { id } = await response.json()
|
||||
return { id, domain }
|
||||
}
|
||||
export function getDomain(handle: string) {
|
||||
const match = handle.match(/^(.+)@(.+)$/);
|
||||
const match = handle.match(/^(.+)@(.+)$/)
|
||||
if (!match || match.length < 2) {
|
||||
throw new Error(`Incorrect handle: ${handle}`);
|
||||
throw new Error(`Incorrect handle: ${handle}`)
|
||||
}
|
||||
const domain = match[2];
|
||||
return domain;
|
||||
const domain = match[2]
|
||||
return domain
|
||||
}
|
||||
|
||||
async function accountFollows(
|
||||
|
@ -48,66 +48,68 @@ async function accountFollows(
|
|||
limit: number,
|
||||
logError: (x: string) => void
|
||||
): Promise<Array<AccountDetails>> {
|
||||
let id, domain: string;
|
||||
let id, domain: string
|
||||
try {
|
||||
; ({ id, domain } = await usernameToId(handle));
|
||||
;({ id, domain } = await usernameToId(handle))
|
||||
} catch (e) {
|
||||
logError(`Cannot find handle ${handle}.`);
|
||||
return [];
|
||||
logError(`Cannot find handle ${handle}.`)
|
||||
return []
|
||||
}
|
||||
|
||||
let nextPage: string |
|
||||
null = `https://${domain}/api/v1/accounts/${id}/following`;
|
||||
let data: Array<AccountDetails> = [];
|
||||
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;
|
||||
console.log(`Get page: ${nextPage}`)
|
||||
let response
|
||||
let page
|
||||
try {
|
||||
response = await fetch(nextPage);
|
||||
response = await fetch(nextPage)
|
||||
if (response.status !== 200) {
|
||||
throw new Error('HTTP request failed');
|
||||
throw new Error('HTTP request failed')
|
||||
}
|
||||
page = await response.json();
|
||||
page = await response.json()
|
||||
} catch (e) {
|
||||
logError(`Error while retrieving followers for ${handle}.`);
|
||||
break;
|
||||
logError(`Error while retrieving followers for ${handle}.`)
|
||||
break
|
||||
}
|
||||
if (!page.map) {
|
||||
break;
|
||||
break
|
||||
}
|
||||
page = page.map((entry: AccountDetails) => {
|
||||
if (entry.acct && !/@/.test(entry.acct)) {
|
||||
// make sure the domain is always there
|
||||
entry.acct = `${entry.acct}@${domain}`;
|
||||
entry.acct = `${entry.acct}@${domain}`
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
data = [...data, ...page];
|
||||
nextPage = getNextPage(response.headers.get('Link'));
|
||||
return entry
|
||||
})
|
||||
data = [...data, ...page]
|
||||
nextPage = getNextPage(response.headers.get('Link'))
|
||||
}
|
||||
return data;
|
||||
return data
|
||||
}
|
||||
export 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;
|
||||
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 directFollowIds = new Set(directFollows.map(({ acct }) => acct))
|
||||
directFollowIds.add(handle.replace(/^@/, ''))
|
||||
|
||||
const indirectFollowLists: Array<Array<AccountDetails>> = [];
|
||||
const indirectFollowLists: Array<Array<AccountDetails>> = []
|
||||
|
||||
const updateList = debounce(() => {
|
||||
let indirectFollows: Array<AccountDetails> = [].concat(
|
||||
[],
|
||||
...indirectFollowLists
|
||||
);
|
||||
const indirectFollowMap = new Map();
|
||||
)
|
||||
const indirectFollowMap = new Map()
|
||||
|
||||
indirectFollows
|
||||
.filter(
|
||||
|
@ -115,50 +117,50 @@ export async function accountFofs(
|
|||
({ acct, discoverable }) => !directFollowIds.has(acct) && discoverable
|
||||
)
|
||||
.map((account) => {
|
||||
const acct = account.acct;
|
||||
const acct = account.acct
|
||||
if (indirectFollowMap.has(acct)) {
|
||||
const otherAccount = indirectFollowMap.get(acct);
|
||||
const otherAccount = indirectFollowMap.get(acct)
|
||||
account.followed_by = new Set([
|
||||
...Array.from(account.followed_by.values()),
|
||||
...otherAccount.followed_by,
|
||||
]);
|
||||
])
|
||||
}
|
||||
indirectFollowMap.set(acct, account);
|
||||
});
|
||||
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.followed_by.size - a.followed_by.size
|
||||
}
|
||||
return b.followers_count - a.followers_count;
|
||||
});
|
||||
return b.followers_count - a.followers_count
|
||||
})
|
||||
|
||||
setFollows(list);
|
||||
}, 2000);
|
||||
setFollows(list)
|
||||
}, 2000)
|
||||
|
||||
await Promise.all(
|
||||
directFollows.map(async ({ acct }) => {
|
||||
const follows = await accountFollows(acct, 200, logError);
|
||||
progress++;
|
||||
setProgress([progress, directFollows.length]);
|
||||
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()
|
||||
})
|
||||
);
|
||||
)
|
||||
|
||||
updateList.flush();
|
||||
updateList.flush()
|
||||
}
|
||||
function getNextPage(linkHeader: string | null): string | null {
|
||||
if (!linkHeader) {
|
||||
return null;
|
||||
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"/);
|
||||
const match = linkHeader.match(/<(.+)>; rel="next"/)
|
||||
if (match && match.length > 0) {
|
||||
return match[1];
|
||||
return match[1]
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue