Bindet die neuen AppView-Endpoints an: sechs IPC-Commands
(fetch_notifications, notification_count, mark_notifications_seen,
fetch_followers, fetch_following, fetch_thread) plus profile_get_by_did,
dazu die TS-Gegenstücke.
* NotificationsView: Liste mit Icon/Text je Art, Avatar, Vorschau des
subject_text, Cursor-Pagination; Klick auf eine Zeile mit Subject öffnet
den Thread. Beim Öffnen wird mit dem indexed_at der obersten Zeile als
Wasserzeichen quittiert.
* NavRail: Eintrag mit Unread-Badge, gepollt im vorhandenen 5s-Timer und
über den bestehenden Teardown-Pfad abgeräumt; der Poll pausiert, solange
die Liste offen ist.
* ProfileView: Follower- und Following-Zahlen sind jetzt Buttons und öffnen
die jeweilige Liste inline.
Navigiert wird über die DID, nicht über den Handle: für Actors, die weder
profiles noch posts kennen, liefert die AppView einen abgeschnittenen
Platzhalter im handle-Feld ("did:plc:abcd…"), und ein Klick darauf landete
in einem synthetischen Leerprofil. Die echte DID steht im DTO und wird jetzt
explizit durchgereicht — keine Heuristik auf das Platzhalter-Format.
Dabei aufgefallen und mitgefixt: ProfileView lud nur in onMount, obwohl die
Komponente bei Profil-zu-Profil-Navigation gemountet bleibt — Posts und
Zahlen des vorherigen Nutzers wären unter dem neuen Namen stehengeblieben.
Jetzt ein auf (did, handle) gekeyter Effekt.
Das Thread-Overlay hing im else-Zweig der leeren Timeline und wäre aus dem
Notifications-View unsichtbar gewesen; es ist jetzt ein Snippet, das beide
Views rendern.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
839 lines
27 KiB
TypeScript
839 lines
27 KiB
TypeScript
import { invoke, isTauri } from "@tauri-apps/api/core";
|
|
import { writable } from "svelte/store";
|
|
|
|
/**
|
|
* Guard the Tauri runtime. When the page is served by `vite dev`
|
|
* for browser-only preview (no Tauri webview), `__TAURI_INTERNALS__`
|
|
* is undefined and the bare `invoke` call throws
|
|
* `TypeError: Cannot read properties of undefined (reading
|
|
* 'invoke')`. That error fired inside `onMount` during
|
|
* `session.load()` and crashed the entire Svelte mount, leaving
|
|
* the user with a white page. We wrap every Tauri call in two
|
|
* helpers:
|
|
* - `tauriCall(cmd, fallback)` for loads — returns the fallback
|
|
* when no Tauri runtime is present (e.g. session.load() returns
|
|
* null).
|
|
* - `safeInvoke(cmd, args)` for actions that MUST hit the runtime
|
|
* (login, register, etc.) — throws a friendly error so the UI
|
|
* can show a "running in browser preview" notice.
|
|
*/
|
|
async function tauriCall<T>(cmd: string, fallback: T, args?: Record<string, unknown>): Promise<T> {
|
|
if (!isTauri()) {
|
|
return fallback;
|
|
}
|
|
return invoke<T>(cmd, args);
|
|
}
|
|
|
|
/// Base URLs the Tauri shell was started with. Exposed via the
|
|
/// `get_api_urls` command so the Svelte components can build
|
|
/// absolute fetch URLs — a relative `/api/...` resolves against
|
|
/// the Vite dev origin (port 1430), not the AppView (port 2584),
|
|
/// and `response.json()` then throws `SyntaxError` on the 404
|
|
/// HTML page. Cached after the first successful call.
|
|
let _apiUrlsCache: { pdsUrl: string; appviewUrl: string } | null = null;
|
|
|
|
export type ApiUrls = { pdsUrl: string; appviewUrl: string };
|
|
|
|
/// Fetch the AppView + PDS base URLs from the Rust shell. Returns
|
|
/// the cached value on subsequent calls.
|
|
export async function getApiUrls(): Promise<ApiUrls> {
|
|
if (_apiUrlsCache) return _apiUrlsCache;
|
|
const urls = await safeInvoke<ApiUrls>("get_api_urls");
|
|
_apiUrlsCache = urls;
|
|
return urls;
|
|
}
|
|
|
|
/// Convenience: just the AppView base URL (the only one the UI
|
|
/// currently needs for direct fetch calls). Same caching as
|
|
/// `getApiUrls`.
|
|
export async function getAppviewUrl(): Promise<string> {
|
|
const { appviewUrl } = await getApiUrls();
|
|
return appviewUrl;
|
|
}
|
|
|
|
/**
|
|
* Strict variant of `tauriCall` for actions that MUST hit the
|
|
* Tauri runtime (login, register, logout, post, like, etc.). In
|
|
* the browser preview this throws a friendly Error so the UI can
|
|
* show a "running in browser preview" notice. In the Tauri
|
|
* webview it falls through to a normal `invoke` call.
|
|
*
|
|
* **Auto-refresh on 401**: the access JWT expires after 1 hour. When
|
|
* the PDS rejects our token with `TokenInvalid` (the rusty
|
|
* `routes::auth` handlers return `{"error":"TokenInvalid",...}`),
|
|
* we ask the Rust shell for a fresh access JWT via the
|
|
* `auth_refresh` Tauri command. The Rust side reads the stored
|
|
* refresh JWT (valid for 90 days) and rotates both. We retry
|
|
* exactly once on the same `cmd` + `args`. The `auth_*` commands
|
|
* themselves are skipped so a failing login doesn't trigger an
|
|
* infinite refresh loop.
|
|
*/
|
|
async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
|
if (!isTauri()) {
|
|
throw new Error(`Tauri command ${cmd} requires the desktop runtime`);
|
|
}
|
|
try {
|
|
return await invoke<T>(cmd, args);
|
|
} catch (e: unknown) {
|
|
if (!isTokenInvalid(e) || cmd.startsWith("auth_")) throw e;
|
|
const fresh = await session.refresh();
|
|
if (!fresh) throw e;
|
|
return await invoke<T>(cmd, args);
|
|
}
|
|
}
|
|
|
|
/// Sniff out a `TokenInvalid` response from the Rust error string.
|
|
/// Returns true when the error message looks like an expired/
|
|
/// invalid JWT (the PDS uses a stable `"TokenInvalid"` code in its
|
|
/// JSON error body, which `@tauri-apps/api/core` surfaces verbatim).
|
|
function isTokenInvalid(e: unknown): boolean {
|
|
if (typeof e !== "object" || e === null) return false;
|
|
const msg = (e as { message?: string }).message ?? String(e);
|
|
if (!msg) return false;
|
|
return msg.includes("TokenInvalid") || msg.includes("ExpiredSignature");
|
|
}
|
|
|
|
export type Session = {
|
|
did: string;
|
|
handle: string;
|
|
access_jwt: string;
|
|
refresh_jwt: string;
|
|
};
|
|
|
|
function createSessionStore() {
|
|
const { subscribe, set } = writable<Session | null>(null);
|
|
// Coalesce concurrent refresh requests into one — every safeInvoke
|
|
// call that hits a 401 would otherwise race to call auth_refresh in
|
|
// parallel. The pending promise is reset to `null` exactly once in
|
|
// the finally block; subsequent callers await the same one.
|
|
let pendingRefresh: Promise<Session | null> | null = null;
|
|
|
|
return {
|
|
subscribe,
|
|
/// Mint a fresh access JWT from the stored refresh JWT. Called
|
|
/// automatically by [`safeInvoke`] on `TokenInvalid` responses.
|
|
/// Returns the new session, or `null` if the refresh itself failed
|
|
/// (e.g. refresh JWT expired; at that point the user has to log
|
|
/// in again).
|
|
async refresh(): Promise<Session | null> {
|
|
if (pendingRefresh) return pendingRefresh;
|
|
pendingRefresh = (async () => {
|
|
try {
|
|
const s = await invoke<Session>("auth_refresh");
|
|
set(s);
|
|
return s;
|
|
} catch (e) {
|
|
console.warn("session refresh failed", e);
|
|
return null;
|
|
} finally {
|
|
pendingRefresh = null;
|
|
}
|
|
})();
|
|
return pendingRefresh;
|
|
},
|
|
async load() {
|
|
const s = await tauriCall<Session | null>("current_session", null);
|
|
set(s);
|
|
},
|
|
async login(handle: string, password: string) {
|
|
if (!isTauri()) {
|
|
throw new Error("login requires the Tauri desktop runtime");
|
|
}
|
|
const s = await safeInvoke<Session>("auth_login", { identifier: handle, password });
|
|
set(s);
|
|
return s;
|
|
},
|
|
async register(handle: string, password: string) {
|
|
if (!isTauri()) {
|
|
throw new Error("register requires the Tauri desktop runtime");
|
|
}
|
|
const s = await safeInvoke<Session>("auth_register", { handle, password });
|
|
set(s);
|
|
return s;
|
|
},
|
|
async logout() {
|
|
try {
|
|
await safeInvoke("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;
|
|
like_count?: number;
|
|
repost_count?: number;
|
|
/// Resolved author-avatar CID from the AppView's `profiles`
|
|
/// cache. NULL when the user has no profile record yet.
|
|
avatar_cid?: string | null;
|
|
};
|
|
|
|
export type TimelineResponse = {
|
|
posts: Post[];
|
|
cursor: string | null;
|
|
};
|
|
|
|
export type ProfileResponse = {
|
|
did: string;
|
|
handle: string;
|
|
posts: Post[];
|
|
followers: number;
|
|
following: number;
|
|
display_name?: string | null;
|
|
description?: string | null;
|
|
avatar_cid?: string | null;
|
|
banner_cid?: string | null;
|
|
post_count: 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;
|
|
};
|
|
|
|
/// Reply block for `app.bsky.feed.post#reply`. Both `root` and
|
|
/// `parent` are `com.atproto.repo.strongRef`s (uri + cid). For a
|
|
/// top-level reply to a single post, `root` and `parent` point at
|
|
/// the same strongRef. The Rust `post_create` command wires this
|
|
/// onto the record's `reply` field.
|
|
export type ReplyRef = {
|
|
root: { uri: string; cid: string };
|
|
parent: { uri: string; cid: string };
|
|
};
|
|
|
|
export async function createPost(
|
|
text: string,
|
|
embed?: unknown | null,
|
|
reply?: ReplyRef | null,
|
|
): 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.
|
|
// `embed` is forwarded verbatim; the caller is responsible for
|
|
// shaping it as an `app.bsky.embed.images` / `.external` / etc.
|
|
// record. Pass `null` or `undefined` to omit.
|
|
// `reply` is the reply block (root + parent strongRefs); `null` or
|
|
// `undefined` means "top-level post" (no reply block on the record).
|
|
return await safeInvoke<any>("post_create", {
|
|
text,
|
|
embed: embed ?? null,
|
|
reply: reply ?? null,
|
|
});
|
|
}
|
|
|
|
/// `com.atproto.uploadBlob` wrapped with a file picker. Opens a
|
|
/// native OS dialog (via `tauri-plugin-dialog`), uploads the chosen
|
|
/// file to the user's PDS, and returns the parsed blob reference.
|
|
///
|
|
/// Returns `null` when the user cancels the picker. The Tauri shell
|
|
/// enforces a 1 MiB cap (matching the PDS's `MAX_BLOB_SIZE`) and
|
|
/// surfaces other errors via the rejected promise.
|
|
export async function pickAndUploadImage(): Promise<{
|
|
cid: string;
|
|
mimeType: string;
|
|
size: number;
|
|
} | null> {
|
|
const r = await safeInvoke<{
|
|
blob: {
|
|
$type: string;
|
|
ref: { $link: string };
|
|
mimeType: string;
|
|
size: number;
|
|
};
|
|
} | null>("pick_and_upload_image");
|
|
if (!r) return null;
|
|
return {
|
|
cid: r.blob.ref.$link,
|
|
mimeType: r.blob.mimeType,
|
|
size: r.blob.size,
|
|
};
|
|
}
|
|
|
|
/// Helper that builds the `app.bsky.embed.images` embed for a single
|
|
/// image blob, ready to drop into a post record.
|
|
export function makeImagesEmbed(blob: {
|
|
cid: string;
|
|
mimeType: string;
|
|
size: number;
|
|
}): Record<string, unknown> {
|
|
return {
|
|
$type: "app.bsky.embed.images",
|
|
images: [
|
|
{
|
|
alt: "",
|
|
image: {
|
|
$type: "blob",
|
|
ref: { $link: blob.cid },
|
|
mimeType: blob.mimeType,
|
|
size: blob.size,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
export async function describeServer(): Promise<any> {
|
|
return await safeInvoke("pds_describe");
|
|
}
|
|
|
|
export async function pdsStatus(): Promise<any> {
|
|
return await safeInvoke("status_pds");
|
|
}
|
|
|
|
export async function fetchTimeline(
|
|
did: string,
|
|
cursor: string | null = null,
|
|
limit: number = 30,
|
|
): Promise<TimelineResponse> {
|
|
return await safeInvoke<TimelineResponse>("timeline_home", {
|
|
did,
|
|
cursor,
|
|
limit,
|
|
});
|
|
}
|
|
|
|
export async function fetchProfile(handle: string): Promise<ProfileResponse> {
|
|
return await safeInvoke<ProfileResponse>("profile_get", { handle });
|
|
}
|
|
|
|
/// Resolve a profile by DID (`GET /api/profile?did=…`) instead of by
|
|
/// handle.
|
|
///
|
|
/// Use this — not [`fetchProfile`] — whenever the actor came out of a
|
|
/// notification or a follower/following list. The AppView synthesises
|
|
/// a placeholder `handle` for actors it has neither a profile row nor
|
|
/// an indexed post for: a truncated DID with an ellipsis, e.g.
|
|
/// `"did:plc:f5…"`. That string matches nothing on the way back in, and
|
|
/// the server answers with a synthetic empty profile (`did: ""`, zero
|
|
/// posts) rather than an error — so a handle-based navigation from
|
|
/// those lists is a silent dead end. The DID in the DTO is always
|
|
/// real, so navigate by that.
|
|
export async function fetchProfileByDid(
|
|
did: string,
|
|
): Promise<ProfileResponse> {
|
|
return await safeInvoke<ProfileResponse>("profile_get_by_did", { did });
|
|
}
|
|
|
|
export async function fetchSearch(
|
|
q: string,
|
|
limit: number = 30,
|
|
): Promise<SearchResponse> {
|
|
return await safeInvoke<SearchResponse>("search", { q, limit });
|
|
}
|
|
|
|
export async function fetchPost(uri: string): Promise<ThreadResponse> {
|
|
return await safeInvoke<ThreadResponse>("post_get", { uri });
|
|
}
|
|
|
|
/// `GET /api/thread?uri=…` — the *full* thread around a post: the
|
|
/// entire ancestor chain plus the direct replies, in one round trip.
|
|
///
|
|
/// `parents` is ordered root-first (so `parents[parents.length - 1]`
|
|
/// is the immediate parent) and `replies` oldest-first. A `parents[0]`
|
|
/// whose own `parent_uri` is non-null means "the chain continues
|
|
/// above but wasn't loaded" — the walk is depth-limited server-side.
|
|
///
|
|
/// `post` is `null` when the URI isn't in the index; the counters are
|
|
/// then absent too, so a single field check renders "post not found".
|
|
/// `viewer_liked` / `viewer_reposted` are `undefined` when no
|
|
/// `viewerDid` was passed — that means "unknown", not "false".
|
|
export type ThreadFullResponse = {
|
|
post: Post | null;
|
|
parents: Post[];
|
|
root: Post | null;
|
|
replies: Post[];
|
|
like_count?: number;
|
|
repost_count?: number;
|
|
viewer_liked?: boolean;
|
|
viewer_reposted?: boolean;
|
|
};
|
|
|
|
export async function fetchThread(
|
|
uri: string,
|
|
viewerDid: string | null = null,
|
|
): Promise<ThreadFullResponse> {
|
|
return await safeInvoke<ThreadFullResponse>("fetch_thread", {
|
|
uri,
|
|
viewerDid,
|
|
});
|
|
}
|
|
|
|
/// One row of `GET /api/notifications`, hydrated server-side with the
|
|
/// author's profile and the subject post's text so the list renders
|
|
/// without any follow-up fetch.
|
|
///
|
|
/// `kind` is typed as the four known values plus `string` so an
|
|
/// unknown kind added upstream still type-checks here; the UI's
|
|
/// `notificationText` falls back to a generic line.
|
|
export type NotificationKind = "like" | "repost" | "follow" | "reply";
|
|
|
|
export type Notification = {
|
|
id: number;
|
|
kind: NotificationKind | string;
|
|
author_did: string;
|
|
/// Without a leading `@` (the UI renders `@{handle}`). Never null —
|
|
/// the AppView falls back to a truncated DID.
|
|
author_handle: string;
|
|
author_display_name?: string | null;
|
|
author_avatar_cid?: string | null;
|
|
/// The post this is about. `null` for `"follow"`. For
|
|
/// `"like"`/`"repost"` it's the recipient's own post; for `"reply"`
|
|
/// it's the reply itself.
|
|
subject_uri: string | null;
|
|
subject_text?: string | null;
|
|
created_at: string;
|
|
/// When the AppView indexed it — the list's sort key and the value
|
|
/// to echo back as `seenAt`.
|
|
indexed_at: string;
|
|
/// `null` while unread.
|
|
read_at: string | null;
|
|
};
|
|
|
|
export type NotificationsResponse = {
|
|
notifications: Notification[];
|
|
cursor: string | null;
|
|
};
|
|
|
|
/// German UI copy for a notification row. Kept next to the type (and
|
|
/// exported) so the mapping is unit-testable without mounting a
|
|
/// component. An unrecognised `kind` gets a neutral fallback rather
|
|
/// than an empty line.
|
|
export function notificationText(kind: string): string {
|
|
switch (kind) {
|
|
case "like":
|
|
return "hat deinen Post geliked";
|
|
case "repost":
|
|
return "hat repostet";
|
|
case "follow":
|
|
return "folgt dir jetzt";
|
|
case "reply":
|
|
return "hat geantwortet";
|
|
default:
|
|
return "hat interagiert";
|
|
}
|
|
}
|
|
|
|
/// Single-glyph icon for a notification row, same switch as
|
|
/// [`notificationText`]. Monospace glyphs rather than SVGs so the
|
|
/// list keeps the terminal look of the rest of the UI.
|
|
export function notificationIcon(kind: string): string {
|
|
switch (kind) {
|
|
case "like":
|
|
return "♥";
|
|
case "repost":
|
|
return "⇄";
|
|
case "follow":
|
|
return "+";
|
|
case "reply":
|
|
return "↩";
|
|
default:
|
|
return "•";
|
|
}
|
|
}
|
|
|
|
/// One page of notifications, newest first. Same opaque-cursor
|
|
/// contract as [`fetchTimeline`]: pass the previous response's
|
|
/// `cursor` to page down, and `cursor === null` means end of list.
|
|
export async function fetchNotifications(
|
|
did: string,
|
|
cursor: string | null = null,
|
|
limit: number = 30,
|
|
): Promise<NotificationsResponse> {
|
|
return await safeInvoke<NotificationsResponse>("fetch_notifications", {
|
|
did,
|
|
cursor,
|
|
limit,
|
|
});
|
|
}
|
|
|
|
/// Unread-notification count for the NavRail badge. Cheap enough to
|
|
/// poll on the same cadence as the timeline refresh.
|
|
export async function notificationCount(did: string): Promise<number> {
|
|
const r = await safeInvoke<{ count: number }>("notification_count", { did });
|
|
return r.count;
|
|
}
|
|
|
|
/// Mark every notification indexed at or before `seenAt` as read.
|
|
/// Pass the `indexed_at` of the topmost row the user actually sees, so
|
|
/// a notification arriving mid-scroll isn't swallowed. `null` marks
|
|
/// everything currently unread. Idempotent — a repeat call reports
|
|
/// `updated: 0`.
|
|
export async function markNotificationsSeen(
|
|
did: string,
|
|
seenAt: string | null = null,
|
|
): Promise<{ ok: boolean; updated: number }> {
|
|
return await safeInvoke<{ ok: boolean; updated: number }>(
|
|
"mark_notifications_seen",
|
|
{ did, seenAt },
|
|
);
|
|
}
|
|
|
|
/// A minimal profile card from `GET /api/followers` / `/api/following`.
|
|
/// Deliberately not a full `ProfileResponse` — a page of 30 followers
|
|
/// would otherwise be 30 post queries server-side. Click a row and the
|
|
/// UI navigates to the full profile by `handle`.
|
|
export type ActorProfile = {
|
|
did: string;
|
|
handle: string;
|
|
display_name?: string | null;
|
|
avatar_cid?: string | null;
|
|
};
|
|
|
|
export type ActorListResponse = {
|
|
profiles: ActorProfile[];
|
|
cursor: string | null;
|
|
};
|
|
|
|
export async function fetchFollowers(
|
|
did: string,
|
|
cursor: string | null = null,
|
|
limit: number = 30,
|
|
): Promise<ActorListResponse> {
|
|
return await safeInvoke<ActorListResponse>("fetch_followers", {
|
|
did,
|
|
cursor,
|
|
limit,
|
|
});
|
|
}
|
|
|
|
export async function fetchFollowing(
|
|
did: string,
|
|
cursor: string | null = null,
|
|
limit: number = 30,
|
|
): Promise<ActorListResponse> {
|
|
return await safeInvoke<ActorListResponse>("fetch_following", {
|
|
did,
|
|
cursor,
|
|
limit,
|
|
});
|
|
}
|
|
|
|
/// `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 safeInvoke<RepoWriteResult>("like_post", {
|
|
subjectUri,
|
|
subjectCid,
|
|
});
|
|
}
|
|
|
|
export async function unlikePost(likeUri: string): Promise<DeleteRecordResult> {
|
|
return await safeInvoke<DeleteRecordResult>("unlike_post", { likeUri });
|
|
}
|
|
|
|
export async function repostPost(
|
|
subjectUri: string,
|
|
subjectCid: string,
|
|
): Promise<RepoWriteResult> {
|
|
return await safeInvoke<RepoWriteResult>("repost_post", {
|
|
subjectUri,
|
|
subjectCid,
|
|
});
|
|
}
|
|
|
|
export async function unrepostPost(
|
|
repostUri: string,
|
|
): Promise<DeleteRecordResult> {
|
|
return await safeInvoke<DeleteRecordResult>("unrepost_post", { repostUri });
|
|
}
|
|
|
|
/// `followUser(targetDid)` — create an `app.bsky.graph.follow` record
|
|
/// on the user's PDS. Returns `{ uri, cid }` — the client caches
|
|
/// `uri` in localStorage so `unfollowUser(uri)` can delete the
|
|
/// record without needing a "list my follows" round-trip.
|
|
export async function followUser(
|
|
targetDid: string,
|
|
): Promise<RepoWriteResult> {
|
|
return await safeInvoke<RepoWriteResult>("follow_user", { targetDid });
|
|
}
|
|
|
|
export async function unfollowUser(
|
|
followUri: string,
|
|
): Promise<DeleteRecordResult> {
|
|
return await safeInvoke<DeleteRecordResult>("unfollow_user", {
|
|
followUri,
|
|
});
|
|
}
|
|
|
|
/// Fire-and-forget user-visible toast. Implemented as a `window`
|
|
/// `CustomEvent` so any component can show toasts 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 } }),
|
|
);
|
|
}
|
|
|
|
export function showInfo(text: string): void {
|
|
if (typeof window === "undefined") return;
|
|
window.dispatchEvent(
|
|
new CustomEvent("maarcadetweet:toast", { detail: { kind: "info", 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 safeInvoke("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" | "home" | "compose" | "profile" | "search" | "settings") => 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://navigate", (e) => {
|
|
handler(e.payload as "home" | "profile" | "search" | "settings");
|
|
});
|
|
const u3 = await listen("app://compose", () => handler("compose"));
|
|
unlisteners.push(u1, u2, u3);
|
|
return () => {
|
|
for (const u of unlisteners) u();
|
|
};
|
|
}
|
|
|
|
/// Open an external URL in the user's default browser. The Rust
|
|
/// `open_external_url` command enforces http(s) only; in the
|
|
/// browser preview where no Tauri runtime is present, fall back
|
|
/// to `window.open` and treat a popup-blocker denial as
|
|
/// "fine, user can copy the URL themselves".
|
|
export type ProfileRecord = {
|
|
displayName?: string;
|
|
description?: string;
|
|
avatar?: { ref: { $link: string }; mimeType?: string; size?: number };
|
|
banner?: { ref: { $link: string }; mimeType?: string; size?: number };
|
|
};
|
|
|
|
/// Read the authenticated user's `app.bsky.actor.profile` record.
|
|
/// Returns `null` if no profile record exists yet (a brand-new
|
|
/// account, or a user whose PDS hasn't pushed one).
|
|
export async function getMyProfile(): Promise<ProfileRecord | null> {
|
|
return await safeInvoke<ProfileRecord | null>("profile_get_record");
|
|
}
|
|
|
|
/// Read-modify-write the authenticated user's profile. The Rust
|
|
/// `profile_set` command fetches the existing record, overlays
|
|
/// the supplied fields, and writes a new commit. `undefined` fields
|
|
/// are preserved.
|
|
export async function setMyProfile(fields: {
|
|
displayName?: string;
|
|
description?: string;
|
|
avatarBlobCid?: string;
|
|
bannerBlobCid?: string;
|
|
}): Promise<ProfileRecord | null> {
|
|
return await safeInvoke<ProfileRecord | null>("profile_set", fields);
|
|
}
|
|
|
|
export async function openExternalUrl(url: string): Promise<void> {
|
|
try {
|
|
if (isTauri()) {
|
|
await safeInvoke("open_external_url", { url });
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.warn("open_external_url failed", e);
|
|
}
|
|
// Browser fallback (vite dev preview).
|
|
try {
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
} catch (e) {
|
|
console.warn("window.open failed (popup blocker?)", e);
|
|
}
|
|
}
|
|
|
|
/// 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 `(did, cid)`, NOT just `cid`:
|
|
// * the blob *bytes* are addressed by content hash, so the same
|
|
// CID *usually* resolves to the same bytes regardless of DID;
|
|
// * BUT in the atproto sync spec, `com.atproto.sync.getBlob` is
|
|
// intentionally unauthenticated and keyed by `(did, cid)` on
|
|
// the server. A future change to a per-DID access control
|
|
// model would make the bytes differ per DID — caching by CID
|
|
// alone would then leak the first responder's bytes to every
|
|
// subsequent viewer.
|
|
// * 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>();
|
|
const _blobKey = (did: string, cid: string) => `${did}/${cid}`;
|
|
|
|
/// 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. Keyed by `(did, cid)` for the security reason above.
|
|
export async function fetchBlob(
|
|
did: string,
|
|
cid: string,
|
|
): Promise<string> {
|
|
const key = _blobKey(did, cid);
|
|
const cached = _blobUrlCache.get(key);
|
|
if (cached) return cached;
|
|
const bytes: number[] = await safeInvoke<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(key, 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(did: string, cid: string): void {
|
|
const url = _blobUrlCache.get(_blobKey(did, cid));
|
|
if (url) {
|
|
URL.revokeObjectURL(url);
|
|
_blobUrlCache.delete(_blobKey(did, cid));
|
|
}
|
|
}
|
|
|
|
/// Test/internal: clear the in-memory blob cache.
|
|
export function clearBlobCache(): void {
|
|
for (const url of _blobUrlCache.values()) {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
_blobUrlCache.clear();
|
|
}
|