Files
maarcadetweet/crates/tauri-app/src/lib/api/client.ts
T
tomdeboneandClaude Opus 5 31880e1005 feat(tauri-app): Notifications-View mit Badge, Follower-Listen, DID-Navigation
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
2026-09-09 21:37:00 +02:00

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();
}