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

Compare changes

Choose any two refs to compare.

+7 -3
src/components/container.tsx
···
const Container = (props: ContainerProps) => {
return (
<div class="container">
-
<div class="container-header">
-
<span>{props.title}</span>
-
</div>
+
{props.title ? (
+
<div class="container-header">
+
<span>{props.title}</span>
+
</div>
+
) : (
+
<></>
+
)}
{props.children}
</div>
);
+58
src/components/miniProfile.tsx
···
+
import { Component, Match, Show, Switch, createResource } from "solid-js";
+
import { Client } from "@atcute/client";
+
import { agent } from "./login";
+
+
type MiniProfileProps = {
+
did: `did:${string}:${string}`;
+
};
+
+
async function getProfileDetails(did: `did:${string}:${string}`) {
+
const rpc = new Client({ handler: agent });
+
+
const res = await rpc.get("app.bsky.actor.getProfile", {
+
params: {
+
actor: did,
+
},
+
});
+
+
if (!res.ok) {
+
throw new Error(`Failed to fetch profile details: ${res.status}`);
+
}
+
+
return res.data;
+
}
+
+
const MiniProfile = (props: MiniProfileProps) => {
+
const [profileInfo] = createResource(agent.sub, getProfileDetails);
+
+
return (
+
<>
+
<Show when={profileInfo.loading}>
+
<p>loading...</p>
+
</Show>
+
<Switch>
+
<Match when={profileInfo.error}>
+
<p>Error: {profileInfo.error.message}</p>
+
</Match>
+
<Match when={profileInfo()}>
+
<div
+
class="mini-profile"
+
// todo: add banner fade
+
style={`background-image: linear-gradient(to bottom, rgba(15, 22, 30, 0.85)), url(${profileInfo()?.banner}); background-size: cover; background-repeat: no-repeat;`}
+
>
+
<img
+
src={profileInfo()?.avatar}
+
alt={`Profile picture for ${profileInfo()?.handle}`}
+
/>
+
<div class="mini-profile-info">
+
<p>{profileInfo()?.displayName}</p>
+
<p>@{profileInfo()?.handle}</p>
+
</div>
+
</div>
+
</Match>
+
</Switch>
+
</>
+
);
+
};
+
+
export default MiniProfile;
+30
src/styles/profile.scss
···
+
@use "vars";
+
+
.mini-profile {
+
display: flex;
+
flex-direction: row;
+
align-items: center;
+
gap: 1rem;
+
padding: 1rem;
+
margin-bottom: 1rem;
+
border-radius: vars.$containerBorderRadius;
+
+
img {
+
max-height: 64px;
+
box-shadow: 10px 5px 5px rgba(0, 0, 0, 0.2);
+
border-radius: 3px;
+
}
+
}
+
+
.mini-profile-info {
+
text-align: left;
+
display: flex;
+
flex-direction: column;
+
align-items: flex-start;
+
justify-content: center;
+
gap: 0.5rem;
+
+
p {
+
margin: 0;
+
}
+
}
+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/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=="],
"babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
+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("");
+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;
+
}
+
}
+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;
+
}
+23
.tangled/build.yaml
···
+
when:
+
- event: [ "push", "pull_request" ]
+
branch: [ "main" ]
+
- event: [ "manual" ]
+
+
engine: "nixery"
+
+
dependencies:
+
nixpkgs:
+
- bun
+
- nodejs
+
+
steps:
+
- name: "Install dependencies"
+
command: "bun install"
+
+
- name: "Build app"
+
command: "bun run build"
+
+
clone:
+
skip: false
+
depth: 50
+
submodules: true
.tangled/build.yaml .tangled/workflows/build.yaml
+1 -1
static/oauth/client-metadata.json static/oauth-client-metadata.json
···
{
-
"client_id": "https://pl.hexmani.ac/oauth/client-metadata.json",
+
"client_id": "https://pl.hexmani.ac/oauth-client-metadata.json",
"client_name": "Bluroma",
"client_uri": "https://pl.hexmani.ac",
"redirect_uris": ["https://pl.hexmani.ac/"],
+1 -1
vite.config.ts
···
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import devtools from "solid-devtools/vite";
-
import metadata from "./static/oauth/client-metadata.json";
+
import metadata from "./static/oauth-client-metadata.json";
const SERVER_HOST = "127.0.0.1";
const SERVER_PORT = 3000;
+1 -1
README.md
···
-
<img src="static/logo.png" alt="The Bluroma logo, containing three rectangles with one rounded corner each shaped to look like an uppercase B and the Bluroma name written in the Convection font beside it." width="200">
+
<img src="static/media/logo.png" alt="The Bluroma logo, containing three rectangles with one rounded corner each shaped to look like an uppercase B and the Bluroma name written in the Convection font beside it." width="200">
## About
+3 -3
index.html
···
<!doctype html>
<html lang="en">
<head>
-
<link rel="icon" href="/favicon.png" type="image/png" />
+
<link rel="icon" href="favicon.png" type="image/png" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#1285FE" />
···
<p>Please enable
JavaScript to continue.</p>
</p>
-
<img src="/favicon.png" alt="Bluroma Logo" />
+
<img src="favicon.png" alt="Bluroma Logo" />
</div>
</noscript>
</body>
-
<script src="/src/index.tsx" type="module"></script>
+
<script src="src/index.tsx" type="module"></script>
</html>
static/bluesky.svg static/media/bluesky.svg
static/logo.png static/media/logo.png
static/tangled.svg static/media/tangled.svg
+41
.tangled/workflows/deploy-main.yaml
···
+
when:
+
- event: ["push"]
+
branch: ["main"]
+
- event: ["manual"]
+
+
engine: "nixery"
+
+
dependencies:
+
nixpkgs:
+
- bun
+
- coreutils
+
- curl
+
- nodejs
+
+
environment:
+
SITE_PATH: "dist"
+
SITE_NAME: "bluroma"
+
WISP_HANDLE: "hexmani.ac"
+
+
steps:
+
- name: "Install dependencies"
+
command: "bun install --frozen-lockfile"
+
+
- name: "Build app"
+
command: "bun run build"
+
+
- name: "Deploy to Wisp"
+
command: |
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
+
chmod +x wisp-cli
+
+
./wisp-cli deploy \
+
"$WISP_HANDLE" \
+
--path "$SITE_PATH" \
+
--site "$SITE_NAME" \
+
--password "$WISP_APP_PASSWORD"
+
+
clone:
+
skip: false
+
depth: 50
+
submodules: true