maarcadetweet: initial commit

AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit.

- PDS (Rust + axum + sqlx)
  - Auth: createAccount, createSession, refreshSession
  - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE)
  - Feed: feed.like.create, feed.repost.create
  - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos
  - Identity: resolveHandle
  - MST: spec-conformant (at-mst crate, 27 tests)
  - Repo: signed commits, TID counter (monotonic, 4096 wrap safe)

- AppView (Rust + axum + sqlx)
  - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed)
  - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration)
  - Handle-sync worker (did:plc + did:web)
  - JSONB embed storage + thread columns (migration 0003)
  - Like/repost counter cache (migration 0004)

- Tauri 2 + Svelte 5 Desktop Client
  - System tray (Show/Compose/Quit menu)
  - OS notifications (tauri-plugin-notification)
  - Auto-update (tauri-plugin-updater, placeholder endpoint)
  - Window-state (tauri-plugin-window-state)
  - 160-char compose with live counter
  - Image/Link embed rendering
  - LocalStorage-persisted like state
  - Timeline with poll (prepend new posts)
  - Custom TitleBar (transparent, no decorations)
  - Orange/IBM Plex Mono maarcade design

Tests: 231 Rust + 9 vitest = 240 passed.
This commit is contained in:
tomdebone
2026-07-05 20:01:31 +02:00
commit c586fd39c9
134 changed files with 35279 additions and 0 deletions
+634
View File
@@ -0,0 +1,634 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
session,
pdsStatus,
fetchTimeline,
fetchProfile,
fetchSearch,
fetchPost,
type Session,
type Post,
type ProfileResponse,
} from "./lib/api/client";
import NavRail from "./lib/components/NavRail.svelte";
import StatusBar from "./lib/components/StatusBar.svelte";
import PostCard from "./lib/components/PostCard.svelte";
import ComposeBox from "./lib/components/ComposeBox.svelte";
import LoginScreen from "./lib/components/LoginScreen.svelte";
import Terminal from "./lib/components/Terminal.svelte";
import Skeleton from "./lib/components/Skeleton.svelte";
type View = "home" | "compose" | "profile" | "search";
let view: View = $state("home");
let currentUser: Session | null = $state(null);
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
// Home timeline state.
let userPosts: Post[] = $state([]);
let timelineCursor: string | null = $state(null);
let timelineLoading: boolean = $state(false);
let timelineError: string | null = $state(null);
let seenUris: Set<string> = new Set();
let _statusTimer: number | undefined;
let _timelinePollTimer: number | undefined;
// Profile state.
let profile: ProfileResponse | null = $state(null);
let profileLoading: boolean = $state(false);
let profileError: string | null = $state(null);
// Search state.
let searchQuery: string = $state("");
let searchResults: Post[] = $state([]);
let searchLoading: boolean = $state(false);
let searchError: string | null = $state(null);
let _searchDebounce: number | undefined;
// Thread state — when set, shows the thread root + parent + the focused post.
let threadRoot: Post | null = $state(null);
let threadParent: Post | null = $state(null);
let threadLoading: boolean = $state(false);
let threadError: string | null = $state(null);
// Toasts surfaced by child components via the `maarcadetweet:toast`
// window event. We keep the last few so a slow render doesn't
// wipe the message before the user reads it.
let toasts: { id: number; kind: "error" | "info"; text: string }[] = $state([]);
let _toastCounter = 0;
function pushToast(kind: "error" | "info", text: string) {
const id = ++_toastCounter;
toasts = [...toasts, { id, kind, text }];
setTimeout(() => {
toasts = toasts.filter((t) => t.id !== id);
}, 5000);
}
async function openThread(root_uri: string) {
threadLoading = true;
threadError = null;
threadRoot = null;
threadParent = null;
try {
const r = await fetchPost(root_uri);
threadRoot = r.post;
threadParent = r.thread.parent;
} catch (e) {
threadError = String(e);
} finally {
threadLoading = false;
}
}
function closeThread() {
threadRoot = null;
threadParent = null;
threadError = null;
}
// Tray / notification events dispatched from `main.ts` as DOM
// CustomEvents (so we don't depend on `@tauri-apps/api/event` here
// — `main.ts` is the only place that wires to the Tauri event bus).
function onNavigateToView(e: Event) {
const detail = (e as CustomEvent<{ view: View }>).detail;
if (detail?.view) view = detail.view;
}
function onNotification(e: Event) {
const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail;
if (detail) {
// Surface in the existing toast stack — works for both the
// OS-notification click and the in-app events.
pushToast("info", `notif: ${detail.title}${detail.body}`);
}
}
// We need to hoist these into the onMount closure (which is below)
// so the addEventListener / removeEventListener pair stays paired.
// (`_toastHandler` is the function below; the other two are
// reassigned inside the async onMount body.)
let _navigateHandler: ((e: Event) => void) | null = null;
let _notificationHandler: ((e: Event) => void) | null = null;
// Track cleanup refs WITHOUT awaiting before registering them.
// Svelte 5's `onDestroy` throws when called after the parent component
// context has been torn down (i.e. after an `await` in `onMount`).
// Register everything synchronously inside `onMount`, then do async
// work afterward.
let _sessionUnsub: (() => void) | null = null;
let _destroyRegistered = false;
function registerCleanup(teardown: () => void) {
if (_destroyRegistered) return;
onDestroy(teardown);
_destroyRegistered = true;
}
function _toastHandler(ev: Event) {
const e = ev as CustomEvent<{ kind: "error" | "info"; text: string }>;
if (!e.detail) return;
pushToast(e.detail.kind, e.detail.text);
}
onMount(() => {
registerCleanup(() => {
if (_sessionUnsub) _sessionUnsub();
if (_statusTimer) clearInterval(_statusTimer);
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
if (_searchDebounce) clearTimeout(_searchDebounce);
if (typeof window !== "undefined") {
window.removeEventListener("maarcadetweet:toast", _toastHandler);
window.removeEventListener("maarcadetweet:navigate", _navigateHandler!);
window.removeEventListener("maarcadetweet:notification", _notificationHandler!);
}
});
// Now safe to await.
(async () => {
await session.load();
_sessionUnsub = session.subscribe((s) => {
currentUser = s;
status = { ...status, did: s?.did, handle: s?.handle, authenticated: !!s };
});
if (typeof window !== "undefined") {
_navigateHandler = onNavigateToView;
_notificationHandler = onNotification;
window.addEventListener("maarcadetweet:toast", _toastHandler);
window.addEventListener("maarcadetweet:navigate", _navigateHandler);
window.addEventListener("maarcadetweet:notification", _notificationHandler);
}
_statusTimer = window.setInterval(async () => {
try {
status = await pdsStatus();
} catch {
/* ignore */
}
}, 5000);
})();
});
// Re-fetch the home timeline whenever we navigate to "home" or
// when the logged-in user changes. We also poll every 5s while
// the home view is active so new posts trickle in.
$effect(() => {
if (view === "home" && currentUser) {
void refreshTimeline(true);
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
_timelinePollTimer = window.setInterval(() => {
void refreshTimeline(false); // poll = prepend new posts, don't wipe
}, 5000);
} else if (_timelinePollTimer) {
clearInterval(_timelinePollTimer);
_timelinePollTimer = undefined;
}
if (view === "profile" && currentUser) {
void refreshProfile(currentUser.handle);
}
if (view === "search" && currentUser && searchQuery.trim().length > 0) {
scheduleSearch();
}
});
async function refreshTimeline(reset: boolean) {
if (!currentUser) return;
timelineLoading = true;
timelineError = null;
try {
const r = await fetchTimeline(currentUser.did, null, 30);
if (reset) {
// Full replace on explicit refresh.
seenUris = new Set(r.posts.map((p) => p.uri));
userPosts = r.posts;
timelineCursor = r.cursor;
} else {
// Poll: prepend posts we haven't seen yet.
const fresh: Post[] = [];
for (const p of r.posts) {
if (!seenUris.has(p.uri)) {
fresh.push(p);
seenUris.add(p.uri);
}
}
if (fresh.length > 0) userPosts = [...fresh, ...userPosts];
}
} catch (e) {
timelineError = String(e);
// Keep whatever we had on a transient failure.
} finally {
timelineLoading = false;
}
}
async function loadMoreTimeline() {
if (!currentUser || !timelineCursor || timelineLoading) return;
timelineLoading = true;
try {
const r = await fetchTimeline(currentUser.did, timelineCursor, 30);
for (const p of r.posts) {
if (!seenUris.has(p.uri)) {
seenUris.add(p.uri);
userPosts = [...userPosts, p];
}
}
timelineCursor = r.cursor;
} catch (e) {
timelineError = String(e);
} finally {
timelineLoading = false;
}
}
async function refreshProfile(handle: string) {
profileLoading = true;
profileError = null;
try {
profile = await fetchProfile(handle);
} catch (e) {
profileError = String(e);
profile = null;
} finally {
profileLoading = false;
}
}
function scheduleSearch() {
if (_searchDebounce) clearTimeout(_searchDebounce);
_searchDebounce = window.setTimeout(() => {
void runSearch();
}, 250);
}
function onSearchInput() {
// Empty input -> immediately clear results (no debounce).
if (searchQuery.trim().length === 0) {
if (_searchDebounce) {
clearTimeout(_searchDebounce);
_searchDebounce = undefined;
}
searchResults = [];
searchError = null;
return;
}
scheduleSearch();
}
async function runSearch() {
const needle = searchQuery.trim();
if (!needle) {
searchResults = [];
searchError = null;
return;
}
searchLoading = true;
searchError = null;
try {
const r = await fetchSearch(needle, 30);
searchResults = r.posts;
} catch (e) {
searchError = String(e);
searchResults = [];
} finally {
searchLoading = false;
}
}
async function handlePosted() {
// After the user posts, reset to page 1 so they see their own post.
await refreshTimeline(true);
}
// Derive a display handle. The session already gives us the user's
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
// posts that have empty handles it falls back to a synthetic
// "@did:plc:abcd…" form, so the fallback here matches that.
function displayHandle(h: string | null | undefined): string {
if (!h) return "@unknown";
if (h.startsWith("@")) return h;
return `@${h}`;
}
</script>
{#if !currentUser}
<div class="login-wrap">
<LoginScreen
onLogin={(s) => {
currentUser = s;
view = "home";
}}
/>
</div>
{:else}
<div class="shell">
<NavRail bind:current={view} />
<div class="main">
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
{#if view === "home"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// home —</span>
<span class="as">@{currentUser.handle}</span>
<span class="meta">{userPosts.length} posts · polling every 5s</span>
</div>
{#if timelineError}
<div class="toast toast--err">err: {timelineError}</div>
{/if}
{#if timelineLoading && userPosts.length === 0}
<Skeleton rows={3} />
{:else if userPosts.length === 0}
<div class="empty">// timeline is empty. compose your first post →</div>
{:else}
{#if threadRoot}
<div class="thread-modal">
<header class="thread-modal__head">
<span class="crumb">// thread</span>
<button class="btn--ghost" onclick={closeThread}>close</button>
</header>
{#if threadLoading}
<Skeleton rows={2} />
{:else if threadError}
<div class="toast toast--err">err: {threadError}</div>
{:else if threadRoot}
{#if threadParent && threadParent.uri !== threadRoot.uri}
<div class="thread-parent"><PostCard post={threadParent} /></div>
{/if}
<PostCard post={threadRoot} />
{/if}
</div>
{/if}
{#each userPosts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} />
{/each}
{#if timelineCursor}
<div class="loadmore">
<button class="btn btn--ghost" onclick={() => void loadMoreTimeline()} disabled={timelineLoading}>
{timelineLoading ? "loading…" : "load more"}
</button>
</div>
{/if}
{/if}
{:else if view === "compose"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// compose —</span>
<span class="as">@{currentUser.handle}</span>
<span class="meta">⌘↵ to post</span>
</div>
<ComposeBox onPosted={handlePosted} />
{:else if view === "profile"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// profile —</span>
<span class="as">@{currentUser.handle}</span>
</div>
{#if profileLoading && !profile}
<Skeleton rows={4} />
{:else if profileError}
<div class="toast toast--err">err: {profileError}</div>
{:else if profile}
<section class="profile">
<header class="profile__head">
<span class="profile__handle">{displayHandle(profile.handle)}</span>
<span class="profile__did" title={profile.did}>{profile.did}</span>
</header>
<dl class="counts">
<div>
<dt>followers</dt>
<dd>{profile.followers}</dd>
</div>
<div>
<dt>following</dt>
<dd>{profile.following}</dd>
</div>
<div>
<dt>posts</dt>
<dd>{profile.posts.length}</dd>
</div>
</dl>
{#if profile.posts.length === 0}
<div class="empty">// no posts yet</div>
{:else}
{#each profile.posts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} />
{/each}
{/if}
</section>
{/if}
{:else if view === "search"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// search</span>
</div>
<input
class="search"
type="text"
bind:value={searchQuery}
oninput={onSearchInput}
placeholder="grep posts…"
/>
{#if searchError}
<div class="toast toast--err">err: {searchError}</div>
{/if}
{#if searchLoading}
<Skeleton rows={2} />
{:else if searchQuery.trim().length === 0}
<div class="empty">// type to search…</div>
{:else if searchResults.length === 0}
<div class="empty">// no posts match "{searchQuery}"</div>
{:else}
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
{#each searchResults as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} />
{/each}
{/if}
{/if}
</Terminal>
</div>
<StatusBar did={status.did ?? ""} authenticated={status.authenticated} />
</div>
{#if toasts.length > 0}
<div class="toasts" role="status" aria-live="polite">
{#each toasts as t (t.id)}
<button
class="toast-pill"
class:toast-pill--err={t.kind === "error"}
type="button"
onclick={() => (toasts = toasts.filter((x) => x.id !== t.id))}
title="dismiss"
>
{t.kind === "error" ? "err" : "info"}: {t.text}
</button>
{/each}
</div>
{/if}
{/if}
<style>
.login-wrap {
height: 100%;
display: grid;
place-items: center;
}
.shell {
display: grid;
grid-template-rows: 1fr 24px;
grid-template-columns: 88px 1fr;
grid-template-areas:
"rail main"
"rail status";
height: 100%;
}
.shell > :global(nav.rail) { grid-area: rail; }
.shell > :global(.statusbar) { grid-area: status; }
.main {
grid-area: main;
overflow: auto;
padding: var(--s-3);
}
.head {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
display: flex;
flex-wrap: wrap;
gap: var(--s-3);
align-items: center;
padding: 0 var(--s-2) var(--s-3);
border-bottom: 1px dashed var(--line);
margin-bottom: var(--s-3);
}
.prompt { color: var(--orange); }
.title { color: var(--orange); font-weight: 700; }
.as { color: var(--text); }
.meta { color: var(--text-dim); margin-left: auto; font-variant-numeric: tabular-nums; }
.meta--results { padding: var(--s-2) var(--s-5); margin: 0; }
.toast {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: var(--s-2) var(--s-3);
margin: 0 var(--s-5) var(--s-3);
border-left: 3px solid var(--red);
background: rgba(255, 59, 48, 0.08);
color: var(--red);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.toast--err { border-left-color: var(--red); }
.empty {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
padding: var(--s-4) var(--s-5);
font-style: italic;
}
.search {
display: block;
width: calc(100% - 2 * var(--s-5));
margin: 0 var(--s-5) var(--s-3);
background: var(--bg);
border: 1px solid var(--line-2);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
padding: var(--s-2) var(--s-3);
border-radius: var(--r-sm);
outline: none;
}
.search:focus { border-color: var(--orange); }
.search::placeholder { color: var(--text-dim); }
.loadmore {
display: flex;
justify-content: center;
padding: var(--s-4) var(--s-5);
}
.btn {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.4rem 0.8rem;
border-radius: var(--r-sm);
border: 1px solid transparent;
cursor: pointer;
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.btn--ghost { color: var(--text-dim); border-color: var(--line-2); background: transparent; }
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.profile {
padding: 0 var(--s-3);
}
.profile__head {
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-3) 0 var(--s-4);
border-bottom: 1px solid var(--line);
margin-bottom: var(--s-3);
}
.profile__handle {
font-family: var(--font-mono);
font-weight: 700;
font-size: var(--fs-200);
color: var(--orange);
}
.profile__did {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
word-break: break-all;
}
.counts {
display: flex;
gap: var(--s-6);
padding: var(--s-2) var(--s-4);
margin: 0 0 var(--s-4);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.counts > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.counts dt { color: var(--text-dim); letter-spacing: 0.04em; }
.counts dd {
margin: 0;
color: var(--text);
font-weight: 700;
font-size: var(--fs-200);
font-variant-numeric: tabular-nums;
}
.toasts {
position: fixed;
right: var(--s-4);
bottom: calc(24px + var(--s-3));
display: flex;
flex-direction: column;
gap: var(--s-2);
z-index: 100;
max-width: 360px;
}
.toast-pill {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: var(--s-2) var(--s-3);
border-radius: var(--r-sm);
background: var(--bg-elev);
color: var(--text);
border: 1px solid var(--line-2);
cursor: pointer;
text-align: left;
line-height: 1.4;
}
.toast-pill:hover { border-color: var(--orange); }
.toast-pill--err {
color: var(--red);
border-color: var(--red);
background: rgba(255, 59, 48, 0.08);
}
</style>
+93
View File
@@ -0,0 +1,93 @@
@import "./lib/styles/tokens.css";
@font-face {
font-family: "IBMPlexMono";
src: url("/fonts/IBMPlexMono-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IBMPlexMono";
src: url("/fonts/IBMPlexMono-Medium.ttf") format("truetype");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IBMPlexMono";
src: url("/fonts/IBMPlexMono-SemiBold.ttf") format("truetype");
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IBMPlexMono";
src: url("/fonts/IBMPlexMono-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "NotoSans";
src: url("/fonts/NotoSans-VF.ttf") format("truetype-variations"),
url("/fonts/NotoSans-VF.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: var(--fs-100);
line-height: var(--lh-body);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
}
body {
background-image:
linear-gradient(var(--orange-3) 1px, transparent 1px),
linear-gradient(90deg, var(--orange-3) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
background-position: center top;
}
h1, h2, h3, h4 {
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: var(--tracking-tight);
line-height: var(--lh-tight);
margin: 0;
}
p { margin: 0; }
a { color: inherit; text-decoration: none; }
img, svg { display: block; max-width: 100%; }
::selection { background: var(--orange); color: var(--bg); }
:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 3px;
border-radius: 2px;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--line); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#0D0D0D"/>
<circle cx="16" cy="16" r="11" fill="rgba(255,102,0,0.08)" stroke="rgba(255,102,0,0.5)" stroke-width="1.2"/>
<g font-family="ui-monospace, monospace" font-weight="700" font-size="11" fill="#FF6600" text-anchor="middle">
<text x="16" y="21">&gt;_</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 403 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="12" fill="#0D0D0D"/>
<circle cx="32" cy="32" r="22" fill="rgba(255,102,0,0.08)" stroke="rgba(255,102,0,0.5)" stroke-width="1.5"/>
<g font-family="ui-monospace, monospace" font-weight="700" font-size="22" fill="#FF6600" text-anchor="middle">
<text x="32" y="42">&gt;_</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 404 B

+348
View File
@@ -0,0 +1,348 @@
import { invoke } from "@tauri-apps/api/core";
import { writable } from "svelte/store";
export type Session = {
did: string;
handle: string;
access_jwt: string;
refresh_jwt: string;
};
function createSessionStore() {
const { subscribe, set } = writable<Session | null>(null);
return {
subscribe,
async load() {
try {
const s = await invoke<Session | null>("current_session");
set(s);
} catch (e) {
console.error("current_session failed", e);
}
},
async login(handle: string, password: string) {
const s = await invoke<Session>("auth_login", { identifier: handle, password });
set(s);
return s;
},
async register(handle: string, password: string) {
const s = await invoke<Session>("auth_register", { handle, password });
set(s);
return s;
},
async logout() {
try {
await invoke("auth_logout");
} catch (e) {
console.error("logout failed", e);
}
set(null);
},
};
}
export const session = createSessionStore();
/// AT-Protocol embed variants. We keep the on-the-wire JSON verbatim
/// (instead of narrowing to one specific shape per variant) so that
/// adding a new embed type upstream doesn't require a frontend change.
/// The UI sniffs `embed.$type` to decide which sub-component to render.
export type Embed = {
$type: string;
images?: EmbedImage[];
external?: EmbedExternal;
record?: EmbedRecord;
media?: EmbedRecordWithMedia;
[k: string]: unknown;
};
export type EmbedImage = {
alt?: string;
image?: unknown;
aspectRatio?: { width: number; height: number };
};
export type EmbedExternal = {
uri: string;
title?: string;
description?: string;
thumb?: string;
};
export type EmbedRecord = {
uri: string;
cid?: string;
author?: { did: string; handle?: string };
value?: { text?: string; createdAt?: string };
};
export type EmbedRecordWithMedia = Embed & {
record: EmbedRecord;
media: { images?: EmbedImage[]; external?: EmbedExternal };
};
/// Post shape returned by the AppView REST API. The Tauri command
/// (also named `Post` on the Rust side) re-exports this as
/// `appview_client::PostDto` and the frontend receives it as-is.
export type Post = {
uri: string;
did: string;
handle: string;
rkey: string;
collection: string;
text: string;
cid: string;
parent_uri?: string | null;
root_uri?: string | null;
embed?: Embed | null;
langs: string[];
created_at: string;
};
export type TimelineResponse = {
posts: Post[];
cursor: string | null;
};
export type ProfileResponse = {
did: string;
handle: string;
posts: Post[];
followers: number;
following: number;
};
export type SearchResponse = {
posts: Post[];
q: string;
};
/// `GET /api/post/{uri}` response. Used by the UI when the user
/// expands a reply to fetch the parent + root in one round trip.
///
/// `like_count` and `repost_count` are present when the post was
/// found; they're `undefined` (or absent) for the "not in index"
/// sentinel response (where `post` is null). AppView has no auth
/// yet, so `viewer_liked` / `viewer_reposted` aren't returned.
export type ThreadResponse = {
post: Post | null;
thread: {
parent: Post | null;
root: Post | null;
};
like_count?: number;
repost_count?: number;
};
export async function createPost(text: string): Promise<Post> {
// The Rust post_create command returns a different shape (uri+cid
// only), but we keep the call simple: it gives us the cid we need
// to show the "ok" toast.
return await invoke<any>("post_create", { text });
}
export async function describeServer(): Promise<any> {
return await invoke("pds_describe");
}
export async function pdsStatus(): Promise<any> {
return await invoke("status_pds");
}
export async function fetchTimeline(
did: string,
cursor: string | null = null,
limit: number = 30,
): Promise<TimelineResponse> {
return await invoke<TimelineResponse>("timeline_home", {
did,
cursor,
limit,
});
}
export async function fetchProfile(handle: string): Promise<ProfileResponse> {
return await invoke<ProfileResponse>("profile_get", { handle });
}
export async function fetchSearch(
q: string,
limit: number = 30,
): Promise<SearchResponse> {
return await invoke<SearchResponse>("search", { q, limit });
}
export async function fetchPost(uri: string): Promise<ThreadResponse> {
return await invoke<ThreadResponse>("post_get", { uri });
}
/// `app.bsky.feed.like.create` — Tauri command. Builds the
/// flat-shape like body on the Rust side, signs a commit, pushes
/// to the AppView. Returns the new like's `uri` and `cid`.
export type RepoWriteResult = { uri: string; cid: string };
/// `com.atproto.repo.deleteRecord` — Tauri command. Used for
/// both unlike and unrepost. The Rust side splits the URI into
/// `rkey` and sends it.
export type DeleteRecordResult = { commit: { cid: string; rev: string } };
export async function likePost(
subjectUri: string,
subjectCid: string,
): Promise<RepoWriteResult> {
return await invoke<RepoWriteResult>("like_post", {
subjectUri,
subjectCid,
});
}
export async function unlikePost(likeUri: string): Promise<DeleteRecordResult> {
return await invoke<DeleteRecordResult>("unlike_post", { likeUri });
}
export async function repostPost(
subjectUri: string,
subjectCid: string,
): Promise<RepoWriteResult> {
return await invoke<RepoWriteResult>("repost_post", {
subjectUri,
subjectCid,
});
}
export async function unrepostPost(
repostUri: string,
): Promise<DeleteRecordResult> {
return await invoke<DeleteRecordResult>("unrepost_post", { repostUri });
}
/// Fire-and-forget user-visible error toast. Implemented as a
/// `window` `CustomEvent` so any component can show errors without
/// pulling in a global store. `App.svelte` listens for the event
/// and renders the toast UI.
export function showError(text: string): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent("maarcadetweet:toast", { detail: { kind: "error", text } }),
);
}
/// Show a native OS notification. Thin wrapper around the
/// `show_notification` Tauri command. The Rust side also emits an
/// `app://notification` event with the same payload, so the click
/// listener (registered via `listenNotification`) can route to a URL.
///
/// Pass `url` to make the notification clickable: when the user
/// clicks, the Rust command focuses the main window and the JS
/// listener navigates.
export async function showNotification(
title: string,
body: string,
url?: string,
): Promise<void> {
try {
await invoke("show_notification", { title, body, url: url ?? null });
} catch (e) {
console.error("show_notification failed", e);
}
}
/// Subscribe to system-tray menu events. The Tauri tray menu emits
/// `app://show` ("Show maarcadetweet") and `app://compose` ("Compose");
/// the Rust side already turns left-clicks into `app://show` and the
/// "Quit" menu item into a clean `app.exit(0)`.
///
/// The handler receives the event payload (empty object for these
/// cases). Returns an unsubscribe function.
export async function listenTrayEvents(
handler: (event: "show" | "compose") => void,
): Promise<() => void> {
const { listen } = await import("@tauri-apps/api/event");
const unlisteners: Array<() => void> = [];
const u1 = await listen("app://show", () => handler("show"));
const u2 = await listen("app://compose", () => handler("compose"));
unlisteners.push(u1, u2);
return () => {
for (const u of unlisteners) u();
};
}
/// Subscribe to `app://notification` events emitted by the Rust
/// `show_notification` command. Used to focus the window and
/// navigate when the user clicks the notification.
export async function listenNotification(
handler: (payload: {
title: string;
body: string;
url: string | null;
}) => void,
): Promise<() => void> {
const { listen } = await import("@tauri-apps/api/event");
const u = await listen<{ title: string; body: string; url: string | null }>(
"app://notification",
(e) => handler(e.payload),
);
return u;
}
// -- blob fetch + cache -----------------------------------------------------
//
// Image embeds reference blobs by CID. The blob bytes themselves
// live on the user's PDS — the AppView only has the CID. The Tauri
// shell handles the PDS HTTP call (`fetch_blob` command) so the
// frontend never has to know the PDS URL.
//
// We cache the *resolved object URL* (not the raw bytes) keyed
// by CID, because:
// * the blob bytes are addressed by content hash, so the same
// CID always resolves to the same bytes regardless of DID;
// * the `<img>` element takes an object URL, not raw bytes, so
// handing the URL straight back to the caller saves a
// Blob/URL.createObjectURL call per render.
const _blobUrlCache = new Map<string, string>();
/// Fetch the raw blob bytes for `cid` and return an object URL
/// suitable for `<img src={...}>`. Caches the URL in-process so
/// navigating the timeline doesn't re-download already-seen
/// images.
export async function fetchBlob(
did: string,
cid: string,
): Promise<string> {
const cached = _blobUrlCache.get(cid);
if (cached) return cached;
const bytes: number[] = await invoke<number[]>("fetch_blob", {
did,
cid,
});
const u8 = new Uint8Array(bytes);
if (u8.length === 0) {
throw new Error(`empty blob for cid ${cid}`);
}
const blob = new Blob([u8]);
const url = URL.createObjectURL(blob);
_blobUrlCache.set(cid, url);
return url;
}
/// Drop the cached object URL and remove it. Components should
/// call this when an `<img>` is unmounted to avoid leaking the
/// underlying Blob. For a Tauri WebView with at most a few
/// dozen visible images the OS cleans up anyway, but explicit
/// revocation makes long sessions friendlier on memory.
export function releaseBlob(cid: string): void {
const url = _blobUrlCache.get(cid);
if (url) {
URL.revokeObjectURL(url);
_blobUrlCache.delete(cid);
}
}
/// Test/internal: clear the in-memory blob cache.
export function clearBlobCache(): void {
for (const url of _blobUrlCache.values()) {
URL.revokeObjectURL(url);
}
_blobUrlCache.clear();
}
@@ -0,0 +1,149 @@
<script lang="ts">
import { createPost, type Post } from "../api/client";
const MAX = 160;
let { onPosted }: { onPosted?: () => void } = $props();
let text: string = $state("");
let isPosting: boolean = $state(false);
let status: { kind: "ok" | "err" | "info"; msg: string } | null = $state(null);
let remaining = $derived(MAX - text.length);
let counterClass = $derived(
remaining < 0 ? "counter counter--err" :
remaining < 40 ? "counter counter--warn" : "counter"
);
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
post();
}
}
async function post() {
if (!text.trim() || remaining < 0 || isPosting) return;
isPosting = true;
status = { kind: "info", msg: "> posting…" };
try {
const r: Post = await createPost(text);
status = { kind: "ok", msg: `> ok (cid: ${(r as any).cid?.slice?.(0, 8) ?? "?"}…)` };
text = "";
onPosted?.();
} catch (e) {
status = { kind: "err", msg: `> ${String(e)}` };
} finally {
isPosting = false;
}
}
</script>
<div class="compose">
<div class="compose__head">
<span class="title">// compose</span>
<span class="handle">@you</span>
<span class={counterClass}>{remaining}</span>
</div>
<div class="compose__body">
<span class="prompt">$</span>
<textarea
bind:value={text}
onkeydown={handleKeydown}
placeholder="// what's happening in 160 chars?"
rows="3"
maxlength="500"
></textarea>
</div>
<div class="compose__foot">
<span class="hint">⌘↵ to post</span>
<div class="actions">
<button class="btn btn--ghost" onclick={() => (text = "")} disabled={!text || isPosting}>draft</button>
<button class="btn btn--primary" onclick={post} disabled={!text.trim() || remaining < 0 || isPosting}>
{isPosting ? "posting…" : "post"}
</button>
</div>
</div>
{#if status}
<div class="status status--{status.kind}">{status.msg}</div>
{/if}
</div>
<style>
.compose {
background: var(--bg-elev);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
margin: var(--s-4) var(--s-5);
}
.compose__head {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
background: var(--bg-deep);
border-bottom: 1px solid var(--line);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.title { color: var(--orange); }
.handle { color: var(--text-dim); flex: 1; }
.counter { color: var(--text-dim); font-variant-numeric: tabular-nums; }
.counter--warn { color: var(--orange); }
.counter--err { color: var(--red); letter-spacing: 0.05em; }
.compose__body {
display: flex;
align-items: flex-start;
gap: var(--s-2);
padding: var(--s-3) var(--s-4);
}
.prompt {
color: var(--orange);
font-family: var(--font-mono);
font-size: var(--fs-100);
line-height: 1.7;
}
textarea {
flex: 1;
background: transparent;
border: 0;
color: var(--text);
font-family: var(--font-sans);
font-size: var(--fs-100);
line-height: 1.6;
resize: vertical;
outline: none;
padding: 0;
}
textarea::placeholder { color: var(--text-dim); }
.compose__foot {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-2) var(--s-4);
border-top: 1px solid var(--line);
}
.hint { font-family: var(--font-mono); font-size: var(--fs-50); color: var(--text-dim); }
.actions { display: flex; gap: var(--s-2); }
.btn {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.4rem 0.8rem;
border-radius: var(--r-sm);
border: 1px solid transparent;
cursor: pointer;
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.btn--ghost { color: var(--text-dim); border-color: var(--line-2); background: transparent; }
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.status {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: var(--s-2) var(--s-4);
border-top: 1px solid var(--line);
}
.status--ok { color: var(--green); }
.status--err { color: var(--red); }
.status--info { color: var(--orange); }
</style>
@@ -0,0 +1,98 @@
<script lang="ts">
import type { EmbedExternal } from "../api/client";
let { external }: { external: EmbedExternal } = $props();
function hostname(uri: string): string {
try {
return new URL(uri).hostname.replace(/^www\./, "");
} catch {
return uri;
}
}
</script>
<a
class="embed-external"
href={external.uri}
target="_blank"
rel="noopener noreferrer nofollow"
>
<div class="embed-external__body">
<div class="embed-external__title">{external.title || external.uri}</div>
{#if external.description}
<div class="embed-external__desc">{external.description}</div>
{/if}
<div class="embed-external__host">{hostname(external.uri)}</div>
</div>
{#if external.thumb}
<div class="embed-external__thumb" aria-hidden="true">
<span>thumb</span>
</div>
{/if}
</a>
<style>
.embed-external {
display: flex;
align-items: stretch;
gap: var(--s-3);
margin: var(--s-2) 0 var(--s-3);
padding: var(--s-3);
border: 1px solid var(--line-2);
border-left: 3px solid var(--orange);
border-radius: var(--r-sm);
background: var(--bg-elev);
text-decoration: none;
color: var(--text);
transition: border-color var(--dur) var(--ease);
max-width: 520px;
}
.embed-external:hover { border-color: var(--orange); }
.embed-external__body {
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
flex: 1;
}
.embed-external__title {
font-family: var(--font-sans);
font-weight: 700;
font-size: var(--fs-100);
line-height: var(--lh-snug);
color: var(--text);
word-wrap: break-word;
}
.embed-external__desc {
font-family: var(--font-sans);
font-size: var(--fs-50);
color: var(--text-dim);
line-height: var(--lh-body);
word-wrap: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.embed-external__host {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
margin-top: var(--s-1);
}
.embed-external__thumb {
width: 64px;
height: 64px;
flex-shrink: 0;
border: 1px dashed var(--line-2);
border-radius: var(--r-sm);
display: grid;
place-items: center;
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
background: var(--bg);
}
</style>
@@ -0,0 +1,214 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { fetchBlob, releaseBlob } from "../api/client";
// The shape of a single image in an `app.bsky.embed.images` record.
// We keep it loose because the AppView passes `embed` through as
// a JSON blob, not a typed struct.
type BlobRef = {
$type?: "blob";
ref?: { $link?: string };
mimeType?: string;
size?: number;
};
type EmbedImageProps = {
image: {
alt?: string;
image?: BlobRef | unknown;
aspectRatio?: { width: number; height: number };
};
/**
* DID of the post's author — needed to fetch the blob from the
* right PDS. Required for image embeds, optional for record
* embeds where we render an icon only.
*/
did?: string;
};
let { image, did }: EmbedImageProps = $props();
const blobRef = $derived((image?.image ?? null) as BlobRef | null);
const cid = $derived(blobRef?.ref?.$link ?? null);
// Lifecycle of an image fetch:
// * `loading=true` → show a skeleton at the aspect-ratio frame.
// * success → render `<img>` with the cached object URL.
// * error → render the alt text in the existing
// striped placeholder so the user still sees
// something (and screen readers get the alt).
//
// The object URL is cached in `client.ts` so re-renders of the
// same CID (e.g. when scrolling the same image back into view)
// reuse the same URL instead of allocating a fresh one each time.
let objectUrl: string | null = $state(null);
let loading: boolean = $state(false);
let errored: boolean = $state(false);
let errorMsg: string = $state("");
// Track the cid we last fetched so we know when to release the
// URL back to the cache on a cid change.
let currentCid: string | null = null;
async function loadImage(didStr: string, c: string) {
loading = true;
errored = false;
errorMsg = "";
try {
const url = await fetchBlob(didStr, c);
objectUrl = url;
currentCid = c;
} catch (e) {
errored = true;
errorMsg = String(e);
} finally {
loading = false;
}
}
$effect(() => {
if (cid && did) {
loadImage(did, cid);
} else {
// No cid (or no did) — fall back to alt-text placeholder.
errored = !image?.alt;
}
});
onDestroy(() => {
// Release the cached URL when this component goes away.
// For a long-running timeline this keeps the in-memory
// cache bounded to the visible images. The browser will
// free the underlying blob either way when the WebView
// navigates; this is just hygiene.
if (currentCid) {
releaseBlob(currentCid);
currentCid = null;
}
objectUrl = null;
});
</script>
<figure class="embed-image">
<div
class="embed-image__frame"
class:embed-image__frame--err={errored}
class:embed-image__frame--loading={loading}
style={image.aspectRatio
? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height};`
: ""}
>
{#if loading}
<div class="embed-image__skel" aria-busy="true" aria-live="polite">
<span class="embed-image__skel-bar"></span>
<span class="embed-image__skel-bar embed-image__skel-bar--short"></span>
</div>
{:else if objectUrl}
<img
class="embed-image__img"
src={objectUrl}
alt={image.alt ?? ""}
loading="lazy"
decoding="async"
/>
{:else}
<span class="embed-image__alt" title={image.alt ?? ""}>
{image.alt || (errored ? "image unavailable" : "image")}
</span>
{/if}
</div>
{#if image.alt || errored}
<figcaption class="embed-image__caption">
{errored
? `couldn't load image: ${errorMsg}`
: `alt: ${image.alt}`}
</figcaption>
{/if}
</figure>
<style>
.embed-image {
margin: var(--s-2) 0 var(--s-3);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
overflow: hidden;
background: var(--bg-elev);
max-width: 480px;
}
.embed-image__frame {
width: 100%;
min-height: 80px;
display: grid;
place-items: center;
background: repeating-linear-gradient(
45deg,
var(--bg-elev),
var(--bg-elev) 10px,
var(--bg) 10px,
var(--bg) 20px
);
}
.embed-image__frame--loading {
background: var(--bg-elev);
animation: img-pulse 1.4s ease-in-out infinite;
}
.embed-image__frame--err {
background: var(--bg);
}
.embed-image__skel {
display: flex;
flex-direction: column;
gap: var(--s-2);
align-items: center;
padding: var(--s-3);
width: 100%;
}
.embed-image__skel-bar {
height: 6px;
border-radius: var(--r-sm);
background: linear-gradient(
90deg,
var(--bg-elev) 0%,
var(--line-2) 50%,
var(--bg-elev) 100%
);
background-size: 200% 100%;
animation: img-shimmer 1.4s ease-in-out infinite;
width: 80%;
}
.embed-image__skel-bar--short { width: 40%; }
.embed-image__img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.embed-image__alt {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
padding: var(--s-3);
text-align: center;
word-wrap: break-word;
}
.embed-image__caption {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
padding: var(--s-2) var(--s-3);
border-top: 1px dashed var(--line);
}
@keyframes img-pulse {
0%, 100% { opacity: 0.95; }
50% { opacity: 0.7; }
}
@keyframes img-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.embed-image__frame--loading,
.embed-image__skel-bar { animation: none; }
}
</style>
@@ -0,0 +1,169 @@
<script lang="ts">
import { onMount } from "svelte";
import { session, describeServer, type Session } from "../api/client";
let { onLogin }: { onLogin: (s: Session) => void } = $props();
let mode: "login" | "register" = $state("register");
let handle: string = $state("");
let password: string = $state("");
let busy = $state(false);
let error: string | null = $state(null);
let serverInfo: any = $state(null);
onMount(async () => {
try {
serverInfo = await describeServer();
} catch (e) {
serverInfo = { error: String(e) };
}
});
async function submit() {
if (!handle.trim() || !password) return;
busy = true;
error = null;
try {
const s = mode === "register"
? await session.register(handle, password)
: await session.login(handle, password);
onLogin(s);
} catch (e) {
error = String(e);
} finally {
busy = false;
}
}
</script>
<div class="login">
<div class="terminal-head">
<div class="dots"><i></i><i></i><i></i></div>
<div class="t">maarcadetweet — {mode}</div>
</div>
<div class="terminal-body">
<div class="line">
<span class="prompt">$</span> maarcadetweet {mode}
</div>
<div class="line muted">// the timeline that fits in 160 chars.</div>
<div class="line">&nbsp;</div>
{#if serverInfo}
<div class="line muted">// pds: {serverInfo.did ?? "?"}</div>
<div class="line muted">// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</div>
{/if}
<div class="line">&nbsp;</div>
<div class="form">
<label>
<span class="key">handle:</span>
<input
type="text"
bind:value={handle}
placeholder="alice.maarcadetweet.local"
disabled={busy}
/>
</label>
<label>
<span class="key">password:</span>
<input
type="password"
bind:value={password}
placeholder="≥ 8 chars"
disabled={busy}
onkeydown={(e) => e.key === "Enter" && submit()}
/>
</label>
</div>
{#if error}
<div class="line err">error: {error}</div>
{/if}
<div class="line">&nbsp;</div>
<div class="line">
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
{busy ? "..." : mode === "register" ? "create account" : "login"}
</button>
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
{mode === "register" ? "have an account? login" : "no account? register"}
</button>
</div>
</div>
</div>
<style>
.login {
background: var(--bg-deep);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
overflow: hidden;
width: min(560px, 92vw);
}
.terminal-head {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: var(--bg-elev);
border-bottom: 1px solid var(--line);
}
.dots { display: flex; gap: 7px; }
.dots i {
width: 11px;
height: 11px;
border-radius: var(--r-pill);
background: var(--line-2);
display: block;
}
.dots i:first-child { background: #4a4a4a; }
.t {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
margin-inline: auto;
}
.terminal-body {
font-family: var(--font-mono);
font-size: 0.95rem;
line-height: 1.85;
padding: var(--s-5);
}
.line { white-space: pre-wrap; }
.muted { color: var(--text-dim); }
.prompt { color: var(--orange); }
.err { color: var(--red); }
.form {
display: flex;
flex-direction: column;
gap: var(--s-3);
margin: var(--s-4) 0;
}
.form label {
display: flex;
align-items: center;
gap: var(--s-3);
}
.key { color: var(--orange); width: 90px; flex-shrink: 0; }
.form input {
flex: 1;
background: var(--bg);
border: 1px solid var(--line-2);
color: var(--text);
font-family: var(--font-mono);
padding: var(--s-2) var(--s-3);
border-radius: var(--r-sm);
outline: none;
}
.form input:focus { border-color: var(--orange); }
.btn {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.5rem 0.8rem;
border-radius: var(--r-sm);
border: 1px solid transparent;
cursor: pointer;
margin-right: var(--s-2);
}
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
.btn--ghost { background: transparent; color: var(--text-dim); border-color: var(--line-2); }
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
@@ -0,0 +1,91 @@
<script lang="ts">
type View = "home" | "compose" | "profile" | "search";
let { current = $bindable<View>("home") }: { current: View } = $props();
const items: Array<{ id: View; label: string; key: string; icon: string }> = [
{ id: "home", label: "home", key: "g h", icon: "home" },
{ id: "compose", label: "compose", key: "c", icon: "compose" },
{ id: "profile", label: "profile", key: "p", icon: "profile" },
{ id: "search", label: "search", key: "/", icon: "search" },
];
</script>
<nav class="rail" aria-label="navigation">
{#each items as item}
<button
class="rail__btn"
class:active={current === item.id}
onclick={() => (current = item.id)}
title={`${item.label} (${item.key})`}
aria-current={current === item.id ? "page" : undefined}
>
<span class="icon">
{#if item.icon === "home"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M3 11l9-8 9 8v9a2 2 0 0 1-2 2h-3v-7H8v7H5a2 2 0 0 1-2-2z"/>
</svg>
{:else if item.icon === "compose"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<text x="3" y="17" font-family="ui-monospace,monospace" font-size="14" font-weight="700" fill="currentColor" stroke="none">&gt;_</text>
</svg>
{:else if item.icon === "profile"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-7 8-7s8 3 8 7"/>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
</svg>
{/if}
</span>
<span class="label">{item.label}</span>
</button>
{/each}
</nav>
<style>
.rail {
width: 88px;
background: var(--bg);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-3) 0;
flex-shrink: 0;
}
.rail__btn {
background: transparent;
border: 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: var(--s-3) var(--s-2);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border-bottom: 1px solid transparent;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.rail__btn:hover, .rail__btn:focus-visible {
color: var(--text);
}
.rail__btn.active {
color: var(--orange);
border-bottom-color: var(--orange);
}
.icon {
width: 24px;
height: 24px;
display: grid;
place-items: center;
}
.icon :global(svg) {
width: 22px;
height: 22px;
}
.label { letter-spacing: 0.04em; }
</style>
@@ -0,0 +1,496 @@
<script lang="ts">
import {
fetchPost,
likePost,
unlikePost,
repostPost,
unrepostPost,
session,
showError,
type Post,
} from "../api/client";
import EmbedImage from "./EmbedImage.svelte";
import EmbedExternal from "./EmbedExternal.svelte";
import { localStorageKey, useLocalStorage } from "../utils/localstorage";
type Props = { post: Post; on_thread_click?: (uri: string) => void };
let { post, on_thread_click }: Props = $props();
// Quoted-post cache. When the post's embed is a `record`, we fetch
// it once on mount and cache it keyed by URI so navigating
// timeline → profile doesn't re-fetch the same quote.
let quoted: Post | null = $state(null);
let quotedErr: string | null = $state(null);
let quotedLoading: boolean = $state(false);
$effect(() => {
const rec = (post.embed?.$type === "app.bsky.embed.record" || post.embed?.$type === "app.bsky.embed.recordWithMedia")
? post.embed?.record
: null;
if (rec?.uri && !quoted && !quotedLoading) {
quotedLoading = true;
fetchPost(rec.uri)
.then((r) => {
quoted = r.post;
})
.catch((e) => {
quotedErr = String(e);
})
.finally(() => {
quotedLoading = false;
});
}
});
// Resolve the embed shape once at render time. We sniff $type to
// decide which sub-component to mount; an unrecognised $type still
// renders the post body, just without any embed.
const embedKind = $derived.by(() => {
if (!post.embed) return "none";
switch (post.embed.$type) {
case "app.bsky.embed.images": return "images";
case "app.bsky.embed.external": return "external";
case "app.bsky.embed.record": return "record";
case "app.bsky.embed.recordWithMedia": return "recordWithMedia";
default: return "unknown";
}
});
const replyParentHandle = $derived(post.parent_uri ? post.handle : "");
const isReply = $derived(!!post.parent_uri);
const isInThread = $derived(
!!post.parent_uri &&
!!post.root_uri &&
post.parent_uri !== post.root_uri
);
// -- engagement (like / repost) ----------------------------------------
//
// Counts come from the AppView's `GET /api/post/{uri}` response
// (`like_count` / `repost_count`). The home timeline doesn't
// return them yet, so the buttons fall back to 0. A future change
// can pipe the counts into the timeline feed.
//
// The "active" state (did I like this?) is local: we don't have
// `viewer_liked` on the wire. We track it optimistically — the
// flag flips the moment the user clicks, and reverts on a server
// error.
//
// Phase 6b: persist the optimistic state (both `liked` and the
// backend's `likedUri`) through a reload. Without persistence the
// user would re-login and find every like reverted — which reads
// to them as "everything got unliked while you were away". We
// store under a `${did}:${rkey}` key so different posts don't
// stomp each other. The `useLocalStorage` helper is invoked
// inside `$effect.pre` so the key changes track changes to
// `post.did` / `post.rkey`.
let liked: boolean = $state(false);
let likedUri: string | null = $state(null);
let reposts: boolean = $state(false);
let repostUri: string | null = $state(null);
let likeBusy: boolean = $state(false);
let repostBusy: boolean = $state(false);
// Hydrate + persist the like state. The box is bound inside an
// `$effect.pre` so the key updates whenever `post.did` /
// `post.rkey` change (e.g. navigating from one timeline card to
// the next).
let likedBox: ReturnType<typeof useLocalStorage<{ liked: boolean; uri: string | null }>> | null =
$state(null);
$effect.pre(() => {
const k = localStorageKey(`liked:${post.did}:${post.rkey}`);
likedBox = useLocalStorage<{ liked: boolean; uri: string | null }>(k, {
liked: false,
uri: null,
});
const stored = likedBox.get();
liked = stored.liked;
likedUri = stored.uri;
});
$effect(() => {
if (!likedBox) return;
likedBox.set({ liked, uri: likedUri });
});
// Pull the latest like/repost counts whenever the post changes.
// We don't request `thread` data — the simple shape is enough.
// Skip the fetch when the post is null (shouldn't happen for a
// card on screen, but harmless).
$effect(() => {
if (!post.uri) return;
fetchPost(post.uri)
.then((r) => {
// The fetchPost response includes the counts when the post
// is found. We read them off and seed the local counter.
if (r.like_count != null) likeCount = r.like_count;
if (r.repost_count != null) repostCount = r.repost_count;
})
.catch(() => {
// Network or AppView outage — keep whatever we had. The
// user can still click the button; counts will resolve
// next time the post is rehydrated.
});
});
let likeCount: number = $state(0);
let repostCount: number = $state(0);
// Whether the buttons are interactive. We disable them when the
// user isn't logged in — anonymous users can read the timeline
// but not engage.
let authed: boolean = $state(false);
$effect(() => {
const u = $session;
authed = !!u;
});
async function onLikeClick() {
if (!authed || likeBusy) return;
if (!post.uri || !post.cid) return;
likeBusy = true;
// Optimistic flip.
const wasLiked = liked;
const prevCount = likeCount;
liked = !wasLiked;
likeCount = Math.max(0, likeCount + (wasLiked ? -1 : 1));
try {
if (wasLiked) {
if (!likedUri) {
// We have no record of the like URI (e.g. the user
// reloaded the page mid-state). Roll back and tell them.
liked = wasLiked;
likeCount = prevCount;
showError("can't unlike: missing like URI");
return;
}
await unlikePost(likedUri);
likedUri = null;
} else {
const r = await likePost(post.uri, post.cid);
likedUri = r.uri;
}
} catch (e) {
// Roll back on any failure — the user can retry.
liked = wasLiked;
likeCount = prevCount;
showError(`like failed: ${e}`);
} finally {
likeBusy = false;
}
}
async function onRepostClick() {
if (!authed || repostBusy) return;
if (!post.uri || !post.cid) return;
repostBusy = true;
const wasReposted = reposts;
const prevCount = repostCount;
reposts = !wasReposted;
repostCount = Math.max(0, repostCount + (wasReposted ? -1 : 1));
try {
if (wasReposted) {
if (!repostUri) {
reposts = wasReposted;
repostCount = prevCount;
showError("can't unrepost: missing repost URI");
return;
}
await unrepostPost(repostUri);
repostUri = null;
} else {
const r = await repostPost(post.uri, post.cid);
repostUri = r.uri;
}
} catch (e) {
reposts = wasReposted;
repostCount = prevCount;
showError(`repost failed: ${e}`);
} finally {
repostBusy = false;
}
}
function shortCid(c: string) {
return c.length > 12 ? c.slice(0, 6) + "…" + c.slice(-4) : c;
}
function shortHandle(h: string) {
if (!h) return "unknown";
return h.length > 22 ? h.slice(0, 18) + "…" : h;
}
function shortDid(d: string) {
return d.length > 22 ? d.slice(0, 14) + "…" + d.slice(-4) : d;
}
function timeAgo(iso: string) {
try {
const ms = Date.now() - new Date(iso).getTime();
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
} catch {
return iso;
}
}
function handleThreadClick() {
if (post.root_uri && on_thread_click) {
on_thread_click(post.root_uri);
}
}
</script>
<article class="post">
{#if isReply || isInThread}
<div class="thread-ctx">
{#if isInThread}
<button
class="thread-ctx__link"
type="button"
onclick={handleThreadClick}
title={`open thread root: ${post.root_uri}`}
>🧵 thread</button>
<span class="thread-ctx__sep">·</span>
{/if}
{#if isReply && post.parent_uri}
<span class="thread-ctx__reply">
↩ in reply to
<a class="thread-ctx__handle" href={`/profile/${replyParentHandle}`}>
@{shortHandle(replyParentHandle)}
</a>
</span>
{/if}
</div>
{/if}
<header class="post__head">
<span class="prompt">&gt;</span>
<a class="handle" href={`/profile/${post.handle}`}>@{shortHandle(post.handle)}</a>
<span class="time">{timeAgo(post.created_at)}</span>
<span class="cid" title={post.cid}>cid: {shortCid(post.cid)}</span>
<span class="did" title={post.did}>{shortDid(post.did)}</span>
</header>
<p class="post__body">{post.text}</p>
{#if embedKind === "images" && post.embed?.images}
<div class="embed-grid">
{#each post.embed.images as img, i (i)}
<EmbedImage image={img} did={post.did} />
{/each}
</div>
{:else if embedKind === "external" && post.embed?.external}
<EmbedExternal external={post.embed.external} />
{:else if embedKind === "record" || embedKind === "recordWithMedia"}
{#if post.embed?.record}
<blockquote class="quote">
<div class="quote__head">
<span class="quote__label">quoted</span>
<span class="quote__uri">{post.embed.record.uri}</span>
</div>
{#if quotedLoading}
<div class="quote__loading">loading…</div>
{:else if quoted}
<p class="quote__body">{quoted.text}</p>
<div class="quote__meta">
<a class="quote__handle" href={`/profile/${quoted.handle}`}>@{shortHandle(quoted.handle)}</a>
<span class="quote__time">{timeAgo(quoted.created_at)}</span>
</div>
{:else if quotedErr}
<div class="quote__err">couldn't fetch quoted post: {quotedErr}</div>
{/if}
</blockquote>
{/if}
{#if embedKind === "recordWithMedia" && post.embed?.media}
{#if post.embed.media.images}
<div class="embed-grid">
{#each post.embed.media.images as img, i (i)}
<EmbedImage image={img} did={post.did} />
{/each}
</div>
{/if}
{#if post.embed.media.external}
<EmbedExternal external={post.embed.media.external} />
{/if}
{/if}
{/if}
<footer class="post__foot">
<span class="dot">·</span>
<span class="ago">{timeAgo(post.created_at)}</span>
<span class="spacer"></span>
<button
class="action"
class:action--active={liked}
type="button"
onclick={onLikeClick}
disabled={!authed || likeBusy}
title={!authed ? "log in to like" : liked ? "unlike" : "like"}
>
<span class="action__icon">{liked ? "♥" : "♡"}</span>
<span class="action__count">{likeCount}</span>
</button>
<button
class="action"
class:action--active={reposts}
type="button"
onclick={onRepostClick}
disabled={!authed || repostBusy}
title={!authed ? "log in to repost" : reposts ? "unrepost" : "repost"}
>
<span class="action__icon">{reposts ? "⇆" : "↻"}</span>
<span class="action__count">{repostCount}</span>
</button>
</footer>
</article>
<style>
.post {
padding: var(--s-4) var(--s-5);
border-bottom: 1px solid var(--line);
transition: background var(--dur) var(--ease);
}
.post:hover { background: rgba(255, 102, 0, 0.02); }
.thread-ctx {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
margin-bottom: var(--s-2);
display: flex;
gap: var(--s-2);
align-items: center;
}
.thread-ctx__link {
background: transparent;
border: 0;
padding: 0;
color: var(--orange);
cursor: pointer;
font-family: inherit;
font-size: inherit;
}
.thread-ctx__link:hover { text-decoration: underline; }
.thread-ctx__sep { color: var(--line-2); }
.thread-ctx__reply { color: var(--text-dim); }
.thread-ctx__handle { color: var(--text); }
.thread-ctx__handle:hover { color: var(--orange); }
.post__head {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
align-items: center;
margin-bottom: var(--s-2);
}
.prompt { color: var(--orange); }
.handle { color: var(--text); }
.handle:hover { color: var(--orange); }
.time, .cid, .did { color: var(--cid-fg); }
.did { color: var(--text-dim); }
.post__body {
color: var(--text);
font-family: var(--font-sans);
font-size: var(--fs-100);
line-height: 1.5;
margin: 0 0 var(--s-3);
white-space: pre-wrap;
word-wrap: break-word;
}
.post__foot {
display: flex;
align-items: center;
gap: var(--s-4);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.dot { color: var(--line-2); }
.ago { color: var(--text-dim); }
.spacer { flex: 1; }
.action {
display: inline-flex;
align-items: center;
gap: var(--s-1);
background: transparent;
border: 1px solid var(--line-2);
color: var(--text-dim);
font-family: inherit;
font-size: inherit;
padding: 0.15rem 0.45rem;
border-radius: var(--r-sm);
cursor: pointer;
transition: color var(--dur) var(--ease),
border-color var(--dur) var(--ease),
background var(--dur) var(--ease);
font-variant-numeric: tabular-nums;
}
.action:hover:not(:disabled) {
color: var(--orange);
border-color: var(--orange);
}
.action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.action--active {
color: var(--orange);
border-color: var(--orange);
background: rgba(255, 102, 0, 0.06);
}
.action__icon {
font-size: var(--fs-100);
line-height: 1;
}
.embed-grid {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.quote {
margin: var(--s-2) 0 var(--s-3);
padding: var(--s-3);
border-left: 2px solid var(--orange-25);
background: var(--bg-elev);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.quote__head {
display: flex;
gap: var(--s-2);
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
margin-bottom: var(--s-2);
}
.quote__label { color: var(--orange); }
.quote__uri {
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quote__loading, .quote__err {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
font-style: italic;
}
.quote__err { color: var(--red); }
.quote__body {
color: var(--text);
font-family: var(--font-sans);
font-size: var(--fs-100);
line-height: var(--lh-body);
margin: 0 0 var(--s-2);
white-space: pre-wrap;
word-wrap: break-word;
}
.quote__meta {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
display: flex;
gap: var(--s-3);
}
.quote__handle { color: var(--text); }
.quote__handle:hover { color: var(--orange); }
</style>
@@ -0,0 +1,62 @@
<script lang="ts">
let { rows = 3 }: { rows?: number } = $props();
let visibleRows = $derived(Math.max(1, rows));
</script>
<div class="skel" aria-busy="true" aria-live="polite">
{#each Array.from({ length: visibleRows }) as _, i (i)}
<div class="skel__head">
<span class="skel__bar skel__bar--xs"></span>
<span class="skel__bar skel__bar--sm"></span>
<span class="skel__bar skel__bar--md"></span>
</div>
<div class="skel__body">
<span class="skel__bar skel__bar--lg"></span>
<span class="skel__bar skel__bar--lg"></span>
</div>
{/each}
</div>
<style>
.skel {
padding: var(--s-4) var(--s-5);
}
.skel__head,
.skel__body {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
margin-bottom: var(--s-3);
}
.skel__head { align-items: center; margin-bottom: var(--s-2); }
.skel__body {
flex-direction: column;
align-items: stretch;
gap: var(--s-2);
}
.skel__bar {
display: inline-block;
height: 10px;
border-radius: var(--r-sm);
background: linear-gradient(
90deg,
var(--bg-elev) 0%,
var(--line-2) 50%,
var(--bg-elev) 100%
);
background-size: 200% 100%;
animation: shimmer 1.4s ease-in-out infinite;
}
.skel__bar--xs { width: 18px; height: 10px; }
.skel__bar--sm { width: 96px; }
.skel__bar--md { width: 140px; }
.skel__bar--lg { width: 100%; height: 14px; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.skel__bar { animation: none; }
}
</style>
@@ -0,0 +1,89 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
type Mode = "NORMAL" | "INSERT" | "COMPOSE";
type Health = "ok" | "warn" | "err";
let { did = "", authenticated = false }: { did?: string; authenticated?: boolean } = $props();
let mode: Mode = $state("NORMAL");
let pds: Health = $state("ok");
let rev: number = $state(0);
let lagMs: number = $state(1200);
let now: string = $state("");
let timer: number | undefined;
onMount(() => {
const update = () => {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
now = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
update();
timer = window.setInterval(update, 1000);
});
onDestroy(() => {
if (timer) clearInterval(timer);
});
function lagColor(l: number) {
if (l < 3000) return "var(--lag-ok)";
if (l < 8000) return "var(--lag-warn)";
return "var(--red)";
}
function shortDid(d: string) {
if (!d) return "did:plc:not-logged-in";
if (d.length > 24) return d.slice(0, 14) + "…" + d.slice(-4);
return d;
}
</script>
<div class="statusbar" data-mode={mode}>
<div class="cluster">
<span class="mode">MODE:{mode}</span>
<span class="dot dot-{pds}"></span>
<span>PDS:{pds}</span>
<span>auth:<b class:auth-ok={authenticated} class:auth-off={!authenticated}>{authenticated ? "ok" : "off"}</b></span>
<span>rev:<b class="rev">{rev}</b></span>
<span style="color: {lagColor(lagMs)}">lag:{lagMs}ms</span>
<span class="did" title={did}>did:{shortDid(did)}</span>
</div>
<div class="time">{now}</div>
</div>
<style>
.statusbar {
height: 24px;
background: var(--bg-elev);
border-top: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--s-3);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
user-select: none;
}
.cluster {
display: flex;
align-items: center;
gap: var(--s-3);
}
.cluster > * { display: inline-flex; align-items: center; gap: 4px; }
.mode { color: var(--orange); }
.rev { color: var(--rev-fg); }
.auth-ok { color: var(--lag-ok); }
.auth-off { color: var(--red); }
.did { color: var(--cid-fg); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dot { width: 7px; height: 7px; border-radius: var(--r-pill); }
.dot-ok { background: var(--lag-ok); animation: pulse 1.6s ease-in-out infinite; }
.dot-warn { background: var(--lag-warn); }
.dot-err { background: var(--red); }
.time { color: var(--text-dim); }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 var(--lag-ok); }
50% { box-shadow: 0 0 0 5px transparent; }
}
</style>
@@ -0,0 +1,60 @@
<script lang="ts">
let { children, title = "maarcadetweet" }: { children?: any; title?: string } = $props();
</script>
<div class="terminal">
<div class="terminal__bar">
<div class="terminal__dots">
<i></i><i></i><i></i>
</div>
<div class="terminal__title">{title}</div>
<div class="terminal__bar-spacer"></div>
</div>
<div class="terminal__body">
{@render children?.()}
</div>
</div>
<style>
.terminal {
background: var(--bg-deep);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: 0 24px 60px -28px rgba(0, 0, 0, 0.8);
}
.terminal__bar {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: var(--bg-elev);
border-bottom: 1px solid var(--line);
}
.terminal__dots {
display: flex;
gap: 7px;
}
.terminal__dots i {
width: 11px;
height: 11px;
border-radius: var(--r-pill);
background: var(--line-2);
display: block;
}
.terminal__dots i:first-child { background: #4a4a4a; }
.terminal__title {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
margin-inline: auto;
letter-spacing: 0.02em;
}
.terminal__bar-spacer { width: 36px; }
.terminal__body {
font-family: var(--font-mono);
font-size: 0.95rem;
line-height: 1.85;
padding: var(--s-5);
}
</style>
@@ -0,0 +1,67 @@
:root {
--bg: #0D0D0D;
--bg-elev: #1A1A1A;
--bg-deep: #0A0A0A;
--line: #2A2A2A;
--line-2: #3A3A3A;
--text: #E8E8E8;
--text-dim: #888888;
--orange: #FF6600;
--orange-bright: #FF9500;
--orange-3: rgba(255, 102, 0, 0.03);
--orange-8: rgba(255, 102, 0, 0.08);
--orange-25: rgba(255, 102, 0, 0.25);
--orange-glow: rgba(255, 102, 0, 0.28);
--green: #00FF41;
--cyan: #00D4FF;
--red: #FF3B30;
--cid-fg: #B8B8B8;
--rev-fg: #FF9500;
--lag-ok: #00FF41;
--lag-warn: #FFB000;
--font-mono: "IBMPlexMono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--font-sans: "NotoSans", system-ui, -apple-system, sans-serif;
--fs-50: 0.8rem;
--fs-100: 1rem;
--fs-200: 1.25rem;
--fs-300: 1.563rem;
--fs-400: 1.953rem;
--fs-500: 2.441rem;
--fs-600: 3.052rem;
--fs-700: 3.815rem;
--lh-tight: 1.05;
--lh-snug: 1.25;
--lh-body: 1.6;
--tracking-tight: -0.02em;
--tracking-label: 0.08em;
--s-1: 0.25rem;
--s-2: 0.5rem;
--s-3: 0.75rem;
--s-4: 1rem;
--s-5: 1.5rem;
--s-6: 2rem;
--s-7: 3rem;
--s-8: 4rem;
--s-9: 6rem;
--s-10: 8rem;
--maxw: 1140px;
--grid-size: 28px;
--r-sm: 4px;
--r-md: 8px;
--r-lg: 12px;
--r-pill: 999px;
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--dur: 170ms;
}
@@ -0,0 +1,116 @@
// Unit tests for `localstorage.ts`. Run with:
// npx vitest run src/lib/utils/localstorage.test.ts
// or, if vitest isn't installed yet:
// node --test --experimental-strip-types src/lib/utils/localstorage.test.ts
//
// We mock `localStorage` per-test with an in-memory shim so the
// tests are deterministic and don't touch the host's actual
// `localStorage`.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { localStorageKey, useLocalStorage } from "./localstorage";
class MemoryStorage {
private store = new Map<string, string>();
getItem(key: string): string | null {
return this.store.has(key) ? (this.store.get(key) as string) : null;
}
setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
removeItem(key: string): void {
this.store.delete(key);
}
clear(): void {
this.store.clear();
}
key(index: number): string | null {
return Array.from(this.store.keys())[index] ?? null;
}
get length(): number {
return this.store.size;
}
}
describe("useLocalStorage", () => {
let memory: MemoryStorage;
beforeEach(() => {
memory = new MemoryStorage();
vi.stubGlobal("localStorage", memory);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns the initial value when storage is empty", () => {
const box = useLocalStorage<{ count: number }>("k1", { count: 0 });
expect(box.get()).toEqual({ count: 0 });
});
it("reads an existing JSON value from storage on construction", () => {
memory.setItem("k2", JSON.stringify({ count: 7 }));
const box = useLocalStorage<{ count: number }>("k2", { count: 0 });
expect(box.get()).toEqual({ count: 7 });
});
it("falls back to initial on a malformed JSON value", () => {
memory.setItem("k3", "{not json");
const box = useLocalStorage<number>("k3", 99);
expect(box.get()).toBe(99);
});
it("writes JSON-encoded values to storage on set", () => {
const box = useLocalStorage<string[]>("k4", []);
box.set(["a", "b"]);
expect(memory.getItem("k4")).toBe(JSON.stringify(["a", "b"]));
});
it("notifies subscribers when set() is called", () => {
const box = useLocalStorage<number>("k5", 0);
const seen: number[] = [];
const unsub = box.subscribe((v) => seen.push(v));
box.set(1);
box.set(2);
unsub();
box.set(3);
expect(seen).toEqual([1, 2]);
});
it("treats missing localStorage as a no-op (in-memory only)", () => {
vi.stubGlobal("localStorage", undefined);
const box = useLocalStorage<boolean>("k6", false);
expect(box.get()).toBe(false);
box.set(true);
expect(box.get()).toBe(true);
// No throw means success.
});
it("swallows subscriber errors so the rest keep firing", () => {
const box = useLocalStorage<string>("k7", "init");
const seen: string[] = [];
box.subscribe(() => {
throw new Error("boom");
});
box.subscribe((v) => seen.push(v));
box.set("after");
expect(seen).toEqual(["after"]);
});
it("preserves identity for the same key after re-instantiation", () => {
const a = useLocalStorage<{ v: number }>("k8", { v: 1 });
a.set({ v: 42 });
const b = useLocalStorage<{ v: number }>("k8", { v: 1 });
expect(b.get()).toEqual({ v: 42 });
});
});
describe("localStorageKey", () => {
it("prefixes with the project namespace", () => {
expect(localStorageKey("liked:did:rkey")).toBe(
"maarcadetweet:liked:did:rkey",
);
});
});
@@ -0,0 +1,116 @@
/// `localStorage`-backed reactive primitive.
///
/// We use this for tiny UI-only state that doesn't need to round
/// trip the PDS (like "did the current viewer like this post").
///
/// The hook is intentionally minimal:
/// * Reads `localStorage.getItem(key)` once on construction and
/// exposes the value through a `get()` accessor and a Svelte
/// `subscribe` rune via the `subscribe(fn)` form. Components
/// that want fine-grained reactivity can call `get()` inside
/// an `$effect` or `$derived`.
/// * `set(value)` writes to `localStorage` and notifies any
/// subscribers.
/// * On the server (no `window`) everything is a no-op and the
/// value defaults to `initial`.
///
/// We deliberately don't use sessionStorage and don't encrypt — the
/// keys are namespaced with `maarcadetweet:` and the values are
/// URIs/CIDs, not credentials.
export type Listener<T> = (value: T) => void;
export interface LocalStorageBox<T> {
/** Read the current value synchronously. */
get(): T;
/** Write a new value (also persisted if storage is available). */
set(value: T): void;
/** Subscribe to future changes; returns an unsubscribe fn. */
subscribe(fn: Listener<T>): () => void;
}
function safeParse<T>(raw: string | null, fallback: T): T {
if (raw == null) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
function hasStorage(): boolean {
try {
return typeof globalThis !== "undefined"
&& typeof (globalThis as { localStorage?: Storage }).localStorage !== "undefined";
} catch {
return false;
}
}
/**
* `useLocalStorage(key, initial)` — persist a JSON-serialisable value
* in `localStorage` under `key`.
*
* Behaviour:
* * SSR-safe: when `localStorage` is unavailable the box still
* behaves as an in-memory holder (writes are dropped on reload,
* which is correct for SSR).
* * Parse errors fall back to `initial` rather than throwing —
* stale or corrupted entries shouldn't crash the UI.
* * `set(value)` writes synchronously and notifies subscribers
* before returning; subscribers are invoked in subscription
* order, and exceptions are caught so a single bad listener
* doesn't break the rest.
*/
export function useLocalStorage<T>(
key: string,
initial: T,
): LocalStorageBox<T> {
const storage = hasStorage() ? globalThis.localStorage : null;
const listeners = new Set<Listener<T>>();
const stored = storage ? safeParse<T>(storage.getItem(key), initial) : initial;
let current: T = stored;
const notify = (value: T) => {
for (const fn of listeners) {
try {
fn(value);
} catch (e) {
// A subscriber threw; swallow and keep going so the UI
// doesn't end up half-updated.
console.error("useLocalStorage subscriber error", e);
}
}
};
return {
get() {
return current;
},
set(value: T) {
current = value;
if (storage) {
try {
storage.setItem(key, JSON.stringify(value));
} catch (e) {
// Quota exceeded / private mode — fall through to the
// in-memory copy so the rest of the app keeps working
// this session.
console.error("useLocalStorage write failed", e);
}
}
notify(value);
},
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
/** Namespacing helper so component authors don't have to remember
* the project prefix. */
export function localStorageKey(suffix: string): string {
return `maarcadetweet:${suffix}`;
}
+76
View File
@@ -0,0 +1,76 @@
import "./app.css";
import App from "./App.svelte";
import { mount } from "svelte";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
const app = mount(App, { target: document.getElementById("app")! });
// Tray + notification event wiring. These are emitted by the Rust
// side from `setup()` in `lib.rs` — see `TrayIconBuilder::on_menu_event`
// and the `show_notification` Tauri command. We keep the listeners
// here (instead of inside App.svelte) so they live for the full
// lifetime of the page, not the current view.
const unlisteners: UnlistenFn[] = [];
async function wireBackendEvents() {
// "Show maarcadetweet" tray menu item, or a left click on the
// tray icon. We focus the main window — Tauri 2 has no
// `WindowExt::show()` shortcut, so we look it up by label.
unlisteners.push(
await listen("app://show", async () => {
try {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const w = getCurrentWindow();
await w.show();
await w.setFocus();
} catch (e) {
console.warn("tray show failed", e);
}
})
);
// "Compose" tray menu item — open the compose view.
unlisteners.push(
await listen("app://compose", () => {
const ev = new CustomEvent("maarcadetweet:navigate", {
detail: { view: "compose" },
});
window.dispatchEvent(ev);
})
);
// OS notification click — payload includes a `url` the user wants
// us to navigate to (e.g. "at://did/.../..."). For now we just
// show a toast via the same custom event the Svelte side listens
// to; a follow-up can route to the post.
unlisteners.push(
await listen<{ title: string; body: string; url: string | null }>(
"app://notification",
(event) => {
console.log("notification event", event.payload);
const ev = new CustomEvent("maarcadetweet:notification", {
detail: event.payload,
});
window.dispatchEvent(ev);
}
)
);
}
wireBackendEvents().catch((e) => {
// Non-fatal: this fails outside a Tauri WebView (e.g. when the app
// is served by `vite dev` for browser-only preview). Log so we
// notice but don't break the dev experience.
console.warn("tauri backend event wiring skipped:", e);
});
// Cleanup on hot-reload (vite dev). Unlisteners are stored
// module-globally so the previous teardown runs first.
if (import.meta.hot) {
import.meta.hot.dispose(() => {
for (const u of unlisteners) u();
});
}
export default app;
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />