appview-less bluesky client
27
fork

Configure Feed

Select the types of activity you want to include in your feed.

move sorting / scoring logic into a separate file

dawn d28cef81 26033e24

+181 -150
+18 -143
src/components/FollowingView.svelte
··· 1 1 <script lang="ts"> 2 2 import { follows, getClient, posts, postActions, currentTime } from '$lib/state.svelte'; 3 - import type { ActorIdentifier, Did, ResourceUri } from '@atcute/lexicons'; 3 + import type { Did } 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 9 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 10 + import { 11 + calculateFollowedUserStats, 12 + calculateInteractionScores, 13 + sortFollowedUser, 14 + type Sort 15 + } from '$lib/following'; 10 16 11 17 interface Props { 12 18 selectedDid: Did; ··· 15 21 16 22 const { selectedDid, selectedClient }: Props = $props(); 17 23 18 - type Sort = 'recent' | 'active' | 'conversational'; 19 24 let followingSort: Sort = $state('active' as Sort); 20 25 21 26 const interactionScores = $derived.by(() => { 22 27 if (followingSort !== 'conversational') return null; 23 - 24 - // eslint-disable-next-line svelte/prefer-svelte-reactivity 25 - const scores = new Map<ActorIdentifier, number>(); 26 - const now = currentTime.getTime(); 27 - 28 - // Interactions are full weight for the first 3 days, then start decreasing linearly 29 - // until 2 weeks, after which they decrease exponentially. 30 - // Keep the same overall exponential timescale as before (half-life ~30 days). 31 - const oneDay = 24 * 60 * 60 * 1000; 32 - const halfLifeMs = 30 * oneDay; 33 - const decayLambda = 0.693 / halfLifeMs; 34 - const threeDays = 3 * oneDay; 35 - const twoWeeks = 14 * oneDay; 36 - 37 - const decay = (time: number) => { 38 - const age = Math.max(0, now - time); 39 - 40 - // Full weight for recent interactions within 3 days 41 - if (age <= threeDays) return 1; 42 - 43 - // Between 3 days and 2 weeks, linearly interpolate down to the value 44 - // that the exponential would have at 2 weeks to keep continuity. 45 - if (age <= twoWeeks) { 46 - const expAtTwoWeeks = Math.exp(-decayLambda * twoWeeks); 47 - const t = (age - threeDays) / (twoWeeks - threeDays); // 0..1 48 - // linear ramp from 1 -> expAtTwoWeeks 49 - return 1 - t * (1 - expAtTwoWeeks); 50 - } 51 - 52 - // After 2 weeks, exponential decay based on the chosen lambda 53 - return Math.exp(-decayLambda * age); 54 - }; 55 - 56 - const replyWeight = 4; 57 - const repostWeight = 2; 58 - const likeWeight = 1; 59 - 60 - const myPosts = posts.get(selectedDid); 61 - if (myPosts) { 62 - for (const post of myPosts.values()) { 63 - if (post.record.reply) { 64 - const parentUri = post.record.reply.parent.uri; 65 - // only try to extract the DID 66 - const match = parentUri.match(/^at:\/\/([^/]+)/); 67 - if (match) { 68 - const targetDid = match[1] as Did; 69 - if (targetDid === selectedDid) continue; 70 - const s = scores.get(targetDid) || 0; 71 - scores.set( 72 - targetDid, 73 - s + replyWeight * decay(new Date(post.record.createdAt).getTime()) 74 - ); 75 - } 76 - } 77 - } 78 - } 79 - 80 - // interactions with others 81 - for (const [key, actions] of postActions) { 82 - const sepIndex = key.indexOf(':'); 83 - if (sepIndex === -1) continue; 84 - const did = key.slice(0, sepIndex) as Did; 85 - const uri = key.slice(sepIndex + 1) as ResourceUri; 86 - 87 - // only try to extract the DID 88 - const match = uri.match(/^at:\/\/([^/]+)/); 89 - if (!match) continue; 90 - const targetDid = match[1] as Did; 91 - 92 - if (did === targetDid) continue; 93 - 94 - let add = 0; 95 - if (actions.like) add += likeWeight; 96 - if (actions.repost) add += repostWeight; 97 - 98 - if (add > 0) { 99 - const targetPosts = posts.get(targetDid); 100 - const post = targetPosts?.get(uri); 101 - if (post) { 102 - const time = new Date(post.record.createdAt).getTime(); 103 - add *= decay(time); 104 - } 105 - scores.set(targetDid, (scores.get(targetDid) || 0) + add); 106 - } 107 - } 108 - 109 - return scores; 28 + return calculateInteractionScores(selectedDid, posts, postActions, currentTime.getTime()); 110 29 }); 111 30 112 31 class FollowedUserStats { ··· 128 47 }); 129 48 } 130 49 131 - data = $derived.by(() => { 132 - const postsMap = posts.get(this.did); 133 - if (!postsMap || postsMap.size === 0) return null; 134 - 135 - let lastPostAtTime = 0; 136 - let activeScore = 0; 137 - let recentPostCount = 0; 138 - const now = currentTime.getTime(); 139 - const quarterPosts = 6 * 60 * 60 * 1000; 140 - const gravity = 2.0; 141 - 142 - for (const post of postsMap.values()) { 143 - const t = new Date(post.record.createdAt).getTime(); 144 - if (t > lastPostAtTime) lastPostAtTime = t; 145 - const ageMs = Math.max(0, now - t); 146 - if (ageMs < quarterPosts) recentPostCount++; 147 - if (followingSort === 'active') { 148 - const ageHours = ageMs / (1000 * 60 * 60); 149 - // score = 1 / t^G 150 - activeScore += 1 / Math.pow(ageHours + 1, gravity); 151 - } 152 - } 153 - 154 - let conversationalScore = 0; 155 - if (followingSort === 'conversational' && interactionScores) 156 - conversationalScore = interactionScores.get(this.did) || 0; 157 - 158 - return { 159 - did: this.did, 160 - lastPostAt: new Date(lastPostAtTime), 161 - activeScore, 162 - conversationalScore, 163 - recentPostCount 164 - }; 165 - }); 50 + data = $derived.by(() => 51 + calculateFollowedUserStats( 52 + followingSort, 53 + selectedDid, 54 + posts, 55 + interactionScores, 56 + currentTime.getTime() 57 + ) 58 + ); 166 59 } 167 60 168 61 const followsMap = $derived(follows.get(selectedDid)); 169 - 170 62 const userStatsList = $derived( 171 63 followsMap ? Array.from(followsMap.values()).map((f) => new FollowedUserStats(f.subject)) : [] 172 64 ); 173 - 174 65 const following = $derived(userStatsList.filter((u) => u.data !== null)); 175 - 176 66 const sortedFollowing = $derived( 177 - [...following].sort((a, b) => { 178 - const statsA = a.data!; 179 - const statsB = b.data!; 180 - if (followingSort === 'conversational') { 181 - if (Math.abs(statsB.conversationalScore - statsA.conversationalScore) > 0.1) 182 - // sort based on conversational score 183 - return statsB.conversationalScore - statsA.conversationalScore; 184 - } else { 185 - if (followingSort === 'active') 186 - if (Math.abs(statsB.activeScore - statsA.activeScore) > 0.0001) 187 - // sort based on activity 188 - return statsB.activeScore - statsA.activeScore; 189 - } 190 - // use recent if scores are similar / we are using recent mode 191 - return statsB.lastPostAt.getTime() - statsA.lastPostAt.getTime(); 192 - }) 67 + [...following].sort((a, b) => sortFollowedUser(followingSort, a.data!, b.data!)) 193 68 ); 194 69 195 70 let listHeight = $state(0);
+154
src/lib/following.ts
··· 1 + import type { ActorIdentifier, Did, ResourceUri } from '@atcute/lexicons'; 2 + import type { PostWithUri } from './at/fetch'; 3 + import type { PostActions } from './thread'; 4 + 5 + export type Sort = 'recent' | 'active' | 'conversational'; 6 + 7 + export const sortFollowedUser = ( 8 + sort: Sort, 9 + statsA: NonNullable<ReturnType<typeof calculateFollowedUserStats>>, 10 + statsB: NonNullable<ReturnType<typeof calculateFollowedUserStats>> 11 + ) => { 12 + if (sort === 'conversational') { 13 + if (Math.abs(statsB.conversationalScore - statsA.conversationalScore) > 0.1) 14 + // sort based on conversational score 15 + return statsB.conversationalScore - statsA.conversationalScore; 16 + } else { 17 + if (sort === 'active') 18 + if (Math.abs(statsB.activeScore - statsA.activeScore) > 0.0001) 19 + // sort based on activity 20 + return statsB.activeScore - statsA.activeScore; 21 + } 22 + // use recent if scores are similar / we are using recent mode 23 + return statsB.lastPostAt.getTime() - statsA.lastPostAt.getTime(); 24 + }; 25 + 26 + export const calculateFollowedUserStats = ( 27 + sort: Sort, 28 + user: Did, 29 + posts: Map<Did, Map<ResourceUri, PostWithUri>>, 30 + interactionScores: Map<ActorIdentifier, number> | null, 31 + now: number 32 + ) => { 33 + const postsMap = posts.get(user); 34 + if (!postsMap || postsMap.size === 0) return null; 35 + 36 + let lastPostAtTime = 0; 37 + let activeScore = 0; 38 + let recentPostCount = 0; 39 + const quarterPosts = 6 * 60 * 60 * 1000; 40 + const gravity = 2.0; 41 + 42 + for (const post of postsMap.values()) { 43 + const t = new Date(post.record.createdAt).getTime(); 44 + if (t > lastPostAtTime) lastPostAtTime = t; 45 + const ageMs = Math.max(0, now - t); 46 + if (ageMs < quarterPosts) recentPostCount++; 47 + if (sort === 'active') { 48 + const ageHours = ageMs / (1000 * 60 * 60); 49 + // score = 1 / t^G 50 + activeScore += 1 / Math.pow(ageHours + 1, gravity); 51 + } 52 + } 53 + 54 + let conversationalScore = 0; 55 + if (sort === 'conversational' && interactionScores) 56 + conversationalScore = interactionScores.get(user) || 0; 57 + 58 + return { 59 + did: user, 60 + lastPostAt: new Date(lastPostAtTime), 61 + activeScore, 62 + conversationalScore, 63 + recentPostCount 64 + }; 65 + }; 66 + 67 + export const calculateInteractionScores = ( 68 + user: Did, 69 + posts: Map<Did, Map<ResourceUri, PostWithUri>>, 70 + postActions: Map<`${Did}:${ResourceUri}`, PostActions>, 71 + now: number 72 + ) => { 73 + const scores = new Map<ActorIdentifier, number>(); 74 + 75 + // Interactions are full weight for the first 3 days, then start decreasing linearly 76 + // until 2 weeks, after which they decrease exponentially. 77 + // Keep the same overall exponential timescale as before (half-life ~30 days). 78 + const oneDay = 24 * 60 * 60 * 1000; 79 + const halfLifeMs = 30 * oneDay; 80 + const decayLambda = 0.693 / halfLifeMs; 81 + const threeDays = 3 * oneDay; 82 + const twoWeeks = 14 * oneDay; 83 + 84 + const decay = (time: number) => { 85 + const age = Math.max(0, now - time); 86 + 87 + // Full weight for recent interactions within 3 days 88 + if (age <= threeDays) return 1; 89 + 90 + // Between 3 days and 2 weeks, linearly interpolate down to the value 91 + // that the exponential would have at 2 weeks to keep continuity. 92 + if (age <= twoWeeks) { 93 + const expAtTwoWeeks = Math.exp(-decayLambda * twoWeeks); 94 + const t = (age - threeDays) / (twoWeeks - threeDays); // 0..1 95 + // linear ramp from 1 -> expAtTwoWeeks 96 + return 1 - t * (1 - expAtTwoWeeks); 97 + } 98 + 99 + // After 2 weeks, exponential decay based on the chosen lambda 100 + return Math.exp(-decayLambda * age); 101 + }; 102 + 103 + const replyWeight = 4; 104 + const repostWeight = 2; 105 + const likeWeight = 1; 106 + 107 + const myPosts = posts.get(user); 108 + if (myPosts) { 109 + for (const post of myPosts.values()) { 110 + if (post.record.reply) { 111 + const parentUri = post.record.reply.parent.uri; 112 + // only try to extract the DID 113 + const match = parentUri.match(/^at:\/\/([^/]+)/); 114 + if (match) { 115 + const targetDid = match[1] as Did; 116 + if (targetDid === user) continue; 117 + const s = scores.get(targetDid) || 0; 118 + scores.set(targetDid, s + replyWeight * decay(new Date(post.record.createdAt).getTime())); 119 + } 120 + } 121 + } 122 + } 123 + 124 + // interactions with others 125 + for (const [key, actions] of postActions) { 126 + const sepIndex = key.indexOf(':'); 127 + if (sepIndex === -1) continue; 128 + const did = key.slice(0, sepIndex) as Did; 129 + const uri = key.slice(sepIndex + 1) as ResourceUri; 130 + 131 + // only try to extract the DID 132 + const match = uri.match(/^at:\/\/([^/]+)/); 133 + if (!match) continue; 134 + const targetDid = match[1] as Did; 135 + 136 + if (did === targetDid) continue; 137 + 138 + let add = 0; 139 + if (actions.like) add += likeWeight; 140 + if (actions.repost) add += repostWeight; 141 + 142 + if (add > 0) { 143 + const targetPosts = posts.get(targetDid); 144 + const post = targetPosts?.get(uri); 145 + if (post) { 146 + const time = new Date(post.record.createdAt).getTime(); 147 + add *= decay(time); 148 + } 149 + scores.set(targetDid, (scores.get(targetDid) || 0) + add); 150 + } 151 + } 152 + 153 + return scores; 154 + };
+1 -7
src/lib/state.svelte.ts
··· 7 7 } from './at/client'; 8 8 import { SvelteMap, SvelteDate } from 'svelte/reactivity'; 9 9 import type { Did, InferOutput, ResourceUri } from '@atcute/lexicons'; 10 - import type { Backlink } from './at/constellation'; 11 10 import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch'; 12 11 import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 13 12 import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 14 13 import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; 15 14 import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; 16 15 import { expect } from './result'; 16 + import type { PostActions } from './thread'; 17 17 18 18 export const notificationStream = writable<NotificationsStream | null>(null); 19 19 export const jetstream = writable<JetstreamSubscription | null>(null); 20 20 21 - export type PostActions = { 22 - like: Backlink | null; 23 - repost: Backlink | null; 24 - // reply: Backlink | null; 25 - // quote: Backlink | null; 26 - }; 27 21 export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>(); 28 22 29 23 export const pulsingPostId = writable<string | null>(null);
+8
src/lib/thread.ts
··· 2 2 import type { Account } from './accounts'; 3 3 import { expect } from './result'; 4 4 import type { PostWithUri } from './at/fetch'; 5 + import type { Backlink } from './at/constellation'; 6 + 7 + export type PostActions = { 8 + like: Backlink | null; 9 + repost: Backlink | null; 10 + // reply: Backlink | null; 11 + // quote: Backlink | null; 12 + }; 5 13 6 14 export type ThreadPost = { 7 15 data: PostWithUri;