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

Compare changes

Choose any two refs to compare.

+29
src/base.tsx
···
+
import { RouteSectionProps } from "@solidjs/router";
+
import { Component, createSignal, onMount, Show } from "solid-js";
+
import { retrieveSession, loginState } from "./components/login";
+
import Navbar from "./components/navbar";
+
+
const Base = (props: RouteSectionProps<unknown>) => {
+
const [isLoading, setIsLoading] = createSignal(true);
+
+
onMount(async () => {
+
await retrieveSession();
+
if (loginState() && location.pathname === "/") {
+
window.location.href = "/dash";
+
}
+
setIsLoading(false);
+
});
+
+
return (
+
<Show when={!isLoading()} fallback={<></>}>
+
<>
+
<header>
+
<Navbar />
+
</header>
+
<main>{props.children}</main>
+
</>
+
</Show>
+
);
+
};
+
+
export default Base;
+153
src/components/login.tsx
···
+
import { Did, isHandle } from "@atcute/lexicons/syntax";
+
import {
+
configureOAuth,
+
createAuthorizationUrl,
+
deleteStoredSession,
+
finalizeAuthorization,
+
getSession,
+
OAuthUserAgent,
+
resolveFromIdentity,
+
resolveFromService,
+
Session,
+
} from "@atcute/oauth-browser-client";
+
import { Component, createSignal } from "solid-js";
+
import Container from "./container";
+
+
configureOAuth({
+
metadata: {
+
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
+
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
+
},
+
});
+
+
export const [loginState, setLoginState] = createSignal(false);
+
let agent: OAuthUserAgent;
+
+
const Login: Component = () => {
+
const [notice, setNotice] = createSignal("");
+
const [loginInput, setLoginInput] = createSignal("");
+
+
const login = async (handle: string) => {
+
try {
+
if (!handle) return;
+
let resolved;
+
document.querySelector(".submitInfo")!.removeAttribute("hidden");
+
document
+
.querySelector('button[type="submit"]')!
+
.setAttribute("disabled", "");
+
if (!isHandle(handle)) {
+
setNotice(`Resolving your service...`);
+
resolved = await resolveFromService(handle);
+
} else {
+
setNotice(`Resolving your identity...`);
+
resolved = await resolveFromIdentity(handle);
+
}
+
+
setNotice(`Contacting your data server...`);
+
const authUrl = await createAuthorizationUrl({
+
scope: import.meta.env.VITE_OAUTH_SCOPE,
+
...resolved,
+
});
+
+
setNotice(`Redirecting...`);
+
await new Promise((resolve) => setTimeout(resolve, 500));
+
+
location.assign(authUrl);
+
} catch (e: unknown) {
+
if (e instanceof Error) {
+
console.error(e);
+
setNotice(`${e.message}`);
+
} else {
+
console.error(e);
+
setNotice(`Unknown error, check console ยฏ\\_(ใƒ„)_/ยฏ`);
+
}
+
} finally {
+
document
+
.querySelector('button[type="submit"]')!
+
.removeAttribute("disabled");
+
}
+
};
+
+
return (
+
<>
+
<Container
+
title="Log in"
+
children={
+
<>
+
<div class="login">
+
<form name="login" id="login" onclick={(e) => e.preventDefault()}>
+
<label for="handle">Handle</label>
+
<br />
+
<input
+
type="text"
+
id="handle"
+
name="handle"
+
maxlength="255"
+
placeholder="soykaf.com"
+
onInput={(e) => setLoginInput(e.currentTarget.value)}
+
required
+
/>
+
<br />
+
<button type="submit" onclick={() => login(loginInput())}>
+
Login
+
</button>
+
</form>
+
<p class="submitInfo" hidden>
+
{notice()}
+
</p>
+
</div>
+
</>
+
}
+
/>
+
</>
+
);
+
};
+
+
const retrieveSession = async (): Promise<void> => {
+
const init = async (): Promise<Session | undefined> => {
+
const params = new URLSearchParams(location.hash.slice(1));
+
+
if (params.has("state") && (params.has("code") || params.has("error"))) {
+
history.replaceState(null, "", location.pathname + location.search);
+
+
const session = await finalizeAuthorization(params);
+
console.log("Finalizing authorization...", session);
+
const agent = new OAuthUserAgent(session);
+
console.log(await agent.getSession());
+
const did = session.info.sub;
+
+
localStorage.setItem("currentUser", did);
+
return session;
+
} else {
+
const currentUser = localStorage.getItem("currentUser");
+
+
if (currentUser) {
+
try {
+
console.log("Retrieving session...");
+
return await getSession(currentUser as Did);
+
} catch (err) {
+
deleteStoredSession(currentUser as Did);
+
localStorage.removeItem("currentUser");
+
throw err;
+
}
+
}
+
}
+
};
+
+
const session = await init().catch(() => {});
+
+
if (session) {
+
console.log("Retrieved session!", session);
+
agent = new OAuthUserAgent(session);
+
setLoginState(true);
+
}
+
};
+
+
const killSession = async (): Promise<void> => {
+
await agent.signOut();
+
setLoginState(false);
+
localStorage.removeItem("currentUser");
+
location.href = "/";
+
};
+
+
export { agent, killSession, Login, retrieveSession };
+7 -13
src/index.tsx
···
import { render } from "solid-js/web";
import "solid-devtools";
import { Route, Router } from "@solidjs/router";
-
-
import Login from "./routes/login";
-
-
const root = document.getElementById("root");
-
-
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
-
throw new Error(
-
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
-
);
-
}
+
import Splash from "./routes/splash";
+
import Base from "./base";
+
import Dashboard from "./routes/dashboard";
render(
() => (
-
<Router root={Login}>
-
<Route path="/" component={Login} />
+
<Router root={Base}>
+
<Route path="/" component={Splash} />
+
<Route path="/dash" component={Dashboard} />
</Router>
),
-
root!,
+
document.getElementById("root") as HTMLElement,
);
-71
src/routes/login.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";
-
import tangledLogo from "/tangled.svg?url";
-
import Container from "../components/container";
-
-
const Login: Component = () => {
-
return (
-
<>
-
<header>
-
<Navbar />
-
</header>
-
<main>
-
<div id="sidebar">
-
<Container
-
title="Log in"
-
children={
-
<div class="login">
-
<form name="login" id="login">
-
<label for="handle">Handle</label>
-
<br />
-
<input
-
type="text"
-
id="handle"
-
name="handle"
-
maxlength="255"
-
placeholder="soykaf.com"
-
required
-
/>
-
<br />
-
<button type="submit">Login</button>
-
</form>
-
</div>
-
}
-
/>
-
</div>
-
<div id="content">
-
<Container
-
title="About"
-
children={
-
<div class="container-content">
-
<img class={"typeface"} src={typefaceLogo} />
-
<h2>A Bluesky client with a familiar face</h2>
-
<hr />
-
<p>
-
<b>Bluroma</b> is a web client for Bluesky, built to provide a
-
customizable power-user experience. Its design is heavily
-
influenced by the <a href="https://pleroma.social">Pleroma</a>{" "}
-
and <a href="https://akkoma.social">Akkoma</a> projects, and
-
intends to provide a similar user interface for Bluesky users.
-
</p>
-
<div class="logo-crawl">
-
<a href="https://bsky.social/profile/did:plc:5szlrh3xkfxxsuu4mo6oe6h7">
-
<img src={blueskyLogo} />
-
</a>
-
<a href="https://tangled.org/@hexmani.ac/bluroma">
-
<img src={tangledLogo}></img>
-
</a>
-
</div>
-
</div>
-
}
-
/>
-
</div>
-
</main>
-
</>
-
);
-
};
-
-
export default Login;
+14
src/styles/button.scss
···
0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset,
0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
}
+
+
button:disabled,
+
.button:disabled {
+
color: #666769;
+
}
+
+
button:focus,
+
.button:focus {
+
outline: none;
+
box-shadow:
+
0px 0px 1px 2px rgba(185, 185, 186, 0.4) inset,
+
0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset,
+
0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
+
}
+12
static/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/"],
+
"scope": "atproto transition:generic",
+
"grant_types": ["authorization_code", "refresh_token"],
+
"response_types": ["code"],
+
"token_endpoint_auth_method": "none",
+
"application_type": "web",
+
"dpop_bound_access_tokens": true
+
}
+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;
+
}
+
}
+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 (
<>
<div id="sidebar">
···
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>
}
+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;
+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
+17 -4
package.json
···
{
-
"name": "vite-template-solid",
-
"version": "0.0.0",
-
"description": "",
+
"name": "bluroma",
+
"version": "0.1.0",
+
"description": "pleroma-like client for Bluesky",
+
"keywords": [
+
"atproto",
+
"bluesky",
+
"client"
+
],
+
"homepage": "https://tangled.org/@hexmani.ac/bluroma",
+
"bugs": {
+
"url": "https://tangled.org/@hexmani.ac/bluroma/issues"
+
},
+
"license": "AGPL-3.0-only",
+
"repository": {
+
"type": "git",
+
"url": "git+https://tangled.org/@hexmani.ac/bluroma.git"
+
},
"type": "module",
"scripts": {
"start": "vite",
···
"build": "vite build",
"serve": "vite preview"
},
-
"license": "MIT",
"devDependencies": {
"@types/bun": "^1.3.0",
"sass": "^1.81.0",
.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
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>
+3 -3
src/routes/splash.tsx
···
import { Component } from "solid-js";
import "../styles/main.scss";
-
import typefaceLogo from "/logo.png?url";
-
import blueskyLogo from "/bluesky.svg?url";
-
import tangledLogo from "/tangled.svg?url";
+
import typefaceLogo from "/media/logo.png?url";
+
import blueskyLogo from "/media/bluesky.svg?url";
+
import tangledLogo from "/media/tangled.svg?url";
import Container from "../components/container";
import { Login, loginState } from "../components/login";
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