Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
614f2b4c30
Add links to the source code
All checks were successful
Deploy / Deploy (push) Successful in 39s
2025-04-26 14:46:30 +09:00
72ba577950 Proper post word wrapping (#3)
All checks were successful
Deploy / Deploy (push) Successful in 19s
Co-authored-by: ari <ariadna@omg.lol>
Reviewed-on: #3
2025-04-23 04:10:14 +00:00
ari
3d38e0f68c Update README.md
All checks were successful
Deploy / Deploy (push) Successful in 36s
2025-04-22 05:27:05 +00:00
5eca07724e Distance tweaks for hidden loads
All checks were successful
Deploy / Deploy (push) Successful in 28s
2025-04-22 03:53:05 +00:00
ari
cff9eed1a4 Dynamic post loading (#2)
All checks were successful
Deploy / Deploy (push) Successful in 33s
Dynamically load the posts so that you can scroll a chronologically sorted timeline infinitely

Reviewed-on: #2
Co-authored-by: ari <ariadna@omg.lol>
Co-committed-by: ari <ariadna@omg.lol>
2025-04-22 03:14:37 +00:00
7 changed files with 217 additions and 95 deletions

View file

@ -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:

View file

@ -9,23 +9,25 @@ 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 <a href='https://witchcraft.systems' target='_blank'>witchcraft.systems</a>";
"Astrally projected from <a href='https://witchcraft.systems' target='_blank'>witchcraft.systems</a><br><br><a href='https://git.witchcraft.systems/scientific-witchery/pds-dash' target='_blank'>Source</a> (<a href='https://github.com/witchcraft-systems/pds-dash/' target='_blank'>github mirror</a>)";
/**
* Whether to show the posts that are in the future

5
deno.lock generated
View file

@ -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"

View file

@ -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",

View file

@ -1,10 +1,36 @@
<script lang="ts">
import PostComponent from "./lib/PostComponent.svelte";
import AccountComponent from "./lib/AccountComponent.svelte";
import { fetchAllPosts, Post, getAllMetadataFromPds } from "./lib/pdsfetch";
import InfiniteLoading from "svelte-infinite-loading";
import { getNextPosts, Post, getAllMetadataFromPds } from "./lib/pdsfetch";
import { Config } from "../config";
const postsPromise = fetchAllPosts();
const accountsPromise = getAllMetadataFromPds();
import { onMount } from "svelte";
let posts: Post[] = [];
onMount(() => {
// Fetch initial posts
getNextPosts().then((initialPosts) => {
posts = initialPosts;
});
});
// Infinite loading function
const onInfinite = ({
detail: { loaded, complete },
}: {
detail: { loaded: () => void; complete: () => void };
}) => {
getNextPosts().then((newPosts) => {
console.log("Loading next posts...");
if (newPosts.length > 0) {
posts = [...posts, ...newPosts];
loaded();
} else {
complete();
}
});
};
</script>
<main>
@ -26,17 +52,14 @@
<p>Error: {error.message}</p>
{/await}
{#await postsPromise}
<p>Loading...</p>
{:then postsData}
<div id="Feed">
<div id="spacer"></div>
{#each postsData as postObject}
{#each posts as postObject}
<PostComponent post={postObject as Post} />
{/each}
<InfiniteLoading on:infinite={onInfinite} distance={3000} />
<div id="spacer"></div>
</div>
{/await}
</div>
</main>

View file

@ -113,7 +113,7 @@
<div id="carouselControls">
<button
id="prevBtn"
on:click={prevImage}
onclick={prevImage}
disabled={currentImageIndex === 0}>←</button
>
<div id="carouselIndicators">
@ -125,7 +125,7 @@
</div>
<button
id="nextBtn"
on:click={nextImage}
onclick={nextImage}
disabled={currentImageIndex === post.imagesCid.length - 1}
>→</button
>
@ -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;

View file

@ -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<At.Did[]> => {
return data.repos.map((repo: any) => repo.did) as At.Did[];
};
const getAccountMetadata = async (
did: `did:${string}:${string}`
): Promise<AccountMetadata> => {
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<AccountMetadata[]> => {
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`);
}
return new Post(record, user);
})
);
posts.sort((a, b) => b.timestamp - a.timestamp);
if(!Config.SHOW_FUTURE_POSTS) {
// Filter out posts that are in the future
interface PostsAcc {
posts: ComAtprotoRepoListRecords.Record[];
account: AccountMetadata;
}
const getCutoffDate = (postAccounts: PostsAcc[]) => {
const now = Date.now();
const filteredPosts = posts.filter((post) => post.timestamp <= now);
return filteredPosts.slice(0, Config.MAX_POSTS);
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;
}
}
});
if (cutoffDate) {
return cutoffDate;
} else {
return new Date(now);
}
};
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 };