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: Eifelduds <noreply@eifelduds.de>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
// Unit tests for the notification / actor-list half of `client.ts`.
|
||||
//
|
||||
// Same setup as `client.test.ts`: `@tauri-apps/api/core` is mocked so
|
||||
// no Tauri shell is needed, and every assertion is about the exact
|
||||
// command name + argument bag we hand the Rust IPC layer. That
|
||||
// argument bag is the contract — Tauri's `invoke` serialises camelCase
|
||||
// JS keys to the snake_case Rust command parameters, so a typo here
|
||||
// surfaces at runtime as "command not found" or a null argument, not
|
||||
// at compile time.
|
||||
//
|
||||
// Covered:
|
||||
// * `fetchNotifications` — command name, args, wire→object mapping
|
||||
// (snake_case fields pass through verbatim), cursor pagination;
|
||||
// * `notificationCount` — unwraps `{ count }` to a number;
|
||||
// * `markNotificationsSeen` — sends the `seenAt` watermark;
|
||||
// * `notificationText` / `notificationIcon` — the kind→copy map,
|
||||
// including the fallback for an unknown kind;
|
||||
// * `fetchFollowers` / `fetchFollowing` — distinct commands, same
|
||||
// shape.
|
||||
//
|
||||
// Run with:
|
||||
// npx vitest run src/lib/api/notifications.test.ts
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
isTauri: () => true,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
});
|
||||
|
||||
/// A full-fat wire row, exactly as `NotificationItem` serialises it
|
||||
/// server-side (snake_case, `read_at` present even when null).
|
||||
function wireNotification(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
kind: "like",
|
||||
author_did: "did:plc:alice",
|
||||
author_handle: "alice.test",
|
||||
author_display_name: "Alice",
|
||||
author_avatar_cid: "bafyavatar",
|
||||
subject_uri: "at://did:plc:me/app.twi.post/3k2",
|
||||
subject_text: "hello world",
|
||||
created_at: "2026-09-09T10:00:00Z",
|
||||
indexed_at: "2026-09-09T10:00:01Z",
|
||||
read_at: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("fetchNotifications", () => {
|
||||
it("invokes fetch_notifications and maps the wire rows verbatim", async () => {
|
||||
const { fetchNotifications } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({
|
||||
notifications: [wireNotification()],
|
||||
cursor: "cur1",
|
||||
});
|
||||
|
||||
const r = await fetchNotifications("did:plc:me");
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
|
||||
did: "did:plc:me",
|
||||
cursor: null,
|
||||
limit: 30,
|
||||
});
|
||||
expect(r.cursor).toBe("cur1");
|
||||
expect(r.notifications).toHaveLength(1);
|
||||
const n = r.notifications[0];
|
||||
expect(n.id).toBe(1);
|
||||
expect(n.kind).toBe("like");
|
||||
expect(n.author_handle).toBe("alice.test");
|
||||
expect(n.author_display_name).toBe("Alice");
|
||||
expect(n.author_avatar_cid).toBe("bafyavatar");
|
||||
expect(n.subject_uri).toBe("at://did:plc:me/app.twi.post/3k2");
|
||||
expect(n.subject_text).toBe("hello world");
|
||||
expect(n.indexed_at).toBe("2026-09-09T10:00:01Z");
|
||||
// `read_at: null` is the unread marker the badge keys off — it
|
||||
// must survive as null, not become undefined.
|
||||
expect(n.read_at).toBeNull();
|
||||
});
|
||||
|
||||
it("forwards the cursor and limit for a follow-up page", async () => {
|
||||
const { fetchNotifications } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({ notifications: [], cursor: null });
|
||||
|
||||
const r = await fetchNotifications("did:plc:me", "cur1", 50);
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
|
||||
did: "did:plc:me",
|
||||
cursor: "cur1",
|
||||
limit: 50,
|
||||
});
|
||||
// A null cursor is the documented end-of-list sentinel.
|
||||
expect(r.cursor).toBeNull();
|
||||
});
|
||||
|
||||
it("handles a follow row (no subject) without inventing fields", async () => {
|
||||
const { fetchNotifications } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({
|
||||
notifications: [
|
||||
{
|
||||
id: 7,
|
||||
kind: "follow",
|
||||
author_did: "did:plc:bob",
|
||||
author_handle: "bob.test",
|
||||
subject_uri: null,
|
||||
created_at: "2026-09-09T09:00:00Z",
|
||||
indexed_at: "2026-09-09T09:00:01Z",
|
||||
read_at: "2026-09-09T09:30:00Z",
|
||||
},
|
||||
],
|
||||
cursor: null,
|
||||
});
|
||||
|
||||
const [n] = (await fetchNotifications("did:plc:me")).notifications;
|
||||
expect(n.kind).toBe("follow");
|
||||
expect(n.subject_uri).toBeNull();
|
||||
expect(n.subject_text).toBeUndefined();
|
||||
expect(n.author_display_name).toBeUndefined();
|
||||
expect(n.read_at).toBe("2026-09-09T09:30:00Z");
|
||||
});
|
||||
|
||||
it("propagates a Tauri-side error verbatim", async () => {
|
||||
const { fetchNotifications } = await import("./client");
|
||||
invokeMock.mockRejectedValueOnce(
|
||||
new Error("appview: notifications returned 400: did is required"),
|
||||
);
|
||||
await expect(fetchNotifications("")).rejects.toThrow(
|
||||
/notifications returned 400/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notificationCount", () => {
|
||||
it("unwraps the {count} envelope to a plain number", async () => {
|
||||
const { notificationCount } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({ count: 12 });
|
||||
|
||||
const n = await notificationCount("did:plc:me");
|
||||
|
||||
expect(n).toBe(12);
|
||||
expect(invokeMock).toHaveBeenCalledWith("notification_count", {
|
||||
did: "did:plc:me",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 0 for a fully-read inbox", async () => {
|
||||
const { notificationCount } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({ count: 0 });
|
||||
await expect(notificationCount("did:plc:me")).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markNotificationsSeen", () => {
|
||||
it("sends the seenAt watermark", async () => {
|
||||
const { markNotificationsSeen } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({ ok: true, updated: 4 });
|
||||
|
||||
const r = await markNotificationsSeen(
|
||||
"did:plc:me",
|
||||
"2026-09-09T10:00:01Z",
|
||||
);
|
||||
|
||||
expect(r).toEqual({ ok: true, updated: 4 });
|
||||
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
|
||||
did: "did:plc:me",
|
||||
seenAt: "2026-09-09T10:00:01Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("omitting the watermark sends null (mark everything read)", async () => {
|
||||
const { markNotificationsSeen } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({ ok: true, updated: 0 });
|
||||
|
||||
await markNotificationsSeen("did:plc:me");
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
|
||||
did: "did:plc:me",
|
||||
seenAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("notificationText / notificationIcon", () => {
|
||||
it("maps each kind to its German line", async () => {
|
||||
const { notificationText } = await import("./client");
|
||||
expect(notificationText("like")).toBe("hat deinen Post geliked");
|
||||
expect(notificationText("repost")).toBe("hat repostet");
|
||||
expect(notificationText("follow")).toBe("folgt dir jetzt");
|
||||
expect(notificationText("reply")).toBe("hat geantwortet");
|
||||
});
|
||||
|
||||
it("falls back rather than rendering an empty line for an unknown kind", async () => {
|
||||
const { notificationText } = await import("./client");
|
||||
expect(notificationText("quote")).toBe("hat interagiert");
|
||||
expect(notificationText("")).toBe("hat interagiert");
|
||||
});
|
||||
|
||||
it("gives every known kind its own icon glyph", async () => {
|
||||
const { notificationIcon } = await import("./client");
|
||||
const icons = ["like", "repost", "follow", "reply"].map(notificationIcon);
|
||||
expect(new Set(icons).size).toBe(4);
|
||||
expect(icons.every((i) => i.length > 0)).toBe(true);
|
||||
expect(notificationIcon("quote")).toBe("•");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchFollowers / fetchFollowing", () => {
|
||||
const wireProfile = {
|
||||
did: "did:plc:carol",
|
||||
handle: "carol.test",
|
||||
display_name: "Carol",
|
||||
avatar_cid: "bafycarol",
|
||||
};
|
||||
|
||||
it("fetchFollowers hits the followers command", async () => {
|
||||
const { fetchFollowers } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({
|
||||
profiles: [wireProfile],
|
||||
cursor: null,
|
||||
});
|
||||
|
||||
const r = await fetchFollowers("did:plc:me");
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("fetch_followers", {
|
||||
did: "did:plc:me",
|
||||
cursor: null,
|
||||
limit: 30,
|
||||
});
|
||||
expect(r.profiles[0].handle).toBe("carol.test");
|
||||
expect(r.profiles[0].display_name).toBe("Carol");
|
||||
expect(r.cursor).toBeNull();
|
||||
});
|
||||
|
||||
it("fetchFollowing hits the following command and pages", async () => {
|
||||
const { fetchFollowing } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({
|
||||
profiles: [wireProfile],
|
||||
cursor: "cur2",
|
||||
});
|
||||
|
||||
const r = await fetchFollowing("did:plc:me", "cur1", 10);
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("fetch_following", {
|
||||
did: "did:plc:me",
|
||||
cursor: "cur1",
|
||||
limit: 10,
|
||||
});
|
||||
expect(r.cursor).toBe("cur2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchProfileByDid", () => {
|
||||
it("invokes profile_get_by_did with the DID", async () => {
|
||||
const { fetchProfileByDid } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({
|
||||
did: "did:plc:f5abcdefghijklmnop",
|
||||
handle: "alice.test",
|
||||
posts: [],
|
||||
followers: 2,
|
||||
following: 3,
|
||||
post_count: 0,
|
||||
});
|
||||
|
||||
const p = await fetchProfileByDid("did:plc:f5abcdefghijklmnop");
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("profile_get_by_did", {
|
||||
did: "did:plc:f5abcdefghijklmnop",
|
||||
});
|
||||
expect(p.did).toBe("did:plc:f5abcdefghijklmnop");
|
||||
expect(p.followers).toBe(2);
|
||||
});
|
||||
|
||||
it("resolves an actor whose only known handle is a truncated-DID placeholder", async () => {
|
||||
// The dead end this endpoint exists to avoid: the AppView hands
|
||||
// us `handle: "did:plc:f5a…"` (from `short_did_bare`) for an actor
|
||||
// it has no profile row or indexed post for. Feeding that back
|
||||
// into the *handle* lookup matches nothing and the server answers
|
||||
// 200 with a synthetic empty profile — `did: ""`, zero counts —
|
||||
// so the UI shows a blank page instead of an error. The DID
|
||||
// lookup resolves the real row.
|
||||
const { fetchProfile, fetchProfileByDid } = await import("./client");
|
||||
|
||||
const placeholderHandle = "did:plc:f5a…";
|
||||
const realDid = "did:plc:f5abcdefghijklmnop";
|
||||
|
||||
// 1. What the handle path actually gets back today.
|
||||
invokeMock.mockResolvedValueOnce({
|
||||
did: "",
|
||||
handle: placeholderHandle,
|
||||
posts: [],
|
||||
followers: 0,
|
||||
following: 0,
|
||||
post_count: 0,
|
||||
});
|
||||
const viaHandle = await fetchProfile(placeholderHandle);
|
||||
expect(viaHandle.did).toBe("");
|
||||
expect(viaHandle.post_count).toBe(0);
|
||||
|
||||
// 2. What the DID path gets back — the real profile.
|
||||
invokeMock.mockResolvedValueOnce({
|
||||
did: realDid,
|
||||
handle: "alice.test",
|
||||
posts: [],
|
||||
followers: 5,
|
||||
following: 1,
|
||||
post_count: 12,
|
||||
});
|
||||
const viaDid = await fetchProfileByDid(realDid);
|
||||
expect(viaDid.did).toBe(realDid);
|
||||
expect(viaDid.handle).toBe("alice.test");
|
||||
expect(viaDid.post_count).toBe(12);
|
||||
|
||||
// The two calls must be distinct commands — a shared one would
|
||||
// reintroduce the ambiguity.
|
||||
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
|
||||
"profile_get",
|
||||
"profile_get_by_did",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchThread", () => {
|
||||
it("invokes fetch_thread with the viewer DID", async () => {
|
||||
const { fetchThread } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({
|
||||
post: null,
|
||||
parents: [],
|
||||
root: null,
|
||||
replies: [],
|
||||
});
|
||||
|
||||
const r = await fetchThread("at://did:plc:me/app.twi.post/3k2", "did:plc:me");
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
|
||||
uri: "at://did:plc:me/app.twi.post/3k2",
|
||||
viewerDid: "did:plc:me",
|
||||
});
|
||||
// `post: null` is the "not in our index" sentinel — one field
|
||||
// check is enough to render "post not found".
|
||||
expect(r.post).toBeNull();
|
||||
expect(r.parents).toEqual([]);
|
||||
expect(r.replies).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults the viewer DID to null", async () => {
|
||||
const { fetchThread } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce({
|
||||
post: null,
|
||||
parents: [],
|
||||
root: null,
|
||||
replies: [],
|
||||
});
|
||||
|
||||
await fetchThread("at://did:plc:me/app.twi.post/3k2");
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
|
||||
uri: "at://did:plc:me/app.twi.post/3k2",
|
||||
viewerDid: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user