replies timeline only, appview-less bluesky client
at main 5.2 kB view raw
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>