1<script lang="ts">
2 import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte';
3 import type { Did } from '@atcute/lexicons';
4 import { type AtpClient } from '$lib/at/client.svelte';
5 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list';
6 import {
7 calculateFollowedUserStats,
8 calculateInteractionScores,
9 sortFollowedUser,
10 type Sort
11 } from '$lib/following';
12 import FollowingItem from './FollowingItem.svelte';
13 import NotLoggedIn from './NotLoggedIn.svelte';
14
15 interface Props {
16 client: AtpClient | undefined;
17 followingSort: Sort;
18 }
19
20 let { client, followingSort = $bindable('active') }: Props = $props();
21
22 const selectedDid = $derived(client?.user?.did);
23 const followsMap = $derived(selectedDid ? follows.get(selectedDid) : undefined);
24
25 // eslint-disable-next-line @typescript-eslint/no-explicit-any
26 let sortedFollowing = $state<{ did: Did; data: any }[]>([]);
27
28 let isLongCalculation = $state(false);
29 let calculationTimer: ReturnType<typeof setTimeout> | undefined;
30
31 // we could update the "now" every second but its pretty unnecessary
32 // so we only do it when we receive new data or sort mode changes
33 let staticNow = $state(Date.now());
34
35 const updateList = async () => {
36 // Reset timer and loading state at start
37 if (calculationTimer) clearTimeout(calculationTimer);
38 isLongCalculation = false;
39
40 if (!followsMap || !selectedDid) {
41 sortedFollowing = [];
42 return;
43 }
44
45 // schedule spinner to appear only if calculation takes > 200ms
46 calculationTimer = setTimeout(() => (isLongCalculation = true), 200);
47 // yield to main thread to allow UI to show spinner/update
48 await new Promise((resolve) => setTimeout(resolve, 0));
49
50 const interactionScores =
51 followingSort === 'conversational'
52 ? calculateInteractionScores(
53 selectedDid,
54 followsMap,
55 allPosts,
56 allBacklinks,
57 replyIndex,
58 staticNow
59 )
60 : null;
61
62 const userStatsList = followsMap.values().map((f) => ({
63 did: f.subject,
64 data: calculateFollowedUserStats(
65 followingSort,
66 f.subject,
67 allPosts,
68 interactionScores,
69 staticNow
70 )
71 }));
72
73 const following = userStatsList.filter((u) => u.data !== null);
74 const sorted = [...following].sort((a, b) => sortFollowedUser(followingSort, a.data!, b.data!));
75
76 sortedFollowing = sorted;
77
78 // Clear timer and remove loading state immediately after done
79 if (calculationTimer) clearTimeout(calculationTimer);
80 isLongCalculation = false;
81 };
82
83 // todo: there is a bug where the view doesn't update and just gets stuck being loaded
84 $effect(() => {
85 // Dependencies that trigger a re-sort
86 // eslint-disable-next-line @typescript-eslint/no-unused-vars
87 const _s = followingSort;
88 // eslint-disable-next-line @typescript-eslint/no-unused-vars
89 const _f = followsMap?.size;
90 // Update time when sort changes
91 staticNow = Date.now();
92
93 updateList();
94 });
95
96 let listHeight = $state(0);
97 let listContainer: HTMLDivElement | undefined = $state();
98
99 const calcHeight = () => {
100 if (!listContainer) return;
101 const footer = document.getElementById('app-footer');
102 const footerHeight = footer?.getBoundingClientRect().height || 0;
103 const top = listContainer.getBoundingClientRect().top;
104 // 24px is our bottom padding
105 listHeight = Math.max(0, window.innerHeight - top - footerHeight - 24);
106 };
107
108 $effect(() => {
109 if (listContainer) {
110 calcHeight();
111 const observer = new ResizeObserver(calcHeight);
112 observer.observe(document.body);
113 return () => observer.disconnect();
114 }
115 });
116</script>
117
118<div class="flex h-full flex-col p-2">
119 <div class="mb-4 flex items-center justify-between gap-2 p-2 px-2 md:gap-4">
120 <div>
121 <h2 class="text-2xl font-bold md:text-3xl">following</h2>
122 <div class="mt-2 flex gap-2">
123 <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div>
124 <div class="h-1 w-11 rounded-full bg-(--nucleus-accent2)"></div>
125 </div>
126 </div>
127 <div class="flex gap-1 text-sm sm:gap-2">
128 {#each ['recent', 'active', 'conversational'] as type (type)}
129 <button
130 class="rounded-sm px-2 py-1 transition-colors {followingSort === type
131 ? 'bg-(--nucleus-accent) text-(--nucleus-bg)'
132 : 'bg-(--nucleus-accent)/10 hover:bg-(--nucleus-accent)/20'}"
133 onclick={() => (followingSort = type as Sort)}
134 >
135 {type}
136 </button>
137 {/each}
138 </div>
139 </div>
140
141 <div class="min-h-0 flex-1" bind:this={listContainer}>
142 {#if !client || !client.user}
143 <NotLoggedIn />
144 {:else if sortedFollowing.length === 0 || isLongCalculation}
145 <div class="flex justify-center py-8">
146 <div
147 class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
148 style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
149 ></div>
150 </div>
151 {:else if listHeight > 0}
152 <VirtualList height={listHeight} itemCount={sortedFollowing.length} itemSize={76}>
153 {#snippet item({ index, style }: { index: number; style: string })}
154 {@const user = sortedFollowing[index]}
155 <FollowingItem
156 {style}
157 did={user.did}
158 stats={user.data!}
159 {client}
160 sort={followingSort}
161 {currentTime}
162 />
163 {/snippet}
164 </VirtualList>
165 {/if}
166 </div>
167</div>