this repo has no description

Dynamic post loading (#2)

Dynamically load the posts so that you can scroll a chronologically sorted timeline infinitely

Reviewed-on: https://git.witchcraft.systems/scientific-witchery/pds-dash/pulls/2
Co-authored-by: ari <ariadna@omg.lol>
Co-committed-by: ari <ariadna@omg.lol>

authored by ari.express ari.express and committed by astrra.space cff9eed1 26aff55d

+12 -9
config.ts
··· 9 static readonly PDS_URL: string = "https://pds.witchcraft.systems"; 10 11 /** 12 - * The base URL of the frontend service for linking to replies 13 * @default "https://deer.social" 14 */ 15 static readonly FRONTEND_URL: string = "https://deer.social"; 16 17 /** 18 - * Maximum number of posts to show in the feed (across all users) 19 - * @default 100 20 */ 21 - static readonly MAX_POSTS: number = 100; 22 23 /** 24 * Footer text for the dashboard ··· 27 static readonly FOOTER_TEXT: string = 28 "Astrally projected from <a href='https://witchcraft.systems' target='_blank'>witchcraft.systems</a>"; 29 30 - /** 31 - * Whether to show the posts that are in the future 32 - * @default false 33 - */ 34 - static readonly SHOW_FUTURE_POSTS: boolean = false; 35 }
··· 9 static readonly PDS_URL: string = "https://pds.witchcraft.systems"; 10 11 /** 12 + * The base URL of the frontend service for linking to replies/quotes/accounts etc. 13 * @default "https://deer.social" 14 */ 15 static readonly FRONTEND_URL: string = "https://deer.social"; 16 17 /** 18 + * Maximum number of posts to fetch from the PDS per request 19 + * Should be around 20 for about 10 users on the pds 20 + * The more users you have, the lower the number should be 21 + * since sorting is slow and is done on the frontend 22 + * @default 20 23 */ 24 + static readonly MAX_POSTS: number = 20; 25 26 /** 27 * Footer text for the dashboard ··· 30 static readonly FOOTER_TEXT: string = 31 "Astrally projected from <a href='https://witchcraft.systems' target='_blank'>witchcraft.systems</a>"; 32 33 + /** 34 + * Whether to show the posts that are in the future 35 + * @default false 36 + */ 37 + static readonly SHOW_FUTURE_POSTS: boolean = false; 38 }
+5
deno.lock
··· 8 "npm:@tsconfig/svelte@^5.0.4": "5.0.4", 9 "npm:moment@^2.30.1": "2.30.1", 10 "npm:svelte-check@^4.1.5": "4.1.6_svelte@5.28.1__acorn@8.14.1_typescript@5.7.3", 11 "npm:svelte@^5.23.1": "5.28.1_acorn@8.14.1", 12 "npm:typescript@~5.7.2": "5.7.3", 13 "npm:vite@^6.3.1": "6.3.2_picomatch@4.0.2" ··· 415 "typescript" 416 ] 417 }, 418 "svelte@5.28.1_acorn@8.14.1": { 419 "integrity": "sha512-iOa9WmfNG95lSOSJdMhdjJ4Afok7IRAQYXpbnxhd5EINnXseG0GVa9j6WPght4eX78XfFez45Fi+uRglGKPV/Q==", 420 "dependencies": [ ··· 476 "npm:@tsconfig/svelte@^5.0.4", 477 "npm:moment@^2.30.1", 478 "npm:svelte-check@^4.1.5", 479 "npm:svelte@^5.23.1", 480 "npm:typescript@~5.7.2", 481 "npm:vite@^6.3.1"
··· 8 "npm:@tsconfig/svelte@^5.0.4": "5.0.4", 9 "npm:moment@^2.30.1": "2.30.1", 10 "npm:svelte-check@^4.1.5": "4.1.6_svelte@5.28.1__acorn@8.14.1_typescript@5.7.3", 11 + "npm:svelte-infinite-loading@^1.4.0": "1.4.0", 12 "npm:svelte@^5.23.1": "5.28.1_acorn@8.14.1", 13 "npm:typescript@~5.7.2": "5.7.3", 14 "npm:vite@^6.3.1": "6.3.2_picomatch@4.0.2" ··· 416 "typescript" 417 ] 418 }, 419 + "svelte-infinite-loading@1.4.0": { 420 + "integrity": "sha512-Jo+f/yr/HmZQuIiiKKzAHVFXdAUWHW2RBbrcQTil8JVk1sCm/riy7KTJVzjBgQvHasrFQYKF84zvtc9/Y4lFYg==" 421 + }, 422 "svelte@5.28.1_acorn@8.14.1": { 423 "integrity": "sha512-iOa9WmfNG95lSOSJdMhdjJ4Afok7IRAQYXpbnxhd5EINnXseG0GVa9j6WPght4eX78XfFez45Fi+uRglGKPV/Q==", 424 "dependencies": [ ··· 480 "npm:@tsconfig/svelte@^5.0.4", 481 "npm:moment@^2.30.1", 482 "npm:svelte-check@^4.1.5", 483 + "npm:svelte-infinite-loading@^1.4.0", 484 "npm:svelte@^5.23.1", 485 "npm:typescript@~5.7.2", 486 "npm:vite@^6.3.1"
+2 -1
package.json
··· 13 "@atcute/bluesky": "^2.0.2", 14 "@atcute/client": "^3.0.1", 15 "@atcute/identity-resolver": "^0.1.2", 16 - "moment": "^2.30.1" 17 }, 18 "devDependencies": { 19 "@sveltejs/vite-plugin-svelte": "^5.0.3",
··· 13 "@atcute/bluesky": "^2.0.2", 14 "@atcute/client": "^3.0.1", 15 "@atcute/identity-resolver": "^0.1.2", 16 + "moment": "^2.30.1", 17 + "svelte-infinite-loading": "^1.4.0" 18 }, 19 "devDependencies": { 20 "@sveltejs/vite-plugin-svelte": "^5.0.3",
+33 -13
src/App.svelte
··· 1 <script lang="ts"> 2 import PostComponent from "./lib/PostComponent.svelte"; 3 import AccountComponent from "./lib/AccountComponent.svelte"; 4 - import { fetchAllPosts, Post, getAllMetadataFromPds } from "./lib/pdsfetch"; 5 import { Config } from "../config"; 6 - const postsPromise = fetchAllPosts(); 7 const accountsPromise = getAllMetadataFromPds(); 8 </script> 9 10 <main> ··· 26 <p>Error: {error.message}</p> 27 {/await} 28 29 - {#await postsPromise} 30 - <p>Loading...</p> 31 - {:then postsData} 32 - <div id="Feed"> 33 - <div id="spacer"></div> 34 - {#each postsData as postObject} 35 - <PostComponent post={postObject as Post} /> 36 - {/each} 37 - <div id="spacer"></div> 38 - </div> 39 - {/await} 40 </div> 41 </main> 42
··· 1 <script lang="ts"> 2 import PostComponent from "./lib/PostComponent.svelte"; 3 import AccountComponent from "./lib/AccountComponent.svelte"; 4 + import InfiniteLoading from "svelte-infinite-loading"; 5 + import { getNextPosts, Post, getAllMetadataFromPds } from "./lib/pdsfetch"; 6 import { Config } from "../config"; 7 const accountsPromise = getAllMetadataFromPds(); 8 + import { onMount } from "svelte"; 9 + 10 + let posts: Post[] = []; 11 + 12 + onMount(() => { 13 + // Fetch initial posts 14 + getNextPosts().then((initialPosts) => { 15 + posts = initialPosts; 16 + }); 17 + }); 18 + // Infinite loading function 19 + const onInfinite = ({ detail: { loaded, complete } } : { detail : { loaded : () => void, complete : () => void}}) => { 20 + getNextPosts().then((newPosts) => { 21 + if (newPosts.length > 0) { 22 + posts = [...posts, ...newPosts]; 23 + loaded(); 24 + } else { 25 + complete(); 26 + } 27 + }); 28 + }; 29 </script> 30 31 <main> ··· 47 <p>Error: {error.message}</p> 48 {/await} 49 50 + <div id="Feed"> 51 + <div id="spacer"></div> 52 + {#each posts as postObject} 53 + <PostComponent post={postObject as Post} /> 54 + {/each} 55 + <InfiniteLoading on:infinite={onInfinite} 56 + distance={0} 57 + /> 58 + <div id="spacer"></div> 59 + </div> 60 </div> 61 </main> 62
+2 -2
src/lib/PostComponent.svelte
··· 113 <div id="carouselControls"> 114 <button 115 id="prevBtn" 116 - on:click={prevImage} 117 disabled={currentImageIndex === 0}>←</button 118 > 119 <div id="carouselIndicators"> ··· 125 </div> 126 <button 127 id="nextBtn" 128 - on:click={nextImage} 129 disabled={currentImageIndex === post.imagesCid.length - 1} 130 >→</button 131 >
··· 113 <div id="carouselControls"> 114 <button 115 id="prevBtn" 116 + onclick={prevImage} 117 disabled={currentImageIndex === 0}>←</button 118 > 119 <div id="carouselIndicators"> ··· 125 </div> 126 <button 127 id="nextBtn" 128 + onclick={nextImage} 129 disabled={currentImageIndex === post.imagesCid.length - 1} 130 >→</button 131 >
+149 -64
src/lib/pdsfetch.ts
··· 18 // import { AppBskyActorDefs } from "@atcute/client/lexicons"; 19 20 interface AccountMetadata { 21 - did: string; 22 displayName: string; 23 handle: string; 24 avatarCid: string | null; 25 } 26 interface atUriObject { 27 repo: string; 28 collection: string; ··· 45 46 constructor( 47 record: ComAtprotoRepoListRecords.Record, 48 - account: AccountMetadata 49 ) { 50 this.postCid = record.cid; 51 this.recordName = processAtUri(record.uri).rkey; ··· 68 switch (post.embed?.$type) { 69 case "app.bsky.embed.images": 70 this.imagesCid = post.embed.images.map( 71 - (imageRecord: any) => imageRecord.image.ref.$link 72 ); 73 break; 74 case "app.bsky.embed.video": ··· 82 switch (post.embed.media.$type) { 83 case "app.bsky.embed.images": 84 this.imagesCid = post.embed.media.images.map( 85 - (imageRecord) => imageRecord.image.ref.$link 86 ); 87 88 break; ··· 118 return data.repos.map((repo: any) => repo.did) as At.Did[]; 119 }; 120 const getAccountMetadata = async ( 121 - did: `did:${string}:${string}` 122 - ): Promise<AccountMetadata> => { 123 // gonna assume self exists in the app.bsky.actor.profile 124 try { 125 const { data } = await rpc.get("com.atproto.repo.getRecord", { ··· 143 return account; 144 } catch (e) { 145 console.error(`Error fetching metadata for ${did}:`, e); 146 - return { 147 - did: "error", 148 - displayName: "", 149 - avatarCid: null, 150 - handle: "error", 151 - }; 152 } 153 }; 154 ··· 157 const metadata = await Promise.all( 158 dids.map(async (repo: `did:${string}:${string}`) => { 159 return await getAccountMetadata(repo); 160 - }) 161 ); 162 - return metadata.filter((account) => account.did !== "error"); 163 - }; 164 - 165 - const fetchPosts = async (did: string) => { 166 - try { 167 - const { data } = await rpc.get("com.atproto.repo.listRecords", { 168 - params: { 169 - repo: did as At.Identifier, 170 - collection: "app.bsky.feed.post", 171 - limit: Config.MAX_POSTS, 172 - }, 173 - }); 174 - return { 175 - records: data.records as ComAtprotoRepoListRecords.Record[], 176 - did: did, 177 - error: false, 178 - }; 179 - } catch (e) { 180 - console.error(`Error fetching posts for ${did}:`, e); 181 - return { 182 - records: [], 183 - did: did, 184 - error: true, 185 - }; 186 - } 187 }; 188 189 const identityResolve = async (did: At.Did) => { ··· 196 197 if (did.startsWith("did:plc:") || did.startsWith("did:web:")) { 198 const doc = await resolver.resolve( 199 - did as `did:plc:${string}` | `did:web:${string}` 200 ); 201 return doc; 202 } else { ··· 219 } 220 }; 221 222 - const fetchAllPosts = async () => { 223 - const users: AccountMetadata[] = await getAllMetadataFromPds(); 224 - const postRecords = await Promise.all( 225 - users.map( 226 - async (metadata: AccountMetadata) => await fetchPosts(metadata.did) 227 - ) 228 - ); 229 - const validPostRecords = postRecords.filter((record) => !record.error); 230 - const posts: Post[] = validPostRecords.flatMap((userFetch) => 231 - userFetch.records.map((record) => { 232 - const user = users.find( 233 - (user: AccountMetadata) => user.did == userFetch.did 234 ); 235 - if (!user) { 236 - throw new Error(`User with DID ${userFetch.did} not found`); 237 } 238 - return new Post(record, user); 239 - }) 240 ); 241 - 242 - posts.sort((a, b) => b.timestamp - a.timestamp); 243 - 244 - if(!Config.SHOW_FUTURE_POSTS) { 245 - // Filter out posts that are in the future 246 const now = Date.now(); 247 - const filteredPosts = posts.filter((post) => post.timestamp <= now); 248 - return filteredPosts.slice(0, Config.MAX_POSTS); 249 } 250 251 - return posts.slice(0, Config.MAX_POSTS); 252 }; 253 - export { fetchAllPosts, getAllMetadataFromPds, Post }; 254 export type { AccountMetadata };
··· 18 // import { AppBskyActorDefs } from "@atcute/client/lexicons"; 19 20 interface AccountMetadata { 21 + did: At.Did; 22 displayName: string; 23 handle: string; 24 avatarCid: string | null; 25 + currentCursor?: string; 26 } 27 + 28 + let accountsMetadata: AccountMetadata[] = []; 29 + 30 interface atUriObject { 31 repo: string; 32 collection: string; ··· 49 50 constructor( 51 record: ComAtprotoRepoListRecords.Record, 52 + account: AccountMetadata, 53 ) { 54 this.postCid = record.cid; 55 this.recordName = processAtUri(record.uri).rkey; ··· 72 switch (post.embed?.$type) { 73 case "app.bsky.embed.images": 74 this.imagesCid = post.embed.images.map( 75 + (imageRecord: any) => imageRecord.image.ref.$link, 76 ); 77 break; 78 case "app.bsky.embed.video": ··· 86 switch (post.embed.media.$type) { 87 case "app.bsky.embed.images": 88 this.imagesCid = post.embed.media.images.map( 89 + (imageRecord) => imageRecord.image.ref.$link, 90 ); 91 92 break; ··· 122 return data.repos.map((repo: any) => repo.did) as At.Did[]; 123 }; 124 const getAccountMetadata = async ( 125 + did: `did:${string}:${string}`, 126 + ) => { 127 // gonna assume self exists in the app.bsky.actor.profile 128 try { 129 const { data } = await rpc.get("com.atproto.repo.getRecord", { ··· 147 return account; 148 } catch (e) { 149 console.error(`Error fetching metadata for ${did}:`, e); 150 + return null; 151 } 152 }; 153 ··· 156 const metadata = await Promise.all( 157 dids.map(async (repo: `did:${string}:${string}`) => { 158 return await getAccountMetadata(repo); 159 + }), 160 ); 161 + return metadata.filter((account) => account !== null) as AccountMetadata[]; 162 }; 163 164 const identityResolve = async (did: At.Did) => { ··· 171 172 if (did.startsWith("did:plc:") || did.startsWith("did:web:")) { 173 const doc = await resolver.resolve( 174 + did as `did:plc:${string}` | `did:web:${string}`, 175 ); 176 return doc; 177 } else { ··· 194 } 195 }; 196 197 + interface PostsAcc { 198 + posts: ComAtprotoRepoListRecords.Record[]; 199 + account: AccountMetadata; 200 + } 201 + const getCutoffDate = (postAccounts: PostsAcc[]) => { 202 + const now = Date.now(); 203 + let cutoffDate: Date | null = null; 204 + postAccounts.forEach((postAcc) => { 205 + const latestPost = new Date( 206 + (postAcc.posts[postAcc.posts.length - 1].value as AppBskyFeedPost.Record) 207 + .createdAt, 208 + ); 209 + if (!cutoffDate) { 210 + cutoffDate = latestPost; 211 + } else { 212 + if (latestPost > cutoffDate) { 213 + cutoffDate = latestPost; 214 + } 215 + } 216 + }); 217 + if (cutoffDate) { 218 + return cutoffDate; 219 + } else { 220 + return new Date(now); 221 + } 222 + }; 223 + 224 + const filterPostsByDate = (posts: PostsAcc[], cutoffDate: Date) => { 225 + // filter posts for each account that are older than the cutoff date and save the cursor of the last post included 226 + const filteredPosts: PostsAcc[] = posts.map((postAcc) => { 227 + const filtered = postAcc.posts.filter((post) => { 228 + const postDate = new Date( 229 + (post.value as AppBskyFeedPost.Record).createdAt, 230 + ); 231 + return postDate >= cutoffDate; 232 + }); 233 + if (filtered.length > 0) { 234 + postAcc.account.currentCursor = processAtUri(filtered[filtered.length - 1].uri).rkey; 235 + } 236 + return { 237 + posts: filtered, 238 + account: postAcc.account, 239 + }; 240 + }); 241 + return filteredPosts; 242 + }; 243 + // nightmare function. However it works so I am not touching it 244 + const getNextPosts = async () => { 245 + if (!accountsMetadata.length) { 246 + accountsMetadata = await getAllMetadataFromPds(); 247 + } 248 + 249 + const postsAcc: PostsAcc[] = await Promise.all( 250 + accountsMetadata.map(async (account) => { 251 + const posts = await fetchPostsForUser( 252 + account.did, 253 + account.currentCursor || null, 254 ); 255 + if (posts) { 256 + return { 257 + posts: posts, 258 + account: account, 259 + }; 260 + } else { 261 + return { 262 + posts: [], 263 + account: account, 264 + }; 265 } 266 + }), 267 + ); 268 + const recordsFiltered = postsAcc.filter((postAcc) => 269 + postAcc.posts.length > 0 270 ); 271 + const cutoffDate = getCutoffDate(recordsFiltered); 272 + const recordsCutoff = filterPostsByDate(recordsFiltered, cutoffDate); 273 + // update the accountMetadata with the new cursor 274 + accountsMetadata = accountsMetadata.map((account) => { 275 + const postAcc = recordsCutoff.find( 276 + (postAcc) => postAcc.account.did == account.did, 277 + ); 278 + if (postAcc) { 279 + account.currentCursor = postAcc.account.currentCursor; 280 + } 281 + return account; 282 + } 283 + ); 284 + // throw the records in a big single array 285 + let records = recordsCutoff.flatMap((postAcc) => postAcc.posts); 286 + // sort the records by timestamp 287 + records = records.sort((a, b) => { 288 + const aDate = new Date( 289 + (a.value as AppBskyFeedPost.Record).createdAt, 290 + ).getTime(); 291 + const bDate = new Date( 292 + (b.value as AppBskyFeedPost.Record).createdAt, 293 + ).getTime(); 294 + return bDate - aDate; 295 + }); 296 + // filter out posts that are in the future 297 + if (!Config.SHOW_FUTURE_POSTS) { 298 const now = Date.now(); 299 + records = records.filter((post) => { 300 + const postDate = new Date( 301 + (post.value as AppBskyFeedPost.Record).createdAt, 302 + ).getTime(); 303 + return postDate <= now; 304 + }); 305 } 306 307 + const newPosts = records.map((record) => { 308 + const account = accountsMetadata.find( 309 + (account) => account.did == processAtUri(record.uri).repo, 310 + ); 311 + if (!account) { 312 + throw new Error( 313 + `Account with DID ${processAtUri(record.uri).repo} not found`, 314 + ); 315 + } 316 + return new Post(record, account); 317 + }); 318 + return newPosts; 319 }; 320 + 321 + const fetchPostsForUser = async (did: At.Did, cursor: string | null) => { 322 + try { 323 + const { data } = await rpc.get("com.atproto.repo.listRecords", { 324 + params: { 325 + repo: did as At.Identifier, 326 + collection: "app.bsky.feed.post", 327 + limit: Config.MAX_POSTS, 328 + cursor: cursor || undefined, 329 + }, 330 + }); 331 + return data.records as ComAtprotoRepoListRecords.Record[]; 332 + } catch (e) { 333 + console.error(`Error fetching posts for ${did}:`, e); 334 + return null; 335 + } 336 + }; 337 + 338 + export { getAllMetadataFromPds, getNextPosts, Post }; 339 export type { AccountMetadata };