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
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
currently the only way to theme the app is to edit css in the components directly, glhf
relevant files:

View file

@ -9,29 +9,27 @@ export class Config {
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"
*/
static readonly FRONTEND_URL: string = "https://deer.social";
/**
* 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
* Maximum number of posts to show in the feed (across all users)
* @default 100
*/
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 =
"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
* @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;
}

5
deno.lock generated
View file

@ -8,7 +8,6 @@
"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"
@ -416,9 +415,6 @@
"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": [
@ -480,7 +476,6 @@
"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,8 +13,7 @@
"@atcute/bluesky": "^2.0.2",
"@atcute/client": "^3.0.1",
"@atcute/identity-resolver": "^0.1.2",
"moment": "^2.30.1",
"svelte-infinite-loading": "^1.4.0"
"moment": "^2.30.1"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",

View file

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

View file

@ -113,7 +113,7 @@
<div id="carouselControls">
<button
id="prevBtn"
onclick={prevImage}
on:click={prevImage}
disabled={currentImageIndex === 0}>←</button
>
<div id="carouselIndicators">
@ -125,7 +125,7 @@
</div>
<button
id="nextBtn"
onclick={nextImage}
on:click={nextImage}
disabled={currentImageIndex === post.imagesCid.length - 1}
>→</button
>
@ -213,10 +213,6 @@
#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,15 +18,11 @@ import { Config } from "../../config";
// import { AppBskyActorDefs } from "@atcute/client/lexicons";
interface AccountMetadata {
did: At.Did;
did: string;
displayName: string;
handle: string;
avatarCid: string | null;
currentCursor?: string;
}
let accountsMetadata: AccountMetadata[] = [];
interface atUriObject {
repo: string;
collection: string;
@ -49,7 +45,7 @@ class Post {
constructor(
record: ComAtprotoRepoListRecords.Record,
account: AccountMetadata,
account: AccountMetadata
) {
this.postCid = record.cid;
this.recordName = processAtUri(record.uri).rkey;
@ -72,7 +68,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":
@ -86,7 +82,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;
@ -122,8 +118,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}`,
) => {
did: `did:${string}:${string}`
): Promise<AccountMetadata> => {
// gonna assume self exists in the app.bsky.actor.profile
try {
const { data } = await rpc.get("com.atproto.repo.getRecord", {
@ -147,7 +143,12 @@ const getAccountMetadata = async (
return account;
} catch (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(
dids.map(async (repo: `did:${string}:${string}`) => {
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) => {
@ -171,7 +196,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 {
@ -194,146 +219,36 @@ const blueskyHandleFromDid = async (did: At.Did) => {
}
};
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;
}
}
});
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,
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
);
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();
}
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,
};
if (!user) {
throw new Error(`User with DID ${userFetch.did} not found`);
}
}),
return new Post(record, user);
})
);
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) {
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();
records = records.filter((post) => {
const postDate = new Date(
(post.value as AppBskyFeedPost.Record).createdAt,
).getTime();
return postDate <= now;
});
const filteredPosts = posts.filter((post) => post.timestamp <= now);
return filteredPosts.slice(0, Config.MAX_POSTS);
}
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;
return posts.slice(0, Config.MAX_POSTS);
};
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 { fetchAllPosts, getAllMetadataFromPds, Post };
export type { AccountMetadata };