Something went wrong. Try again.
appview-less bluesky client
Something went wrong. Try again.
5.9 kB · 197 lines
Svelte
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198<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>