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
··· 12 "@atcute/oauth-browser-client": "^1.0.27", 13 "@atcute/tid": "^1.0.3", 14 "@solidjs/router": "^0.15.3", 15 "solid-js": "^1.9.5", 16 }, 17 "devDependencies": { ··· 266 "@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], 267 268 "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], 269 270 "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=="], 271
··· 12 "@atcute/oauth-browser-client": "^1.0.27", 13 "@atcute/tid": "^1.0.3", 14 "@solidjs/router": "^0.15.3", 15 + "@yaireo/relative-time": "^1.1.0", 16 "solid-js": "^1.9.5", 17 }, 18 "devDependencies": { ··· 267 "@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], 268 269 "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], 270 + 271 + "@yaireo/relative-time": ["@yaireo/relative-time@1.1.0", "", {}, "sha512-3XXsDpeKARlMUlGw7pBn+2cihakH0UQMr0m7hD/aZnQEJ+4Ele8IR/FzKlGzM9R+mfU7cbpyzL1PLGPTOBBiUg=="], 272 273 "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=="], 274
+1
package.json
··· 27 "@atcute/oauth-browser-client": "^1.0.27", 28 "@atcute/tid": "^1.0.3", 29 "@solidjs/router": "^0.15.3", 30 "solid-js": "^1.9.5" 31 } 32 }
··· 27 "@atcute/oauth-browser-client": "^1.0.27", 28 "@atcute/tid": "^1.0.3", 29 "@solidjs/router": "^0.15.3", 30 + "@yaireo/relative-time": "^1.1.0", 31 "solid-js": "^1.9.5" 32 } 33 }
+74
src/components/post.tsx
···
··· 1 + import RelativeTime from "@yaireo/relative-time"; 2 + import { Show } from "solid-js"; 3 + import type { Post } from "../types/post"; 4 + 5 + type PostProps = { 6 + data: Post; 7 + }; 8 + 9 + // todo: don't just copy FA svgs in from akko-fe 10 + const BoostIcon = () => { 11 + return ( 12 + <svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"> 13 + <title>Boost</title> 14 + <path 15 + class="" 16 + fill="#5dc94a" 17 + 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" 18 + ></path> 19 + </svg> 20 + ); 21 + }; 22 + 23 + const Post = (props: PostProps) => { 24 + return ( 25 + <div class="post"> 26 + <Show when={props.data.context}> 27 + <div class="post-context"> 28 + <img 29 + src={props.data.context?.invoker.avatar} 30 + alt={`Profile picture of ${props.data.context?.invoker.handle}`} 31 + /> 32 + <span class="post-context-user"> 33 + {props.data.context?.invoker.displayName} 34 + </span> 35 + <BoostIcon /> 36 + <span>reposted</span> 37 + </div> 38 + </Show> 39 + <div class="post-content"> 40 + <img 41 + class="post-avatar" 42 + src={props.data.avatar} 43 + alt={`Profile picture of ${props.data.handle}`} 44 + /> 45 + <div class="post-main"> 46 + <div class="post-header"> 47 + <div class="post-author"> 48 + <span>{props.data.displayName}</span> 49 + <span class="post-author-handle">@{props.data.handle}</span> 50 + </div> 51 + <span class="post-time"> 52 + {new RelativeTime({ options: { style: "narrow" } }).from( 53 + props.data.createdAt, 54 + )} 55 + </span> 56 + </div> 57 + <div class="post-body">{props.data.record.text}</div> 58 + </div> 59 + </div> 60 + <div class="post-interactions"> 61 + <p> 62 + {props.data.counts.replyCount}{" "} 63 + {props.data.counts.replyCount === 1 ? "reply" : "replies"} |{" "} 64 + {props.data.counts.repostCount}{" "} 65 + {props.data.counts.repostCount === 1 ? "repost" : "reposts"} |{" "} 66 + {props.data.counts.likeCount}{" "} 67 + {props.data.counts.likeCount === 1 ? "like" : "likes"} 68 + </p> 69 + </div> 70 + </div> 71 + ); 72 + }; 73 + 74 + export default Post;
-1
src/components/postForm.tsx
··· 2 import { agent } from "./login"; 3 import { Client } from "@atcute/client"; 4 import * as TID from "@atcute/tid"; 5 - import RichtextBuilder from "@atcute/bluesky-richtext-builder"; 6 7 const PostForm: Component = () => { 8 const [notice, setNotice] = createSignal("");
··· 2 import { agent } from "./login"; 3 import { Client } from "@atcute/client"; 4 import * as TID from "@atcute/tid"; 5 6 const PostForm: Component = () => { 7 const [notice, setNotice] = createSignal("");
+22 -1
src/routes/dashboard.tsx
··· 1 import Container from "../components/container"; 2 import { agent, killSession, loginState } from "../components/login"; 3 import MiniProfile from "../components/miniProfile"; 4 import PostForm from "../components/postForm"; 5 6 const Dashboard = () => { 7 if (!loginState()) { 8 location.href = "/"; 9 } 10 11 return ( 12 <> ··· 28 children={ 29 <div class="container-content"> 30 <div class="dashboard-feed"> 31 - <p>No more posts</p> 32 </div> 33 </div> 34 }
··· 1 + import { createResource, For, Match, Show, Switch } from "solid-js"; 2 import Container from "../components/container"; 3 import { agent, killSession, loginState } from "../components/login"; 4 import MiniProfile from "../components/miniProfile"; 5 import PostForm from "../components/postForm"; 6 + import { createPostElements, getFollowingTimeline } from "../utils/posts"; 7 + import Post from "../components/post"; 8 + 9 + async function renderTimeline() { 10 + const feed = await getFollowingTimeline(); 11 + return await createPostElements(feed.feed); 12 + } 13 14 const Dashboard = () => { 15 if (!loginState()) { 16 location.href = "/"; 17 } 18 + 19 + const [feed] = createResource(renderTimeline); 20 21 return ( 22 <> ··· 38 children={ 39 <div class="container-content"> 40 <div class="dashboard-feed"> 41 + <Switch> 42 + <Match when={feed.loading}> 43 + <p>Loading...</p> 44 + </Match> 45 + <Match when={feed.error}> 46 + <p>Error while loading timeline: {feed.error}</p> 47 + </Match> 48 + <Match when={feed()}> 49 + <For each={feed()}>{(item) => <Post data={item} />}</For> 50 + <p>No more posts</p> 51 + </Match> 52 + </Switch> 53 </div> 54 </div> 55 }
-1
src/routes/splash.tsx
··· 1 import { Component } from "solid-js"; 2 - import Navbar from "../components/navbar"; 3 import "../styles/main.scss"; 4 import typefaceLogo from "/logo.png?url"; 5 import blueskyLogo from "/bluesky.svg?url";
··· 1 import { Component } from "solid-js"; 2 import "../styles/main.scss"; 3 import typefaceLogo from "/logo.png?url"; 4 import blueskyLogo from "/bluesky.svg?url";
+224
src/styles/components/post.scss
···
··· 1 + @use "../vars"; 2 + 3 + $currentColor: #1185fe; 4 + 5 + .dashboard-feed { 6 + font-size: 0.95rem; 7 + display: flex; 8 + flex-direction: column; 9 + overflow: scroll; 10 + p { 11 + color: #8d8d8d; 12 + } 13 + max-width: 600px; 14 + width: 100%; 15 + min-width: 0; 16 + 17 + @media (max-width: 850px) { 18 + max-width: 500px; 19 + } 20 + 21 + @media (max-width: 768px) { 22 + max-width: 100%; 23 + margin: 0; 24 + padding: 0; 25 + } 26 + } 27 + 28 + .post { 29 + display: flex; 30 + flex-direction: column; 31 + gap: 0.1rem; 32 + margin: 0.5rem 0; 33 + border-bottom: 1px solid #444; 34 + min-width: 0; 35 + width: 100%; 36 + } 37 + 38 + .post-context { 39 + display: flex; 40 + gap: 0.5rem; 41 + padding-left: 2.5rem; 42 + padding-bottom: 0.5rem; 43 + max-height: 32px; 44 + align-items: center; 45 + text-align: left; 46 + 47 + .post-context-user { 48 + color: $currentColor; 49 + } 50 + 51 + span { 52 + color: rgba(185, 185, 186, 0.5); 53 + } 54 + 55 + span:first-of-type { 56 + margin-left: 0.5rem; 57 + } 58 + 59 + img { 60 + max-height: 24px; 61 + border-radius: 5px; 62 + } 63 + 64 + svg { 65 + max-height: 16px; 66 + border-radius: 5px; 67 + } 68 + 69 + @media (max-width: 850px) { 70 + span:first-of-type { 71 + margin-left: 0rem; 72 + } 73 + } 74 + 75 + @media (max-width: 768px) { 76 + padding-left: 2rem; 77 + gap: 0.34rem; 78 + 79 + span:first-of-type { 80 + margin-left: 0.1rem; 81 + } 82 + } 83 + } 84 + 85 + .post-content { 86 + display: flex; 87 + flex-direction: row; 88 + gap: 1rem; 89 + padding-left: 1rem; 90 + min-width: 0; 91 + 92 + @media (max-width: 850px) { 93 + gap: 0.75rem; 94 + padding-left: 0.75rem; 95 + } 96 + 97 + @media (max-width: 768px) { 98 + padding-left: 0.5rem; 99 + gap: 0.5rem; 100 + } 101 + } 102 + 103 + .post-main { 104 + display: flex; 105 + flex-direction: column; 106 + gap: 0.5rem; 107 + flex: 1; 108 + min-width: 0; 109 + overflow-wrap: break-word; 110 + } 111 + 112 + .post-avatar { 113 + width: 48px; 114 + height: 48px; 115 + border-radius: 5px; 116 + flex-shrink: 0; 117 + 118 + @media (max-width: 768px) { 119 + width: 48px; 120 + height: 48px; 121 + } 122 + } 123 + 124 + .post-header { 125 + display: flex; 126 + flex-direction: row; 127 + align-items: flex-start; 128 + justify-content: space-between; 129 + text-align: left; 130 + width: 100%; 131 + min-width: 0; 132 + gap: 1rem; 133 + 134 + .post-author { 135 + display: flex; 136 + gap: 0.5rem; 137 + align-items: baseline; 138 + min-width: 0; 139 + flex: 1; 140 + overflow: hidden; 141 + 142 + span { 143 + white-space: nowrap; 144 + overflow: hidden; 145 + text-overflow: ellipsis; 146 + } 147 + 148 + .post-author-handle { 149 + color: #1185fe; 150 + } 151 + } 152 + 153 + .post-time { 154 + color: #8d8d8d; 155 + white-space: nowrap; 156 + flex-shrink: 0; 157 + margin-left: auto; 158 + margin-right: 1rem; 159 + } 160 + 161 + @media (max-width: 850px) { 162 + .post-author { 163 + span { 164 + max-width: 150px; 165 + } 166 + } 167 + } 168 + 169 + @media (max-width: 768px) { 170 + flex-direction: row; 171 + align-items: flex-start; 172 + justify-content: space-between; 173 + 174 + .post-author { 175 + flex-direction: column; 176 + align-items: flex-start; 177 + gap: 0.25rem; 178 + flex: none; 179 + max-width: calc(100% - 80px); 180 + 181 + span { 182 + white-space: normal; 183 + overflow-wrap: break-word; 184 + word-break: break-word; 185 + max-width: none; 186 + overflow: visible; 187 + text-overflow: clip; 188 + } 189 + } 190 + 191 + .post-time { 192 + align-self: flex-start; 193 + } 194 + } 195 + } 196 + 197 + .post-body { 198 + text-align: left; 199 + margin-top: 0.25rem; 200 + margin-right: 1rem; 201 + overflow-wrap: break-word; 202 + word-break: break-word; 203 + 204 + @media (max-width: 850px) { 205 + margin-right: 0.75rem; 206 + } 207 + 208 + @media (max-width: 768px) { 209 + margin-right: 0.5rem; 210 + } 211 + } 212 + 213 + .post-interactions { 214 + text-align: left; 215 + margin-left: 1rem; 216 + 217 + @media (max-width: 850px) { 218 + margin-left: 0.75rem; 219 + } 220 + 221 + @media (max-width: 768px) { 222 + margin-left: 0.5rem; 223 + } 224 + }
+11 -3
src/styles/container.scss
··· 6 margin: 1em; 7 padding: 0 0 1em 0; 8 max-height: 100%; 9 box-shadow: 10 0px 0px 3px 0px rgba(0, 0, 0, 0.5), 11 0px 4px 6px 3px rgba(0, 0, 0, 0.3); 12 13 .container-content { 14 - padding: 0 1rem; 15 } 16 } 17 18 .container-header { 19 background-color: vars.$foregroundColor; 20 text-align: left; 21 - padding: 1em; 22 height: 1rem; 23 border-radius: vars.$containerBorderRadius vars.$containerBorderRadius 0 0; 24 - margin-bottom: 1em; 25 box-shadow: 26 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 27 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
··· 6 margin: 1em; 7 padding: 0 0 1em 0; 8 max-height: 100%; 9 + min-width: 0; 10 + box-sizing: border-box; 11 box-shadow: 12 0px 0px 3px 0px rgba(0, 0, 0, 0.5), 13 0px 4px 6px 3px rgba(0, 0, 0, 0.3); 14 15 .container-content { 16 + padding: 0; 17 + margin-top: 1rem; 18 + min-width: 0; 19 + overflow-wrap: break-word; 20 + } 21 + 22 + @media (max-width: 768px) { 23 + margin: 0.5em; 24 } 25 } 26 27 .container-header { 28 background-color: vars.$foregroundColor; 29 text-align: left; 30 + padding: 1rem; 31 height: 1rem; 32 border-radius: vars.$containerBorderRadius vars.$containerBorderRadius 0 0; 33 box-shadow: 34 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 35 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
+7 -3
src/styles/main.scss
··· 1 @use "./button"; 2 @use "./container"; 3 @use "./nav"; 4 @use "./profile"; 5 @use "./routes/dashboard"; ··· 12 background-color: rgba(12, 17, 24, 1); 13 font-family: Arial, Helvetica, sans-serif; 14 margin: 0; 15 - overflow: hidden; 16 } 17 18 main { ··· 21 justify-content: center; 22 margin: 0 auto; 23 max-width: 75%; 24 } 25 26 @media (max-width: 768px) { 27 main { 28 flex-direction: column; 29 - max-width: 90%; 30 - margin: 0 1rem; 31 } 32 } 33
··· 1 @use "./button"; 2 @use "./container"; 3 + @use "./components/post"; 4 @use "./nav"; 5 @use "./profile"; 6 @use "./routes/dashboard"; ··· 13 background-color: rgba(12, 17, 24, 1); 14 font-family: Arial, Helvetica, sans-serif; 15 margin: 0; 16 } 17 18 main { ··· 21 justify-content: center; 22 margin: 0 auto; 23 max-width: 75%; 24 + min-width: 0; 25 + width: 100%; 26 } 27 28 @media (max-width: 768px) { 29 main { 30 flex-direction: column; 31 + max-width: 100%; 32 + margin: 0; 33 + padding: 0 0.5rem; 34 + box-sizing: border-box; 35 } 36 } 37
-6
src/styles/routes/dashboard.scss
··· 31 width: 100%; 32 } 33 } 34 - 35 - .dashboard-feed { 36 - p { 37 - color: #8d8d8d; 38 - } 39 - }
··· 31 width: 100%; 32 } 33 }
+26
src/types/post.ts
···
··· 1 + import { AppBskyFeedPost } from "@atcute/bluesky"; 2 + import { ProfileViewBasic } from "@atcute/bluesky/types/app/actor/defs"; 3 + 4 + export type Post = { 5 + avatar?: string; 6 + context?: PostContext; 7 + counts: PostCounts; 8 + createdAt: Date; 9 + displayName: string; 10 + handle: string; 11 + indexedAt: Date; 12 + record: AppBskyFeedPost.Main; 13 + }; 14 + 15 + type PostCounts = { 16 + bookmarkCount?: number; 17 + likeCount?: number; 18 + quoteCount?: number; 19 + repostCount?: number; 20 + replyCount?: number; 21 + }; 22 + 23 + type PostContext = { 24 + invoker: ProfileViewBasic; 25 + reason: string; 26 + };
+91
src/utils/posts.ts
···
··· 1 + import { Client } from "@atcute/client"; 2 + import { agent } from "../components/login"; 3 + import { FeedViewPost } from "@atcute/bluesky/types/app/feed/defs"; 4 + import type { Post } from "../types/post"; 5 + import { is } from "@atcute/lexicons"; 6 + import { AppBskyFeedPost } from "@atcute/bluesky"; 7 + 8 + export async function getFollowingTimeline( 9 + cursor: string = "", 10 + limit: number = 50, 11 + ) { 12 + const rpc = new Client({ handler: agent }); 13 + 14 + const res = await rpc.get("app.bsky.feed.getTimeline", { 15 + params: { 16 + cursor, 17 + limit, 18 + }, 19 + }); 20 + 21 + if (!res.ok) { 22 + throw new Error( 23 + `Failed to fetch user's following timeline: ${res.data.error}/${res.data.message}`, 24 + ); 25 + } 26 + 27 + return { feed: res.data.feed, cursor: res.data.cursor }; 28 + } 29 + 30 + export async function createPostElements(feed: FeedViewPost[]) { 31 + let elms: Post[] = []; 32 + const seenCreators = new Set<string>(); 33 + 34 + feed.forEach((post) => { 35 + if (is(AppBskyFeedPost.mainSchema, post.post.record)) { 36 + const record = post.post.record as unknown as AppBskyFeedPost.Main; 37 + const isReply = record.reply !== undefined; 38 + const creatorDid = post.post.author.did; 39 + 40 + // Skip replies from creators who already have a post in elms 41 + if (isReply && seenCreators.has(creatorDid)) { 42 + return; 43 + } 44 + 45 + if (post.reason) { 46 + if (post.reason.$type === "app.bsky.feed.defs#reasonRepost") { 47 + elms.push({ 48 + avatar: post.post.author.avatar, 49 + context: { 50 + invoker: post.reason.by, 51 + reason: post.reason.$type, 52 + }, 53 + counts: { 54 + bookmarkCount: post.post.bookmarkCount, 55 + likeCount: post.post.likeCount, 56 + quoteCount: post.post.quoteCount, 57 + repostCount: post.post.repostCount, 58 + replyCount: post.post.replyCount, 59 + }, 60 + createdAt: new Date(post.post.record.createdAt), 61 + displayName: 62 + post.post.author.displayName || post.post.author.handle, 63 + handle: post.post.author.handle, 64 + indexedAt: new Date(post.post.indexedAt), 65 + record: record, 66 + }); 67 + seenCreators.add(creatorDid); 68 + } 69 + } else { 70 + elms.push({ 71 + avatar: post.post.author.avatar, 72 + counts: { 73 + bookmarkCount: post.post.bookmarkCount, 74 + likeCount: post.post.likeCount, 75 + quoteCount: post.post.quoteCount, 76 + repostCount: post.post.repostCount, 77 + replyCount: post.post.replyCount, 78 + }, 79 + createdAt: new Date(post.post.record.createdAt), 80 + displayName: post.post.author.displayName || post.post.author.handle, 81 + handle: post.post.author.handle, 82 + indexedAt: new Date(post.post.indexedAt), 83 + record: record, 84 + }); 85 + seenCreators.add(creatorDid); 86 + } 87 + } 88 + }); 89 + 90 + return elms; 91 + }