replies timeline only, appview-less bluesky client

better algos for the sorting, refactor FollowingView a ton, just a lot better in general

ptr.pet 449fb9da 7437d892

verified
Changed files
+214 -91
src
+9 -2
src/components/BskyPost.svelte
··· 24 24 import BskyPost from './BskyPost.svelte'; 25 25 import Icon from '@iconify/svelte'; 26 26 import { type Backlink, type BacklinksSource } from '$lib/at/constellation'; 27 - import { clients, postActions, posts, pulsingPostId, type PostActions } from '$lib/state.svelte'; 27 + import { 28 + clients, 29 + postActions, 30 + posts, 31 + pulsingPostId, 32 + type PostActions, 33 + currentTime 34 + } from '$lib/state.svelte'; 28 35 import * as TID from '@atcute/tid'; 29 36 import type { PostWithUri } from '$lib/at/fetch'; 30 37 import { onMount } from 'svelte'; ··· 405 412 title={new Date(record.createdAt).toLocaleString()} 406 413 class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" 407 414 > 408 - {getRelativeTime(new Date(record.createdAt))} 415 + {getRelativeTime(new Date(record.createdAt), currentTime)} 409 416 </span> 410 417 </div> 411 418 <p class="leading-normal text-wrap wrap-break-word">
+194 -85
src/components/FollowingView.svelte
··· 1 1 <script lang="ts"> 2 - import { follows, getClient, posts } from '$lib/state.svelte'; 3 - import type { Did } from '@atcute/lexicons'; 2 + import { follows, getClient, posts, postActions, currentTime } from '$lib/state.svelte'; 3 + import type { ActorIdentifier, Did, ResourceUri } from '@atcute/lexicons'; 4 4 import ProfilePicture from './ProfilePicture.svelte'; 5 5 import { type AtpClient, resolveDidDoc } from '$lib/at/client'; 6 6 import { getRelativeTime } from '$lib/date'; 7 7 import { generateColorForDid } from '$lib/accounts'; 8 8 import { type AtprotoDid } from '@atcute/lexicons/syntax'; 9 + import { flip } from 'svelte/animate'; 10 + import { cubicOut } from 'svelte/easing'; 9 11 10 12 interface Props { 11 13 selectedDid: Did; ··· 14 16 15 17 const { selectedDid, selectedClient }: Props = $props(); 16 18 17 - const burstTimeframeMs = 1000 * 60 * 60; // 1 hour 19 + type Sort = 'recent' | 'active' | 'conversational'; 20 + let followingSort: Sort = $state('active' as Sort); 21 + 22 + const interactionScores = $derived.by(() => { 23 + if (followingSort !== 'conversational') return null; 24 + 25 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 26 + const scores = new Map<ActorIdentifier, number>(); 27 + const now = currentTime.getTime(); 28 + 29 + // Interactions are full weight for the first 3 days, then start decreasing linearly 30 + // until 2 weeks, after which they decrease exponentially. 31 + // Keep the same overall exponential timescale as before (half-life ~30 days). 32 + const oneDay = 24 * 60 * 60 * 1000; 33 + const halfLifeMs = 30 * oneDay; 34 + const decayLambda = 0.693 / halfLifeMs; 35 + const threeDays = 3 * oneDay; 36 + const twoWeeks = 14 * oneDay; 37 + 38 + const decay = (time: number) => { 39 + const age = Math.max(0, now - time); 18 40 19 - type FollowedAccount = { 20 - did: Did; 21 - lastPostAt: Date; 22 - postsInBurst: number; 23 - }; 41 + // Full weight for recent interactions within 3 days 42 + if (age <= threeDays) return 1; 43 + 44 + // Between 3 days and 2 weeks, linearly interpolate down to the value 45 + // that the exponential would have at 2 weeks to keep continuity. 46 + if (age <= twoWeeks) { 47 + const expAtTwoWeeks = Math.exp(-decayLambda * twoWeeks); 48 + const t = (age - threeDays) / (twoWeeks - threeDays); // 0..1 49 + // linear ramp from 1 -> expAtTwoWeeks 50 + return 1 - t * (1 - expAtTwoWeeks); 51 + } 52 + 53 + // After 2 weeks, exponential decay based on the chosen lambda 54 + return Math.exp(-decayLambda * age); 55 + }; 56 + 57 + const replyWeight = 4; 58 + const repostWeight = 2; 59 + const likeWeight = 1; 60 + 61 + const myPosts = posts.get(selectedDid); 62 + if (myPosts) { 63 + for (const post of myPosts.values()) { 64 + if (post.record.reply) { 65 + const parentUri = post.record.reply.parent.uri; 66 + // only try to extract the DID 67 + const match = parentUri.match(/^at:\/\/([^/]+)/); 68 + if (match) { 69 + const targetDid = match[1] as Did; 70 + if (targetDid === selectedDid) continue; 71 + const s = scores.get(targetDid) || 0; 72 + scores.set( 73 + targetDid, 74 + s + replyWeight * decay(new Date(post.record.createdAt).getTime()) 75 + ); 76 + } 77 + } 78 + } 79 + } 80 + 81 + // interactions with others 82 + for (const [key, actions] of postActions) { 83 + const sepIndex = key.indexOf(':'); 84 + if (sepIndex === -1) continue; 85 + const did = key.slice(0, sepIndex) as Did; 86 + const uri = key.slice(sepIndex + 1) as ResourceUri; 87 + 88 + // only try to extract the DID 89 + const match = uri.match(/^at:\/\/([^/]+)/); 90 + if (!match) continue; 91 + const targetDid = match[1] as Did; 92 + 93 + if (did === targetDid) continue; 94 + 95 + let add = 0; 96 + if (actions.like) add += likeWeight; 97 + if (actions.repost) add += repostWeight; 98 + 99 + if (add > 0) { 100 + const targetPosts = posts.get(targetDid); 101 + const post = targetPosts?.get(uri); 102 + if (post) { 103 + const time = new Date(post.record.createdAt).getTime(); 104 + add *= decay(time); 105 + } 106 + scores.set(targetDid, (scores.get(targetDid) || 0) + add); 107 + } 108 + } 109 + 110 + return scores; 111 + }); 24 112 25 113 class FollowedUserStats { 26 114 did: Did; 115 + profile: Promise<string | null | undefined>; 116 + handle: Promise<string>; 117 + 27 118 constructor(did: Did) { 28 119 this.did = did; 120 + this.profile = getClient(did as AtprotoDid) 121 + .then((client) => client.getProfile()) 122 + .then((profile) => { 123 + if (profile.ok) return profile.value.displayName; 124 + return null; 125 + }); 126 + this.handle = resolveDidDoc(did).then((doc) => { 127 + if (doc.ok) return doc.value.handle; 128 + return 'handle.invalid'; 129 + }); 29 130 } 30 131 31 132 data = $derived.by(() => { ··· 33 134 if (!postsMap || postsMap.size === 0) return null; 34 135 35 136 let lastPostAtTime = 0; 36 - let postsInBurst = 0; 37 - const now = Date.now(); 38 - const timeframe = now - burstTimeframeMs; 137 + let activeScore = 0; 138 + let recentPostCount = 0; 139 + const now = currentTime.getTime(); 140 + const quarterPosts = 6 * 60 * 60 * 1000; 141 + const gravity = 2.0; 39 142 40 143 for (const post of postsMap.values()) { 41 144 const t = new Date(post.record.createdAt).getTime(); 42 145 if (t > lastPostAtTime) lastPostAtTime = t; 43 - if (t > timeframe) postsInBurst++; 146 + const ageMs = Math.max(0, now - t); 147 + if (ageMs < quarterPosts) recentPostCount++; 148 + if (followingSort === 'active') { 149 + const ageHours = ageMs / (1000 * 60 * 60); 150 + // score = 1 / t^G 151 + activeScore += 1 / Math.pow(ageHours + 1, gravity); 152 + } 44 153 } 45 154 155 + let conversationalScore = 0; 156 + if (followingSort === 'conversational' && interactionScores) 157 + conversationalScore = interactionScores.get(this.did) || 0; 158 + 46 159 return { 47 160 did: this.did, 48 161 lastPostAt: new Date(lastPostAtTime), 49 - postsInBurst 162 + activeScore, 163 + conversationalScore, 164 + recentPostCount 50 165 }; 51 166 }); 52 167 } 53 - 54 - type Sort = 'recent' | 'active'; 55 - let followingSort: Sort = $state('active' as Sort); 56 168 57 169 const followsMap = $derived(follows.get(selectedDid)); 58 170 ··· 60 172 followsMap ? Array.from(followsMap.values()).map((f) => new FollowedUserStats(f.subject)) : [] 61 173 ); 62 174 63 - const following: FollowedAccount[] = $derived( 64 - userStatsList.map((u) => u.data).filter((d): d is FollowedAccount => d !== null) 65 - ); 175 + const following = $derived(userStatsList.filter((u) => u.data !== null)); 66 176 67 177 const sortedFollowing = $derived( 68 178 [...following].sort((a, b) => { 69 - if (followingSort === 'recent') { 70 - // Sort by last post time descending, then burst descending 71 - const timeA = a.lastPostAt.getTime(); 72 - const timeB = b.lastPostAt.getTime(); 73 - if (timeA !== timeB) return timeB - timeA; 74 - return b.postsInBurst - a.postsInBurst; 179 + const statsA = a.data!; 180 + const statsB = b.data!; 181 + if (followingSort === 'conversational') { 182 + if (Math.abs(statsB.conversationalScore - statsA.conversationalScore) > 0.1) 183 + // sort based on conversational score 184 + return statsB.conversationalScore - statsA.conversationalScore; 75 185 } else { 76 - // Sort by burst descending, then last post time descending 77 - if (b.postsInBurst !== a.postsInBurst) return b.postsInBurst - a.postsInBurst; 78 - return b.lastPostAt.getTime() - a.lastPostAt.getTime(); 186 + if (followingSort === 'active') 187 + if (Math.abs(statsB.activeScore - statsA.activeScore) > 0.0001) 188 + // sort based on activity 189 + return statsB.activeScore - statsA.activeScore; 79 190 } 191 + // use recent if scores are similar / we are using recent mode 192 + return statsB.lastPostAt.getTime() - statsA.lastPostAt.getTime(); 80 193 }) 81 194 ); 195 + </script> 82 196 83 - let highlightedDid: Did | undefined = $state(undefined); 84 - </script> 197 + {#snippet followingItems()} 198 + {#each sortedFollowing as user (user.did)} 199 + {@const stats = user.data!} 200 + {@const lastPostAt = stats.lastPostAt} 201 + {@const relTime = getRelativeTime(lastPostAt, currentTime)} 202 + {@const color = generateColorForDid(user.did)} 203 + <div animate:flip={{ duration: 350, easing: cubicOut }}> 204 + <div 205 + class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 206 + style={`--post-color: ${color};`} 207 + > 208 + <ProfilePicture client={selectedClient} did={user.did} size={10} /> 209 + <div class="min-w-0 flex-1 space-y-1"> 210 + <div 211 + class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)" 212 + style={`--post-color: ${color};`} 213 + > 214 + {#await Promise.all([user.profile, user.handle]) then [displayName, handle]} 215 + <span class="truncate">{displayName || handle}</span> 216 + <span class="truncate text-sm opacity-60">@{handle}</span> 217 + {/await} 218 + </div> 219 + <div class="flex gap-2 text-xs opacity-70"> 220 + <span 221 + class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 222 + ? 'text-(--nucleus-accent)' 223 + : ''} 224 + > 225 + posted {relTime} 226 + {relTime !== 'now' ? 'ago' : ''} 227 + </span> 228 + {#if stats.recentPostCount > 0} 229 + <span class="text-(--nucleus-accent2)"> 230 + {stats.recentPostCount} posts / 6h 231 + </span> 232 + {/if} 233 + {#if followingSort === 'conversational' && stats.conversationalScore > 0} 234 + <span class="ml-auto font-bold text-(--nucleus-accent)"> 235 + ★ {stats.conversationalScore.toFixed(1)} 236 + </span> 237 + {/if} 238 + </div> 239 + </div> 240 + </div> 241 + </div> 242 + {/each} 243 + {/snippet} 85 244 86 245 <div class="p-2"> 87 - <div class="mb-4 flex items-center justify-between px-2"> 246 + <div class="mb-4 flex flex-col justify-between gap-4 px-2 sm:flex-row sm:items-center"> 88 247 <div> 89 248 <h2 class="text-3xl font-bold">following</h2> 90 249 <div class="mt-2 flex gap-2"> ··· 92 251 <div class="h-1 w-11 rounded-full bg-(--nucleus-accent2)"></div> 93 252 </div> 94 253 </div> 95 - <div class="flex gap-2 text-sm"> 96 - {#each ['recent', 'active'] as Sort[] as type (type)} 254 + <div class="flex flex-wrap gap-2 text-sm"> 255 + {#each ['recent', 'active', 'conversational'] as type (type)} 97 256 <button 98 257 class="rounded-sm px-2 py-1 transition-colors {followingSort === type 99 258 ? 'bg-(--nucleus-accent) text-(--nucleus-bg)' 100 259 : 'bg-(--nucleus-accent)/10 hover:bg-(--nucleus-accent)/20'}" 101 - onclick={() => (followingSort = type)} 260 + onclick={() => (followingSort = type as Sort)} 102 261 > 103 262 {type} 104 263 </button> ··· 115 274 ></div> 116 275 </div> 117 276 {:else} 118 - {#each sortedFollowing as user (user.did)} 119 - {@const lastPostAt = user.lastPostAt} 120 - {@const relTime = getRelativeTime(lastPostAt)} 121 - {@const color = generateColorForDid(user.did)} 122 - {@const isHighlighted = highlightedDid === user.did} 123 - {@const displayName = getClient(user.did as AtprotoDid) 124 - .then((client) => client.getProfile()) 125 - .then((profile) => { 126 - if (profile.ok) return profile.value.displayName; 127 - return null; 128 - })} 129 - {@const handle = resolveDidDoc(user.did).then((doc) => { 130 - if (doc.ok) return doc.value.handle; 131 - return 'handle.invalid'; 132 - })} 133 - <!-- svelte-ignore a11y_no_static_element_interactions --> 134 - <div 135 - class="flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors" 136 - style={`background-color: ${isHighlighted ? `color-mix(in srgb, ${color} 20%, transparent)` : 'color-mix(in srgb, var(--nucleus-accent) 7%, transparent)'};`} 137 - onmouseenter={() => (highlightedDid = user.did)} 138 - onmouseleave={() => (highlightedDid = undefined)} 139 - > 140 - <ProfilePicture client={selectedClient} did={user.did} size={10} /> 141 - <div class="min-w-0 flex-1"> 142 - <div 143 - class="flex items-baseline gap-2 font-bold transition-colors" 144 - style={`${isHighlighted ? `color: ${color};` : ''}`} 145 - > 146 - {#await Promise.all([displayName, handle]) then [displayName, handle]} 147 - <span class="truncate">{displayName || handle}</span> 148 - <span class="truncate text-sm opacity-60">@{handle}</span> 149 - {/await} 150 - </div> 151 - <div class="flex gap-2 text-xs opacity-70"> 152 - <span 153 - class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 154 - ? 'text-(--nucleus-accent)' 155 - : ''} 156 - > 157 - posted {relTime} 158 - {relTime !== 'now' ? 'ago' : ''} 159 - </span> 160 - {#if user.postsInBurst > 0} 161 - <span class="font-bold text-(--nucleus-accent2)"> 162 - {user.postsInBurst} posts / 1h 163 - </span> 164 - {/if} 165 - </div> 166 - </div> 167 - </div> 168 - {/each} 277 + {@render followingItems()} 169 278 {/if} 170 279 </div> 171 280 </div>
+2 -3
src/lib/date.ts
··· 1 - export const getRelativeTime = (date: Date) => { 2 - const now = new Date(); 1 + export const getRelativeTime = (date: Date, now: Date = new Date()) => { 3 2 const diff = now.getTime() - date.getTime(); 4 3 const seconds = Math.floor(diff / 1000); 5 4 const minutes = Math.floor(seconds / 60); ··· 9 8 const years = Math.floor(months / 12); 10 9 11 10 if (years > 0) return `${years}y`; 12 - if (months > 0) return `${months}m`; 11 + if (months > 0) return `${months}mo`; 13 12 if (days > 0) return `${days}d`; 14 13 if (hours > 0) return `${hours}h`; 15 14 if (minutes > 0) return `${minutes}m`;
+9 -1
src/lib/state.svelte.ts
··· 1 1 import { writable } from 'svelte/store'; 2 2 import { AtpClient, newPublicClient, type NotificationsStream } from './at/client'; 3 - import { SvelteMap } from 'svelte/reactivity'; 3 + import { SvelteMap, SvelteDate } from 'svelte/reactivity'; 4 4 import type { Did, InferOutput, ResourceUri } from '@atcute/lexicons'; 5 5 import type { Backlink } from './at/constellation'; 6 6 import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch'; ··· 133 133 } 134 134 } 135 135 }; 136 + 137 + export const currentTime = new SvelteDate(); 138 + 139 + if (typeof window !== 'undefined') { 140 + setInterval(() => { 141 + currentTime.setTime(Date.now()); 142 + }, 1000); 143 + }