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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user