diff --git a/README.md b/README.md index 25b3cfb..d9eb2ea 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,9 @@ we use our own CI/CD workflow at [`.forgejo/workflows/deploy.yaml`](.forgejo/wor ## theming -currently the only way to theme the app is to edit css in the components directly, glhf +the colors are designated in [`src/app.css`](src/app.css) as variables, go crazy with them + +the rest is done by editing the css files and style tags directly, good luck relevant files: diff --git a/config.ts b/config.ts index 2b1b511..8d09cf6 100644 --- a/config.ts +++ b/config.ts @@ -9,27 +9,29 @@ export class Config { static readonly PDS_URL: string = "https://pds.witchcraft.systems"; /** - * The base URL of the frontend service for linking to replies + * The base URL of the frontend service for linking to replies/quotes/accounts etc. * @default "https://deer.social" */ static readonly FRONTEND_URL: string = "https://deer.social"; /** - * Maximum number of posts to show in the feed (across all users) - * @default 100 + * Maximum number of posts to fetch from the PDS per request + * Should be around 20 for about 10 users on the pds + * The more users you have, the lower the number should be + * since sorting is slow and is done on the frontend + * @default 20 */ - static readonly MAX_POSTS: number = 100; + static readonly MAX_POSTS: number = 20; /** - * Footer text for the dashboard - * @default "Astrally projected from witchcraft.systems" + * Footer text for the dashboard, you probably want to change this */ static readonly FOOTER_TEXT: string = - "Astrally projected from witchcraft.systems"; + "Astrally projected from witchcraft.systems

Source (github mirror)"; - /** - * Whether to show the posts that are in the future - * @default false - */ - static readonly SHOW_FUTURE_POSTS: boolean = false; + /** + * Whether to show the posts that are in the future + * @default false + */ + static readonly SHOW_FUTURE_POSTS: boolean = false; } diff --git a/deno.lock b/deno.lock index 0616852..724a5c0 100644 --- a/deno.lock +++ b/deno.lock @@ -8,6 +8,7 @@ "npm:@tsconfig/svelte@^5.0.4": "5.0.4", "npm:moment@^2.30.1": "2.30.1", "npm:svelte-check@^4.1.5": "4.1.6_svelte@5.28.1__acorn@8.14.1_typescript@5.7.3", + "npm:svelte-infinite-loading@^1.4.0": "1.4.0", "npm:svelte@^5.23.1": "5.28.1_acorn@8.14.1", "npm:typescript@~5.7.2": "5.7.3", "npm:vite@^6.3.1": "6.3.2_picomatch@4.0.2" @@ -415,6 +416,9 @@ "typescript" ] }, + "svelte-infinite-loading@1.4.0": { + "integrity": "sha512-Jo+f/yr/HmZQuIiiKKzAHVFXdAUWHW2RBbrcQTil8JVk1sCm/riy7KTJVzjBgQvHasrFQYKF84zvtc9/Y4lFYg==" + }, "svelte@5.28.1_acorn@8.14.1": { "integrity": "sha512-iOa9WmfNG95lSOSJdMhdjJ4Afok7IRAQYXpbnxhd5EINnXseG0GVa9j6WPght4eX78XfFez45Fi+uRglGKPV/Q==", "dependencies": [ @@ -476,6 +480,7 @@ "npm:@tsconfig/svelte@^5.0.4", "npm:moment@^2.30.1", "npm:svelte-check@^4.1.5", + "npm:svelte-infinite-loading@^1.4.0", "npm:svelte@^5.23.1", "npm:typescript@~5.7.2", "npm:vite@^6.3.1" diff --git a/package.json b/package.json index 9f84465..1db6461 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "@atcute/bluesky": "^2.0.2", "@atcute/client": "^3.0.1", "@atcute/identity-resolver": "^0.1.2", - "moment": "^2.30.1" + "moment": "^2.30.1", + "svelte-infinite-loading": "^1.4.0" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.3", diff --git a/src/App.svelte b/src/App.svelte index fa5a5c1..733320e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,10 +1,36 @@
@@ -26,17 +52,14 @@

Error: {error.message}

{/await} - {#await postsPromise} -

Loading...

- {:then postsData} -
-
- {#each postsData as postObject} - - {/each} -
-
- {/await} +
+
+ {#each posts as postObject} + + {/each} + +
+
diff --git a/src/lib/PostComponent.svelte b/src/lib/PostComponent.svelte index c7c59ac..dc0b874 100644 --- a/src/lib/PostComponent.svelte +++ b/src/lib/PostComponent.svelte @@ -113,7 +113,7 @@
@@ -125,7 +125,7 @@
@@ -213,6 +213,10 @@ #postText { margin: 0; padding: 0; + overflow-wrap: break-word; + word-wrap: normal; + word-break: break-word; + hyphens: none; } #headerText { margin-left: 10px; diff --git a/src/lib/pdsfetch.ts b/src/lib/pdsfetch.ts index 0d36e8d..79edab0 100644 --- a/src/lib/pdsfetch.ts +++ b/src/lib/pdsfetch.ts @@ -18,11 +18,15 @@ import { Config } from "../../config"; // import { AppBskyActorDefs } from "@atcute/client/lexicons"; interface AccountMetadata { - did: string; + did: At.Did; displayName: string; handle: string; avatarCid: string | null; + currentCursor?: string; } + +let accountsMetadata: AccountMetadata[] = []; + interface atUriObject { repo: string; collection: string; @@ -45,7 +49,7 @@ class Post { constructor( record: ComAtprotoRepoListRecords.Record, - account: AccountMetadata + account: AccountMetadata, ) { this.postCid = record.cid; this.recordName = processAtUri(record.uri).rkey; @@ -68,7 +72,7 @@ class Post { switch (post.embed?.$type) { case "app.bsky.embed.images": this.imagesCid = post.embed.images.map( - (imageRecord: any) => imageRecord.image.ref.$link + (imageRecord: any) => imageRecord.image.ref.$link, ); break; case "app.bsky.embed.video": @@ -82,7 +86,7 @@ class Post { switch (post.embed.media.$type) { case "app.bsky.embed.images": this.imagesCid = post.embed.media.images.map( - (imageRecord) => imageRecord.image.ref.$link + (imageRecord) => imageRecord.image.ref.$link, ); break; @@ -118,8 +122,8 @@ const getDidsFromPDS = async (): Promise => { return data.repos.map((repo: any) => repo.did) as At.Did[]; }; const getAccountMetadata = async ( - did: `did:${string}:${string}` -): Promise => { + did: `did:${string}:${string}`, +) => { // gonna assume self exists in the app.bsky.actor.profile try { const { data } = await rpc.get("com.atproto.repo.getRecord", { @@ -143,12 +147,7 @@ const getAccountMetadata = async ( return account; } catch (e) { console.error(`Error fetching metadata for ${did}:`, e); - return { - did: "error", - displayName: "", - avatarCid: null, - handle: "error", - }; + return null; } }; @@ -157,33 +156,9 @@ const getAllMetadataFromPds = async (): Promise => { const metadata = await Promise.all( dids.map(async (repo: `did:${string}:${string}`) => { return await getAccountMetadata(repo); - }) + }), ); - return metadata.filter((account) => account.did !== "error"); -}; - -const fetchPosts = async (did: string) => { - try { - const { data } = await rpc.get("com.atproto.repo.listRecords", { - params: { - repo: did as At.Identifier, - collection: "app.bsky.feed.post", - limit: Config.MAX_POSTS, - }, - }); - return { - records: data.records as ComAtprotoRepoListRecords.Record[], - did: did, - error: false, - }; - } catch (e) { - console.error(`Error fetching posts for ${did}:`, e); - return { - records: [], - did: did, - error: true, - }; - } + return metadata.filter((account) => account !== null) as AccountMetadata[]; }; const identityResolve = async (did: At.Did) => { @@ -196,7 +171,7 @@ const identityResolve = async (did: At.Did) => { if (did.startsWith("did:plc:") || did.startsWith("did:web:")) { const doc = await resolver.resolve( - did as `did:plc:${string}` | `did:web:${string}` + did as `did:plc:${string}` | `did:web:${string}`, ); return doc; } else { @@ -219,36 +194,146 @@ const blueskyHandleFromDid = async (did: At.Did) => { } }; -const fetchAllPosts = async () => { - const users: AccountMetadata[] = await getAllMetadataFromPds(); - const postRecords = await Promise.all( - users.map( - async (metadata: AccountMetadata) => await fetchPosts(metadata.did) - ) - ); - const validPostRecords = postRecords.filter((record) => !record.error); - const posts: Post[] = validPostRecords.flatMap((userFetch) => - userFetch.records.map((record) => { - const user = users.find( - (user: AccountMetadata) => user.did == userFetch.did - ); - if (!user) { - throw new Error(`User with DID ${userFetch.did} not found`); +interface PostsAcc { + posts: ComAtprotoRepoListRecords.Record[]; + account: AccountMetadata; +} +const getCutoffDate = (postAccounts: PostsAcc[]) => { + const now = Date.now(); + let cutoffDate: Date | null = null; + postAccounts.forEach((postAcc) => { + const latestPost = new Date( + (postAcc.posts[postAcc.posts.length - 1].value as AppBskyFeedPost.Record) + .createdAt, + ); + if (!cutoffDate) { + cutoffDate = latestPost; + } else { + if (latestPost > cutoffDate) { + cutoffDate = latestPost; } - return new Post(record, user); - }) - ); + } + }); + if (cutoffDate) { + return cutoffDate; + } else { + return new Date(now); + } +}; - posts.sort((a, b) => b.timestamp - a.timestamp); - - if(!Config.SHOW_FUTURE_POSTS) { - // Filter out posts that are in the future - const now = Date.now(); - const filteredPosts = posts.filter((post) => post.timestamp <= now); - return filteredPosts.slice(0, Config.MAX_POSTS); +const filterPostsByDate = (posts: PostsAcc[], cutoffDate: Date) => { + // filter posts for each account that are older than the cutoff date and save the cursor of the last post included + const filteredPosts: PostsAcc[] = posts.map((postAcc) => { + const filtered = postAcc.posts.filter((post) => { + const postDate = new Date( + (post.value as AppBskyFeedPost.Record).createdAt, + ); + return postDate >= cutoffDate; + }); + if (filtered.length > 0) { + postAcc.account.currentCursor = processAtUri(filtered[filtered.length - 1].uri).rkey; + } + return { + posts: filtered, + account: postAcc.account, + }; + }); + return filteredPosts; +}; +// nightmare function. However it works so I am not touching it +const getNextPosts = async () => { + if (!accountsMetadata.length) { + accountsMetadata = await getAllMetadataFromPds(); } - return posts.slice(0, Config.MAX_POSTS); + const postsAcc: PostsAcc[] = await Promise.all( + accountsMetadata.map(async (account) => { + const posts = await fetchPostsForUser( + account.did, + account.currentCursor || null, + ); + if (posts) { + return { + posts: posts, + account: account, + }; + } else { + return { + posts: [], + account: account, + }; + } + }), + ); + const recordsFiltered = postsAcc.filter((postAcc) => + postAcc.posts.length > 0 + ); + const cutoffDate = getCutoffDate(recordsFiltered); + const recordsCutoff = filterPostsByDate(recordsFiltered, cutoffDate); + // update the accountMetadata with the new cursor + accountsMetadata = accountsMetadata.map((account) => { + const postAcc = recordsCutoff.find( + (postAcc) => postAcc.account.did == account.did, + ); + if (postAcc) { + account.currentCursor = postAcc.account.currentCursor; + } + return account; + } + ); + // throw the records in a big single array + let records = recordsCutoff.flatMap((postAcc) => postAcc.posts); + // sort the records by timestamp + records = records.sort((a, b) => { + const aDate = new Date( + (a.value as AppBskyFeedPost.Record).createdAt, + ).getTime(); + const bDate = new Date( + (b.value as AppBskyFeedPost.Record).createdAt, + ).getTime(); + return bDate - aDate; + }); + // filter out posts that are in the future + if (!Config.SHOW_FUTURE_POSTS) { + const now = Date.now(); + records = records.filter((post) => { + const postDate = new Date( + (post.value as AppBskyFeedPost.Record).createdAt, + ).getTime(); + return postDate <= now; + }); + } + + const newPosts = records.map((record) => { + const account = accountsMetadata.find( + (account) => account.did == processAtUri(record.uri).repo, + ); + if (!account) { + throw new Error( + `Account with DID ${processAtUri(record.uri).repo} not found`, + ); + } + return new Post(record, account); + }); + return newPosts; }; -export { fetchAllPosts, getAllMetadataFromPds, Post }; + +const fetchPostsForUser = async (did: At.Did, cursor: string | null) => { + try { + const { data } = await rpc.get("com.atproto.repo.listRecords", { + params: { + repo: did as At.Identifier, + collection: "app.bsky.feed.post", + limit: Config.MAX_POSTS, + cursor: cursor || undefined, + }, + }); + return data.records as ComAtprotoRepoListRecords.Record[]; + } catch (e) { + console.error(`Error fetching posts for ${did}:`, e); + return null; + } +}; + +export { getAllMetadataFromPds, getNextPosts, Post }; export type { AccountMetadata };