<script lang="ts">
import {
follows,
allPosts,
postsByDid,
allBacklinks,
currentTime,
replyIndex
} from '$lib/state.svelte';
import type { Did } from '@atcute/lexicons';
import { type AtpClient } from '$lib/at/client.svelte';
import { createVirtualizer } from '@tanstack/svelte-virtual';
import {
calculateFollowedUserStats,
calculateInteractionScores,
sortFollowedUser,
type Sort
} from '$lib/following';
import FollowingItem from './FollowingItem.svelte';
import NotLoggedIn from './NotLoggedIn.svelte';
interface Props {
client: AtpClient | undefined;
followingSort: Sort;
}
let { client, followingSort = $bindable('active') }: Props = $props();
const selectedDid = $derived(client?.user?.did);
const followsMap = $derived(selectedDid ? follows.get(selectedDid) : undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let sortedFollowing = $state<{ did: Did; data: any }[]>([]);
let isLongCalculation = $state(false);
let calculationTimer: ReturnType<typeof setTimeout> | undefined;
// we could update the "now" every second but its pretty unnecessary
// so we only do it when we receive new data or sort mode changes
let staticNow = $state(Date.now());
const updateList = async () => {
// Reset timer and loading state at start
if (calculationTimer) clearTimeout(calculationTimer);
isLongCalculation = false;
if (!followsMap || !selectedDid) {
sortedFollowing = [];
return;
}
// schedule spinner to appear only if calculation takes > 200ms
calculationTimer = setTimeout(() => (isLongCalculation = true), 200);
// yield to main thread to allow UI to show spinner/update
await new Promise((resolve) => setTimeout(resolve, 0));
const interactionScores =
followingSort === 'conversational'
? calculateInteractionScores(
selectedDid,
allPosts,
postsByDid,
allBacklinks,
replyIndex,
staticNow
)
: null;
const userStatsList = followsMap.keys().map((did) => ({
did,
data: calculateFollowedUserStats(
followingSort,
did,
allPosts,
postsByDid,
interactionScores,
staticNow
)
}));
const following = userStatsList.filter((u) => u.data !== null);
const sorted = [...following].sort((a, b) => sortFollowedUser(followingSort, a.data!, b.data!));
sortedFollowing = sorted;
// Clear timer and remove loading state immediately after done
if (calculationTimer) clearTimeout(calculationTimer);
isLongCalculation = false;
};
// todo: there is a bug where the view doesn't update and just gets stuck being loaded
$effect(() => {
// Dependencies that trigger a re-sort
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _s = followingSort;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _f = followsMap?.size;
// Update time when sort changes
staticNow = Date.now();
updateList();
});
let listHeight = $state(0);
let listContainer: HTMLDivElement | undefined = $state();
const calcHeight = () => {
if (!listContainer) return;
const footer = document.getElementById('app-footer');
const footerHeight = footer?.getBoundingClientRect().height || 0;
const top = listContainer.getBoundingClientRect().top;
// 24px is our bottom padding
listHeight = Math.max(0, window.innerHeight - top - footerHeight - 24);
};
$effect(() => {
if (listContainer) {
calcHeight();
const observer = new ResizeObserver(calcHeight);
observer.observe(document.body);
return () => observer.disconnect();
}
});
let parentRef = $state<HTMLDivElement | null>(null);
const virtualizer = createVirtualizer({
count: 0,
getScrollElement: () => parentRef,
estimateSize: () => 76,
overscan: 5
});
$effect(() => {
$virtualizer.setOptions({
count: sortedFollowing.length,
getScrollElement: () => parentRef,
estimateSize: () => 76,
overscan: 5
});
});
</script>
<div class="flex h-full flex-col p-2">
<div class="mb-4 flex items-center justify-between gap-2 p-2 px-2 md:gap-4">
<div>
<h2 class="text-2xl font-bold md:text-3xl">following</h2>
<div class="mt-2 flex gap-2">
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div>
<div class="h-1 w-11 rounded-full bg-(--nucleus-accent2)"></div>
</div>
</div>
<div class="flex gap-1 text-sm sm:gap-2">
{#each ['recent', 'active', 'conversational'] as type (type)}
<button
class="rounded-sm px-2 py-1 transition-colors {followingSort === type
? 'bg-(--nucleus-accent) text-(--nucleus-bg)'
: 'bg-(--nucleus-accent)/10 hover:bg-(--nucleus-accent)/20'}"
onclick={() => (followingSort = type as Sort)}
>
{type}
</button>
{/each}
</div>
</div>
<div class="min-h-0 flex-1" bind:this={listContainer}>
{#if !client || !client.user}
<NotLoggedIn />
{:else if sortedFollowing.length === 0 || isLongCalculation}
<div class="flex justify-center py-8">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
></div>
</div>
{:else if listHeight > 0}
<div
bind:this={parentRef}
style="height: {listHeight}px; overflow-y: auto; overflow-x: hidden; position: relative;"
>
<div style="height: {$virtualizer.getTotalSize()}px; width: 100%; position: relative;">
{#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
{@const user = sortedFollowing[virtualItem.index]}
<FollowingItem
style="position: absolute; top: 0; left: 0; width: 100%; height: {virtualItem.size}px; transform: translateY({virtualItem.start}px);"
did={user.did}
stats={user.data!}
{client}
sort={followingSort}
{currentTime}
/>
{/each}
</div>
</div>
{/if}
</div>
</div>