pleroma-like client for Bluesky pl.hexmani.ac
bluesky pleroma social-media

Create basic following timeline with retweet context

hexmani.ac 2f3751aa 18468c0b

verified
Changed files
+459 -15
src
+3
bun.lock
···
"@atcute/oauth-browser-client": "^1.0.27",
"@atcute/tid": "^1.0.3",
"@solidjs/router": "^0.15.3",
+
"@yaireo/relative-time": "^1.1.0",
"solid-js": "^1.9.5",
},
"devDependencies": {
···
"@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
+
+
"@yaireo/relative-time": ["@yaireo/relative-time@1.1.0", "", {}, "sha512-3XXsDpeKARlMUlGw7pBn+2cihakH0UQMr0m7hD/aZnQEJ+4Ele8IR/FzKlGzM9R+mfU7cbpyzL1PLGPTOBBiUg=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.1", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2", "validate-html-nesting": "^1.2.1" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-b4iHuirqK7RgaMzB2Lsl7MqrlDgQtVRSSazyrmx7wB3T759ggGjod5Rkok5MfHjQXhR7tRPmdwoeGPqBnW2KfA=="],
+1
package.json
···
"@atcute/oauth-browser-client": "^1.0.27",
"@atcute/tid": "^1.0.3",
"@solidjs/router": "^0.15.3",
+
"@yaireo/relative-time": "^1.1.0",
"solid-js": "^1.9.5"
}
}
+74
src/components/post.tsx
···
+
import RelativeTime from "@yaireo/relative-time";
+
import { Show } from "solid-js";
+
import type { Post } from "../types/post";
+
+
type PostProps = {
+
data: Post;
+
};
+
+
// todo: don't just copy FA svgs in from akko-fe
+
const BoostIcon = () => {
+
return (
+
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
+
<title>Boost</title>
+
<path
+
class=""
+
fill="#5dc94a"
+
d="M272 416c17.7 0 32-14.3 32-32s-14.3-32-32-32H160c-17.7 0-32-14.3-32-32V192h32c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-64-64c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l32 0 0 128c0 53 43 96 96 96H272zM304 96c-17.7 0-32 14.3-32 32s14.3 32 32 32l112 0c17.7 0 32 14.3 32 32l0 128H416c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8l-32 0V192c0-53-43-96-96-96L304 96z"
+
></path>
+
</svg>
+
);
+
};
+
+
const Post = (props: PostProps) => {
+
return (
+
<div class="post">
+
<Show when={props.data.context}>
+
<div class="post-context">
+
<img
+
src={props.data.context?.invoker.avatar}
+
alt={`Profile picture of ${props.data.context?.invoker.handle}`}
+
/>
+
<span class="post-context-user">
+
{props.data.context?.invoker.displayName}
+
</span>
+
<BoostIcon />
+
<span>reposted</span>
+
</div>
+
</Show>
+
<div class="post-content">
+
<img
+
class="post-avatar"
+
src={props.data.avatar}
+
alt={`Profile picture of ${props.data.handle}`}
+
/>
+
<div class="post-main">
+
<div class="post-header">
+
<div class="post-author">
+
<span>{props.data.displayName}</span>
+
<span class="post-author-handle">@{props.data.handle}</span>
+
</div>
+
<span class="post-time">
+
{new RelativeTime({ options: { style: "narrow" } }).from(
+
props.data.createdAt,
+
)}
+
</span>
+
</div>
+
<div class="post-body">{props.data.record.text}</div>
+
</div>
+
</div>
+
<div class="post-interactions">
+
<p>
+
{props.data.counts.replyCount}{" "}
+
{props.data.counts.replyCount === 1 ? "reply" : "replies"} |{" "}
+
{props.data.counts.repostCount}{" "}
+
{props.data.counts.repostCount === 1 ? "repost" : "reposts"} |{" "}
+
{props.data.counts.likeCount}{" "}
+
{props.data.counts.likeCount === 1 ? "like" : "likes"}
+
</p>
+
</div>
+
</div>
+
);
+
};
+
+
export default Post;
-1
src/components/postForm.tsx
···
import { agent } from "./login";
import { Client } from "@atcute/client";
import * as TID from "@atcute/tid";
-
import RichtextBuilder from "@atcute/bluesky-richtext-builder";
const PostForm: Component = () => {
const [notice, setNotice] = createSignal("");
+22 -1
src/routes/dashboard.tsx
···
+
import { createResource, For, Match, Show, Switch } from "solid-js";
import Container from "../components/container";
import { agent, killSession, loginState } from "../components/login";
import MiniProfile from "../components/miniProfile";
import PostForm from "../components/postForm";
+
import { createPostElements, getFollowingTimeline } from "../utils/posts";
+
import Post from "../components/post";
+
+
async function renderTimeline() {
+
const feed = await getFollowingTimeline();
+
return await createPostElements(feed.feed);
+
}
const Dashboard = () => {
if (!loginState()) {
location.href = "/";
}
+
+
const [feed] = createResource(renderTimeline);
return (
<>
···
children={
<div class="container-content">
<div class="dashboard-feed">
-
<p>No more posts</p>
+
<Switch>
+
<Match when={feed.loading}>
+
<p>Loading...</p>
+
</Match>
+
<Match when={feed.error}>
+
<p>Error while loading timeline: {feed.error}</p>
+
</Match>
+
<Match when={feed()}>
+
<For each={feed()}>{(item) => <Post data={item} />}</For>
+
<p>No more posts</p>
+
</Match>
+
</Switch>
</div>
</div>
}
-1
src/routes/splash.tsx
···
import { Component } from "solid-js";
-
import Navbar from "../components/navbar";
import "../styles/main.scss";
import typefaceLogo from "/logo.png?url";
import blueskyLogo from "/bluesky.svg?url";
+224
src/styles/components/post.scss
···
+
@use "../vars";
+
+
$currentColor: #1185fe;
+
+
.dashboard-feed {
+
font-size: 0.95rem;
+
display: flex;
+
flex-direction: column;
+
overflow: scroll;
+
p {
+
color: #8d8d8d;
+
}
+
max-width: 600px;
+
width: 100%;
+
min-width: 0;
+
+
@media (max-width: 850px) {
+
max-width: 500px;
+
}
+
+
@media (max-width: 768px) {
+
max-width: 100%;
+
margin: 0;
+
padding: 0;
+
}
+
}
+
+
.post {
+
display: flex;
+
flex-direction: column;
+
gap: 0.1rem;
+
margin: 0.5rem 0;
+
border-bottom: 1px solid #444;
+
min-width: 0;
+
width: 100%;
+
}
+
+
.post-context {
+
display: flex;
+
gap: 0.5rem;
+
padding-left: 2.5rem;
+
padding-bottom: 0.5rem;
+
max-height: 32px;
+
align-items: center;
+
text-align: left;
+
+
.post-context-user {
+
color: $currentColor;
+
}
+
+
span {
+
color: rgba(185, 185, 186, 0.5);
+
}
+
+
span:first-of-type {
+
margin-left: 0.5rem;
+
}
+
+
img {
+
max-height: 24px;
+
border-radius: 5px;
+
}
+
+
svg {
+
max-height: 16px;
+
border-radius: 5px;
+
}
+
+
@media (max-width: 850px) {
+
span:first-of-type {
+
margin-left: 0rem;
+
}
+
}
+
+
@media (max-width: 768px) {
+
padding-left: 2rem;
+
gap: 0.34rem;
+
+
span:first-of-type {
+
margin-left: 0.1rem;
+
}
+
}
+
}
+
+
.post-content {
+
display: flex;
+
flex-direction: row;
+
gap: 1rem;
+
padding-left: 1rem;
+
min-width: 0;
+
+
@media (max-width: 850px) {
+
gap: 0.75rem;
+
padding-left: 0.75rem;
+
}
+
+
@media (max-width: 768px) {
+
padding-left: 0.5rem;
+
gap: 0.5rem;
+
}
+
}
+
+
.post-main {
+
display: flex;
+
flex-direction: column;
+
gap: 0.5rem;
+
flex: 1;
+
min-width: 0;
+
overflow-wrap: break-word;
+
}
+
+
.post-avatar {
+
width: 48px;
+
height: 48px;
+
border-radius: 5px;
+
flex-shrink: 0;
+
+
@media (max-width: 768px) {
+
width: 48px;
+
height: 48px;
+
}
+
}
+
+
.post-header {
+
display: flex;
+
flex-direction: row;
+
align-items: flex-start;
+
justify-content: space-between;
+
text-align: left;
+
width: 100%;
+
min-width: 0;
+
gap: 1rem;
+
+
.post-author {
+
display: flex;
+
gap: 0.5rem;
+
align-items: baseline;
+
min-width: 0;
+
flex: 1;
+
overflow: hidden;
+
+
span {
+
white-space: nowrap;
+
overflow: hidden;
+
text-overflow: ellipsis;
+
}
+
+
.post-author-handle {
+
color: #1185fe;
+
}
+
}
+
+
.post-time {
+
color: #8d8d8d;
+
white-space: nowrap;
+
flex-shrink: 0;
+
margin-left: auto;
+
margin-right: 1rem;
+
}
+
+
@media (max-width: 850px) {
+
.post-author {
+
span {
+
max-width: 150px;
+
}
+
}
+
}
+
+
@media (max-width: 768px) {
+
flex-direction: row;
+
align-items: flex-start;
+
justify-content: space-between;
+
+
.post-author {
+
flex-direction: column;
+
align-items: flex-start;
+
gap: 0.25rem;
+
flex: none;
+
max-width: calc(100% - 80px);
+
+
span {
+
white-space: normal;
+
overflow-wrap: break-word;
+
word-break: break-word;
+
max-width: none;
+
overflow: visible;
+
text-overflow: clip;
+
}
+
}
+
+
.post-time {
+
align-self: flex-start;
+
}
+
}
+
}
+
+
.post-body {
+
text-align: left;
+
margin-top: 0.25rem;
+
margin-right: 1rem;
+
overflow-wrap: break-word;
+
word-break: break-word;
+
+
@media (max-width: 850px) {
+
margin-right: 0.75rem;
+
}
+
+
@media (max-width: 768px) {
+
margin-right: 0.5rem;
+
}
+
}
+
+
.post-interactions {
+
text-align: left;
+
margin-left: 1rem;
+
+
@media (max-width: 850px) {
+
margin-left: 0.75rem;
+
}
+
+
@media (max-width: 768px) {
+
margin-left: 0.5rem;
+
}
+
}
+11 -3
src/styles/container.scss
···
margin: 1em;
padding: 0 0 1em 0;
max-height: 100%;
+
min-width: 0;
+
box-sizing: border-box;
box-shadow:
0px 0px 3px 0px rgba(0, 0, 0, 0.5),
0px 4px 6px 3px rgba(0, 0, 0, 0.3);
.container-content {
-
padding: 0 1rem;
+
padding: 0;
+
margin-top: 1rem;
+
min-width: 0;
+
overflow-wrap: break-word;
+
}
+
+
@media (max-width: 768px) {
+
margin: 0.5em;
}
}
.container-header {
background-color: vars.$foregroundColor;
text-align: left;
-
padding: 1em;
+
padding: 1rem;
height: 1rem;
border-radius: vars.$containerBorderRadius vars.$containerBorderRadius 0 0;
-
margin-bottom: 1em;
box-shadow:
0px 1px 3px 0px rgba(0, 0, 0, 0.4),
0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
+7 -3
src/styles/main.scss
···
@use "./button";
@use "./container";
+
@use "./components/post";
@use "./nav";
@use "./profile";
@use "./routes/dashboard";
···
background-color: rgba(12, 17, 24, 1);
font-family: Arial, Helvetica, sans-serif;
margin: 0;
-
overflow: hidden;
}
main {
···
justify-content: center;
margin: 0 auto;
max-width: 75%;
+
min-width: 0;
+
width: 100%;
}
@media (max-width: 768px) {
main {
flex-direction: column;
-
max-width: 90%;
-
margin: 0 1rem;
+
max-width: 100%;
+
margin: 0;
+
padding: 0 0.5rem;
+
box-sizing: border-box;
}
}
-6
src/styles/routes/dashboard.scss
···
width: 100%;
}
}
-
-
.dashboard-feed {
-
p {
-
color: #8d8d8d;
-
}
-
}
+26
src/types/post.ts
···
+
import { AppBskyFeedPost } from "@atcute/bluesky";
+
import { ProfileViewBasic } from "@atcute/bluesky/types/app/actor/defs";
+
+
export type Post = {
+
avatar?: string;
+
context?: PostContext;
+
counts: PostCounts;
+
createdAt: Date;
+
displayName: string;
+
handle: string;
+
indexedAt: Date;
+
record: AppBskyFeedPost.Main;
+
};
+
+
type PostCounts = {
+
bookmarkCount?: number;
+
likeCount?: number;
+
quoteCount?: number;
+
repostCount?: number;
+
replyCount?: number;
+
};
+
+
type PostContext = {
+
invoker: ProfileViewBasic;
+
reason: string;
+
};
+91
src/utils/posts.ts
···
+
import { Client } from "@atcute/client";
+
import { agent } from "../components/login";
+
import { FeedViewPost } from "@atcute/bluesky/types/app/feed/defs";
+
import type { Post } from "../types/post";
+
import { is } from "@atcute/lexicons";
+
import { AppBskyFeedPost } from "@atcute/bluesky";
+
+
export async function getFollowingTimeline(
+
cursor: string = "",
+
limit: number = 50,
+
) {
+
const rpc = new Client({ handler: agent });
+
+
const res = await rpc.get("app.bsky.feed.getTimeline", {
+
params: {
+
cursor,
+
limit,
+
},
+
});
+
+
if (!res.ok) {
+
throw new Error(
+
`Failed to fetch user's following timeline: ${res.data.error}/${res.data.message}`,
+
);
+
}
+
+
return { feed: res.data.feed, cursor: res.data.cursor };
+
}
+
+
export async function createPostElements(feed: FeedViewPost[]) {
+
let elms: Post[] = [];
+
const seenCreators = new Set<string>();
+
+
feed.forEach((post) => {
+
if (is(AppBskyFeedPost.mainSchema, post.post.record)) {
+
const record = post.post.record as unknown as AppBskyFeedPost.Main;
+
const isReply = record.reply !== undefined;
+
const creatorDid = post.post.author.did;
+
+
// Skip replies from creators who already have a post in elms
+
if (isReply && seenCreators.has(creatorDid)) {
+
return;
+
}
+
+
if (post.reason) {
+
if (post.reason.$type === "app.bsky.feed.defs#reasonRepost") {
+
elms.push({
+
avatar: post.post.author.avatar,
+
context: {
+
invoker: post.reason.by,
+
reason: post.reason.$type,
+
},
+
counts: {
+
bookmarkCount: post.post.bookmarkCount,
+
likeCount: post.post.likeCount,
+
quoteCount: post.post.quoteCount,
+
repostCount: post.post.repostCount,
+
replyCount: post.post.replyCount,
+
},
+
createdAt: new Date(post.post.record.createdAt),
+
displayName:
+
post.post.author.displayName || post.post.author.handle,
+
handle: post.post.author.handle,
+
indexedAt: new Date(post.post.indexedAt),
+
record: record,
+
});
+
seenCreators.add(creatorDid);
+
}
+
} else {
+
elms.push({
+
avatar: post.post.author.avatar,
+
counts: {
+
bookmarkCount: post.post.bookmarkCount,
+
likeCount: post.post.likeCount,
+
quoteCount: post.post.quoteCount,
+
repostCount: post.post.repostCount,
+
replyCount: post.post.replyCount,
+
},
+
createdAt: new Date(post.post.record.createdAt),
+
displayName: post.post.author.displayName || post.post.author.handle,
+
handle: post.post.author.handle,
+
indexedAt: new Date(post.post.indexedAt),
+
record: record,
+
});
+
seenCreators.add(creatorDid);
+
}
+
}
+
});
+
+
return elms;
+
}