Compare commits

..

No commits in common. "main" and "astra/cleanup" have entirely different histories.

7 changed files with 93 additions and 215 deletions

View file

@ -44,9 +44,7 @@ we use our own CI/CD workflow at [`.forgejo/workflows/deploy.yaml`](.forgejo/wor
## theming ## theming
the colors are designated in [`src/app.css`](src/app.css) as variables, go crazy with them currently the only way to theme the app is to edit css in the components directly, glhf
the rest is done by editing the css files and style tags directly, good luck
relevant files: relevant files:

View file

@ -9,29 +9,27 @@ export class Config {
static readonly PDS_URL: string = "https://pds.witchcraft.systems"; static readonly PDS_URL: string = "https://pds.witchcraft.systems";
/** /**
* The base URL of the frontend service for linking to replies/quotes/accounts etc. * The base URL of the frontend service for linking to replies
* @default "https://deer.social" * @default "https://deer.social"
*/ */
static readonly FRONTEND_URL: string = "https://deer.social"; static readonly FRONTEND_URL: string = "https://deer.social";
/** /**
* Maximum number of posts to fetch from the PDS per request * Maximum number of posts to show in the feed (across all users)
* Should be around 20 for about 10 users on the pds * @default 100
* 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 = 20; static readonly MAX_POSTS: number = 100;
/** /**
* Footer text for the dashboard, you probably want to change this * Footer text for the dashboard
* @default "Astrally projected from witchcraft.systems"
*/ */
static readonly FOOTER_TEXT: string = static readonly FOOTER_TEXT: string =
"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>)"; "Astrally projected from <a href='https://witchcraft.systems' target='_blank'>witchcraft.systems</a>";
/** /**
* Whether to show the posts that are in the future * Whether to show the posts that are in the future
* @default false * @default false
*/ */
static readonly SHOW_FUTURE_POSTS: boolean = false; static readonly SHOW_FUTURE_POSTS: boolean = false;
} }

5
deno.lock generated
View file

@ -8,7 +8,6 @@
"npm:@tsconfig/svelte@^5.0.4": "5.0.4", "npm:@tsconfig/svelte@^5.0.4": "5.0.4",
"npm:moment@^2.30.1": "2.30.1", "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-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:svelte@^5.23.1": "5.28.1_acorn@8.14.1",
"npm:typescript@~5.7.2": "5.7.3", "npm:typescript@~5.7.2": "5.7.3",
"npm:vite@^6.3.1": "6.3.2_picomatch@4.0.2" "npm:vite@^6.3.1": "6.3.2_picomatch@4.0.2"
@ -416,9 +415,6 @@
"typescript" "typescript"
] ]
}, },
"svelte-infinite-loading@1.4.0": {
"integrity": "sha512-Jo+f/yr/HmZQuIiiKKzAHVFXdAUWHW2RBbrcQTil8JVk1sCm/riy7KTJVzjBgQvHasrFQYKF84zvtc9/Y4lFYg=="
},
"svelte@5.28.1_acorn@8.14.1": { "svelte@5.28.1_acorn@8.14.1": {
"integrity": "sha512-iOa9WmfNG95lSOSJdMhdjJ4Afok7IRAQYXpbnxhd5EINnXseG0GVa9j6WPght4eX78XfFez45Fi+uRglGKPV/Q==", "integrity": "sha512-iOa9WmfNG95lSOSJdMhdjJ4Afok7IRAQYXpbnxhd5EINnXseG0GVa9j6WPght4eX78XfFez45Fi+uRglGKPV/Q==",
"dependencies": [ "dependencies": [
@ -480,7 +476,6 @@
"npm:@tsconfig/svelte@^5.0.4", "npm:@tsconfig/svelte@^5.0.4",
"npm:moment@^2.30.1", "npm:moment@^2.30.1",
"npm:svelte-check@^4.1.5", "npm:svelte-check@^4.1.5",
"npm:svelte-infinite-loading@^1.4.0",
"npm:svelte@^5.23.1", "npm:svelte@^5.23.1",
"npm:typescript@~5.7.2", "npm:typescript@~5.7.2",
"npm:vite@^6.3.1" "npm:vite@^6.3.1"

View file

@ -13,8 +13,7 @@
"@atcute/bluesky": "^2.0.2", "@atcute/bluesky": "^2.0.2",
"@atcute/client": "^3.0.1", "@atcute/client": "^3.0.1",
"@atcute/identity-resolver": "^0.1.2", "@atcute/identity-resolver": "^0.1.2",
"moment": "^2.30.1", "moment": "^2.30.1"
"svelte-infinite-loading": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",

View file

@ -1,36 +1,10 @@
<script lang="ts"> <script lang="ts">
import PostComponent from "./lib/PostComponent.svelte"; import PostComponent from "./lib/PostComponent.svelte";
import AccountComponent from "./lib/AccountComponent.svelte"; import AccountComponent from "./lib/AccountComponent.svelte";
import InfiniteLoading from "svelte-infinite-loading"; import { fetchAllPosts, Post, getAllMetadataFromPds } from "./lib/pdsfetch";
import { getNextPosts, Post, getAllMetadataFromPds } from "./lib/pdsfetch";
import { Config } from "../config"; import { Config } from "../config";
const postsPromise = fetchAllPosts();
const accountsPromise = getAllMetadataFromPds(); 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> </script>
<main> <main>
@ -52,14 +26,17 @@
<p>Error: {error.message}</p> <p>Error: {error.message}</p>
{/await} {/await}
<div id="Feed"> {#await postsPromise}
<div id="spacer"></div> <p>Loading...</p>
{#each posts as postObject} {:then postsData}
<PostComponent post={postObject as Post} /> <div id="Feed">
{/each} <div id="spacer"></div>
<InfiniteLoading on:infinite={onInfinite} distance={3000} /> {#each postsData as postObject}
<div id="spacer"></div> <PostComponent post={postObject as Post} />
</div> {/each}
<div id="spacer"></div>
</div>
{/await}
</div> </div>
</main> </main>

View file

@ -113,7 +113,7 @@
<div id="carouselControls"> <div id="carouselControls">
<button <button
id="prevBtn" id="prevBtn"
onclick={prevImage} on:click={prevImage}
disabled={currentImageIndex === 0}>←</button disabled={currentImageIndex === 0}>←</button
> >
<div id="carouselIndicators"> <div id="carouselIndicators">
@ -125,7 +125,7 @@
</div> </div>
<button <button
id="nextBtn" id="nextBtn"
onclick={nextImage} on:click={nextImage}
disabled={currentImageIndex === post.imagesCid.length - 1} disabled={currentImageIndex === post.imagesCid.length - 1}
>→</button >→</button
> >
@ -213,10 +213,6 @@
#postText { #postText {
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow-wrap: break-word;
word-wrap: normal;
word-break: break-word;
hyphens: none;
} }
#headerText { #headerText {
margin-left: 10px; margin-left: 10px;

View file

@ -18,15 +18,11 @@ import { Config } from "../../config";
// import { AppBskyActorDefs } from "@atcute/client/lexicons"; // import { AppBskyActorDefs } from "@atcute/client/lexicons";
interface AccountMetadata { interface AccountMetadata {
did: At.Did; did: string;
displayName: string; displayName: string;
handle: string; handle: string;
avatarCid: string | null; avatarCid: string | null;
currentCursor?: string;
} }
let accountsMetadata: AccountMetadata[] = [];
interface atUriObject { interface atUriObject {
repo: string; repo: string;
collection: string; collection: string;
@ -49,7 +45,7 @@ class Post {
constructor( constructor(
record: ComAtprotoRepoListRecords.Record, record: ComAtprotoRepoListRecords.Record,
account: AccountMetadata, account: AccountMetadata
) { ) {
this.postCid = record.cid; this.postCid = record.cid;
this.recordName = processAtUri(record.uri).rkey; this.recordName = processAtUri(record.uri).rkey;
@ -72,7 +68,7 @@ class Post {
switch (post.embed?.$type) { switch (post.embed?.$type) {
case "app.bsky.embed.images": case "app.bsky.embed.images":
this.imagesCid = post.embed.images.map( this.imagesCid = post.embed.images.map(
(imageRecord: any) => imageRecord.image.ref.$link, (imageRecord: any) => imageRecord.image.ref.$link
); );
break; break;
case "app.bsky.embed.video": case "app.bsky.embed.video":
@ -86,7 +82,7 @@ class Post {
switch (post.embed.media.$type) { switch (post.embed.media.$type) {
case "app.bsky.embed.images": case "app.bsky.embed.images":
this.imagesCid = post.embed.media.images.map( this.imagesCid = post.embed.media.images.map(
(imageRecord) => imageRecord.image.ref.$link, (imageRecord) => imageRecord.image.ref.$link
); );
break; break;
@ -122,8 +118,8 @@ const getDidsFromPDS = async (): Promise<At.Did[]> => {
return data.repos.map((repo: any) => repo.did) as At.Did[]; return data.repos.map((repo: any) => repo.did) as At.Did[];
}; };
const getAccountMetadata = async ( const getAccountMetadata = async (
did: `did:${string}:${string}`, did: `did:${string}:${string}`
) => { ): Promise<AccountMetadata> => {
// gonna assume self exists in the app.bsky.actor.profile // gonna assume self exists in the app.bsky.actor.profile
try { try {
const { data } = await rpc.get("com.atproto.repo.getRecord", { const { data } = await rpc.get("com.atproto.repo.getRecord", {
@ -147,7 +143,12 @@ const getAccountMetadata = async (
return account; return account;
} catch (e) { } catch (e) {
console.error(`Error fetching metadata for ${did}:`, e); console.error(`Error fetching metadata for ${did}:`, e);
return null; return {
did: "error",
displayName: "",
avatarCid: null,
handle: "error",
};
} }
}; };
@ -156,9 +157,33 @@ const getAllMetadataFromPds = async (): Promise<AccountMetadata[]> => {
const metadata = await Promise.all( const metadata = await Promise.all(
dids.map(async (repo: `did:${string}:${string}`) => { dids.map(async (repo: `did:${string}:${string}`) => {
return await getAccountMetadata(repo); return await getAccountMetadata(repo);
}), })
); );
return metadata.filter((account) => account !== null) as AccountMetadata[]; 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,
};
}
}; };
const identityResolve = async (did: At.Did) => { const identityResolve = async (did: At.Did) => {
@ -171,7 +196,7 @@ const identityResolve = async (did: At.Did) => {
if (did.startsWith("did:plc:") || did.startsWith("did:web:")) { if (did.startsWith("did:plc:") || did.startsWith("did:web:")) {
const doc = await resolver.resolve( const doc = await resolver.resolve(
did as `did:plc:${string}` | `did:web:${string}`, did as `did:plc:${string}` | `did:web:${string}`
); );
return doc; return doc;
} else { } else {
@ -194,146 +219,36 @@ const blueskyHandleFromDid = async (did: At.Did) => {
} }
}; };
interface PostsAcc { const fetchAllPosts = async () => {
posts: ComAtprotoRepoListRecords.Record[]; const users: AccountMetadata[] = await getAllMetadataFromPds();
account: AccountMetadata; const postRecords = await Promise.all(
} users.map(
const getCutoffDate = (postAccounts: PostsAcc[]) => { async (metadata: AccountMetadata) => await fetchPosts(metadata.did)
const now = Date.now(); )
let cutoffDate: Date | null = null; );
postAccounts.forEach((postAcc) => { const validPostRecords = postRecords.filter((record) => !record.error);
const latestPost = new Date( const posts: Post[] = validPostRecords.flatMap((userFetch) =>
(postAcc.posts[postAcc.posts.length - 1].value as AppBskyFeedPost.Record) userFetch.records.map((record) => {
.createdAt, const user = users.find(
); (user: AccountMetadata) => user.did == userFetch.did
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 (!user) {
}); throw new Error(`User with DID ${userFetch.did} not found`);
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();
}
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,
};
} }
}), return new Post(record, user);
})
); );
const recordsFiltered = postsAcc.filter((postAcc) =>
postAcc.posts.length > 0 posts.sort((a, b) => b.timestamp - a.timestamp);
);
const cutoffDate = getCutoffDate(recordsFiltered); if(!Config.SHOW_FUTURE_POSTS) {
const recordsCutoff = filterPostsByDate(recordsFiltered, cutoffDate); // Filter out posts that are in the future
// 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(); const now = Date.now();
records = records.filter((post) => { const filteredPosts = posts.filter((post) => post.timestamp <= now);
const postDate = new Date( return filteredPosts.slice(0, Config.MAX_POSTS);
(post.value as AppBskyFeedPost.Record).createdAt,
).getTime();
return postDate <= now;
});
} }
const newPosts = records.map((record) => { return posts.slice(0, Config.MAX_POSTS);
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 }; export type { AccountMetadata };