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
This commit is contained in:
co-authored by
Claude Opus 5
parent
bce4c7862f
commit
31880e1005
@@ -4,18 +4,35 @@
|
||||
// `$bindable`, use a callback prop to bubble state changes up to
|
||||
// the parent.
|
||||
|
||||
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
|
||||
type View =
|
||||
| "home"
|
||||
| "notifications"
|
||||
| "compose"
|
||||
| "profile"
|
||||
| "user"
|
||||
| "search"
|
||||
| "settings";
|
||||
|
||||
let {
|
||||
view = "home",
|
||||
on_select,
|
||||
unread = 0,
|
||||
}: {
|
||||
view?: View;
|
||||
on_select?: (v: View) => void;
|
||||
/// Unread-notification count from `/api/notifications/count`.
|
||||
/// The parent polls it and hands it down; `0` (or anything
|
||||
/// below) hides the badge entirely rather than rendering a "0".
|
||||
unread?: number;
|
||||
} = $props();
|
||||
|
||||
/// Badge label. Anything past 99 collapses to "99+" so a long
|
||||
/// unread backlog can't widen the 88px rail.
|
||||
const badge = $derived(unread > 99 ? "99+" : String(unread));
|
||||
|
||||
const items: Array<{ id: View; label: string; key: string; icon: string }> = [
|
||||
{ id: "home", label: "home", key: "g h", icon: "home" },
|
||||
{ id: "notifications", label: "notifs", key: "g n", icon: "bell" },
|
||||
{ id: "compose", label: "compose", key: "c", icon: "compose" },
|
||||
{ id: "profile", label: "profile", key: "p", icon: "profile" },
|
||||
{ id: "search", label: "search", key: "/", icon: "search" },
|
||||
@@ -37,6 +54,10 @@
|
||||
<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 === "bell"}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 15v-4a6 6 0 1 0-12 0v4l-2 3h16z"/><path d="M10 21h4"/>
|
||||
</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">>_</text>
|
||||
@@ -55,6 +76,16 @@
|
||||
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
|
||||
</svg>
|
||||
{/if}
|
||||
{#if item.id === "notifications" && unread > 0}
|
||||
<!-- Live region so a screen reader announces a badge that
|
||||
appears while the rail is already on screen. -->
|
||||
<span
|
||||
class="badge"
|
||||
data-testid="notif-badge"
|
||||
aria-live="polite"
|
||||
aria-label={`${unread} ungelesene Benachrichtigungen`}
|
||||
>{badge}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="label">{item.label}</span>
|
||||
</button>
|
||||
@@ -107,6 +138,26 @@
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
/* Anchor for the unread badge, which overhangs the glyph's
|
||||
top-right corner the way a tray badge does. */
|
||||
position: relative;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -10px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--orange);
|
||||
color: var(--bg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
.icon :global(svg) {
|
||||
width: 22px;
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//
|
||||
// Pattern: `let view = $state("home")` in the harness, then the
|
||||
// `on_select` callback updates it (same shape as App.svelte).
|
||||
//
|
||||
// The rail order is: home, notifications, compose, profile, search,
|
||||
// settings. The indices below follow that order — if you reorder the
|
||||
// `items` array in NavRail.svelte, update them here too.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mount, unmount, tick } from "svelte";
|
||||
@@ -23,16 +27,16 @@ afterEach(() => {
|
||||
target.remove();
|
||||
});
|
||||
|
||||
async function mountHarness() {
|
||||
app = mount(NavRailHarness, { target });
|
||||
async function mountHarness(props: { unread?: number } = {}) {
|
||||
app = mount(NavRailHarness, { target, props });
|
||||
await tick();
|
||||
}
|
||||
|
||||
describe("NavRail (callback-prop pattern)", () => {
|
||||
it("renders 5 buttons with home active", async () => {
|
||||
it("renders 6 buttons with home active", async () => {
|
||||
await mountHarness();
|
||||
const btns = target.querySelectorAll("button.rail__btn");
|
||||
expect(btns.length).toBe(5);
|
||||
expect(btns.length).toBe(6);
|
||||
expect(btns[0].classList.contains("active")).toBe(true);
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
|
||||
});
|
||||
@@ -41,25 +45,68 @@ describe("NavRail (callback-prop pattern)", () => {
|
||||
await mountHarness();
|
||||
const btns = target.querySelectorAll("button.rail__btn");
|
||||
|
||||
btns[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await tick();
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("search");
|
||||
expect(btns[3].classList.contains("active")).toBe(true);
|
||||
expect(btns[4].classList.contains("active")).toBe(true);
|
||||
expect(btns[0].classList.contains("active")).toBe(false);
|
||||
|
||||
btns[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await tick();
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("compose");
|
||||
expect(btns[1].classList.contains("active")).toBe(true);
|
||||
|
||||
btns[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await tick();
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("compose");
|
||||
expect(btns[2].classList.contains("active")).toBe(true);
|
||||
|
||||
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
btns[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await tick();
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
|
||||
expect(btns[3].classList.contains("active")).toBe(true);
|
||||
|
||||
btns[5].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await tick();
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("settings");
|
||||
expect(btns[4].classList.contains("active")).toBe(true);
|
||||
expect(btns[5].classList.contains("active")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("routes to the notifications view", async () => {
|
||||
await mountHarness();
|
||||
const btns = target.querySelectorAll("button.rail__btn");
|
||||
|
||||
btns[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await tick();
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe(
|
||||
"notifications",
|
||||
);
|
||||
expect(btns[1].classList.contains("active")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavRail unread badge", () => {
|
||||
it("renders no badge at zero unread", async () => {
|
||||
await mountHarness({ unread: 0 });
|
||||
expect(target.querySelector('[data-testid="notif-badge"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the count on the notifications button", async () => {
|
||||
await mountHarness({ unread: 3 });
|
||||
const badge = target.querySelector('[data-testid="notif-badge"]');
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge?.textContent?.trim()).toBe("3");
|
||||
// The badge must sit on the notifications entry, not on some
|
||||
// other rail button.
|
||||
const btns = target.querySelectorAll("button.rail__btn");
|
||||
expect(btns[1].contains(badge!)).toBe(true);
|
||||
});
|
||||
|
||||
it("caps a large backlog at 99+ so the 88px rail can't widen", async () => {
|
||||
await mountHarness({ unread: 1234 });
|
||||
const badge = target.querySelector('[data-testid="notif-badge"]');
|
||||
expect(badge?.textContent?.trim()).toBe("99+");
|
||||
});
|
||||
|
||||
it("keeps 99 unabbreviated (the cap is exclusive)", async () => {
|
||||
await mountHarness({ unread: 99 });
|
||||
expect(
|
||||
target.querySelector('[data-testid="notif-badge"]')?.textContent?.trim(),
|
||||
).toBe("99");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,14 +5,28 @@
|
||||
|
||||
import NavRail from "./NavRail.svelte";
|
||||
|
||||
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
|
||||
type View =
|
||||
| "home"
|
||||
| "notifications"
|
||||
| "compose"
|
||||
| "profile"
|
||||
| "user"
|
||||
| "search"
|
||||
| "settings";
|
||||
|
||||
// `unread` is a prop so the badge test can drive it without going
|
||||
// through the polling path in App.svelte. Defaults to 0 (no badge),
|
||||
// which is what every non-badge test expects.
|
||||
let { unread = 0 }: { unread?: number } = $props();
|
||||
|
||||
let view: View = $state("home");
|
||||
</script>
|
||||
|
||||
<NavRail
|
||||
{view}
|
||||
{unread}
|
||||
on_select={(v) => {
|
||||
view = v;
|
||||
}}
|
||||
/>
|
||||
<span data-testid="view-value">{view}</span>
|
||||
<span data-testid="view-value">{view}</span>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
<script lang="ts">
|
||||
import Avatar from "./Avatar.svelte";
|
||||
import Skeleton from "./Skeleton.svelte";
|
||||
import {
|
||||
fetchNotifications,
|
||||
markNotificationsSeen,
|
||||
notificationIcon,
|
||||
notificationText,
|
||||
type Notification,
|
||||
} from "../api/client";
|
||||
|
||||
type Props = {
|
||||
/// Recipient DID — whose notifications to list.
|
||||
did: string;
|
||||
/// Opens the thread for a notification's `subject_uri`. Wired to
|
||||
/// App.svelte's existing `openThread` helper, so a notification
|
||||
/// and a PostCard's thread button land in the same modal.
|
||||
on_thread_click?: (uri: string) => void;
|
||||
/// Navigates to the author's profile. Takes the DID *and* the
|
||||
/// handle, and the DID is what the navigation resolves by: the
|
||||
/// AppView hands us a placeholder handle (a truncated DID, see
|
||||
/// `short_did_bare`) for any author it has neither a profile row
|
||||
/// nor an indexed post for, and that placeholder resolves to a
|
||||
/// synthetic empty profile on the way back in. `author_did` is
|
||||
/// always real, so it's the one field worth navigating on. The
|
||||
/// handle still travels along for the header label while the
|
||||
/// profile loads.
|
||||
on_actor_click?: (did: string, handle: string) => void;
|
||||
/// Fired once the first page is marked read, so the parent can
|
||||
/// zero the NavRail badge without waiting for the next poll.
|
||||
on_seen?: () => void;
|
||||
};
|
||||
|
||||
let { did, on_thread_click, on_actor_click, on_seen }: Props = $props();
|
||||
|
||||
let items: Notification[] = $state([]);
|
||||
let cursor: string | null = $state(null);
|
||||
let loading: boolean = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
/// Guards `load()` against a re-entrant `$effect` run: the effect
|
||||
/// tracks `did`, but `load` writes `$state` the effect would
|
||||
/// otherwise see as a self-write (the same depth-guard problem
|
||||
/// ProfileView documents around `untrack`).
|
||||
let loadedDid: string | null = null;
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const r = await fetchNotifications(did, null, 30);
|
||||
items = r.notifications;
|
||||
cursor = r.cursor;
|
||||
// Watermark the read marker at the newest row we actually
|
||||
// rendered — never `null` — so a notification that lands while
|
||||
// the user is scrolling isn't silently marked as seen.
|
||||
const top = r.notifications[0]?.indexed_at ?? null;
|
||||
if (top) {
|
||||
try {
|
||||
await markNotificationsSeen(did, top);
|
||||
// Reflect it locally too: the rows are already on screen,
|
||||
// and re-fetching just to flip `read_at` would be a wasted
|
||||
// round trip.
|
||||
items = items.map((n) =>
|
||||
n.read_at ? n : { ...n, read_at: top },
|
||||
);
|
||||
on_seen?.();
|
||||
} catch (e) {
|
||||
// A failed ack is not a failed load — the list is usable,
|
||||
// the badge just stays until the next attempt.
|
||||
console.warn("mark_notifications_seen failed", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!cursor || loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
const r = await fetchNotifications(did, cursor, 30);
|
||||
// De-dupe on `id`: the keyset cursor is stable, but a row
|
||||
// indexed between two page fetches can otherwise shift a row
|
||||
// across the page boundary.
|
||||
const seen = new Set(items.map((n) => n.id));
|
||||
items = [...items, ...r.notifications.filter((n) => !seen.has(n.id))];
|
||||
cursor = r.cursor;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!did || loadedDid === did) return;
|
||||
loadedDid = did;
|
||||
void load();
|
||||
});
|
||||
|
||||
/// Display label for the author — the profile's display name when
|
||||
/// the AppView has one cached, else the handle (which the server
|
||||
/// guarantees is non-empty, falling back to a truncated DID).
|
||||
function authorName(n: Notification): string {
|
||||
return n.author_display_name || n.author_handle;
|
||||
}
|
||||
|
||||
/// Same relative-time rendering as PostCard's `timeAgo`, on
|
||||
/// `indexed_at` (the list's sort key) rather than `created_at`.
|
||||
function timeAgo(iso: string): string {
|
||||
const timestamp = new Date(iso).getTime();
|
||||
if (!Number.isFinite(timestamp)) return iso;
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
||||
return `${Math.floor(seconds / 86400)}d`;
|
||||
}
|
||||
|
||||
function openSubject(n: Notification) {
|
||||
if (!n.subject_uri) return;
|
||||
on_thread_click?.(n.subject_uri);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="notifs">
|
||||
{#if error}
|
||||
<div class="notifs__err">err: {error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading && items.length === 0}
|
||||
<Skeleton rows={3} />
|
||||
{:else if items.length === 0}
|
||||
<div class="notifs__empty">// keine Benachrichtigungen.</div>
|
||||
{:else}
|
||||
<ul class="notifs__list">
|
||||
{#each items as n (n.id)}
|
||||
<li class="notifs__item" class:notifs__item--unread={!n.read_at}>
|
||||
<!--
|
||||
The whole row is the click target when there's a subject
|
||||
to open; for a "follow" (no subject) the row is inert and
|
||||
only the handle button navigates. `type="button"` +
|
||||
`disabled` keeps the keyboard order honest instead of
|
||||
faking it with a div.
|
||||
-->
|
||||
<button
|
||||
class="notifs__row"
|
||||
type="button"
|
||||
disabled={!n.subject_uri}
|
||||
aria-label={`${authorName(n)} ${notificationText(n.kind)}`}
|
||||
onclick={() => openSubject(n)}
|
||||
>
|
||||
<span class="notifs__icon" data-kind={n.kind} aria-hidden="true">
|
||||
{notificationIcon(n.kind)}
|
||||
</span>
|
||||
<span class="notifs__avatar">
|
||||
<Avatar
|
||||
did={n.author_did}
|
||||
cid={n.author_avatar_cid ?? null}
|
||||
name={authorName(n)}
|
||||
size={32}
|
||||
/>
|
||||
</span>
|
||||
<span class="notifs__body">
|
||||
<span class="notifs__line">
|
||||
<span class="notifs__name">{authorName(n)}</span>
|
||||
<span class="notifs__text">{notificationText(n.kind)}</span>
|
||||
<span class="notifs__age">{timeAgo(n.indexed_at)}</span>
|
||||
</span>
|
||||
{#if n.subject_text}
|
||||
<span class="notifs__subject">{n.subject_text}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="notifs__handle"
|
||||
type="button"
|
||||
title={n.author_did}
|
||||
onclick={() => on_actor_click?.(n.author_did, n.author_handle)}
|
||||
>@{n.author_handle}</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if cursor}
|
||||
<div class="notifs__loadmore">
|
||||
<button
|
||||
class="notifs__btn"
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onclick={() => void loadMore()}
|
||||
>
|
||||
{loading ? "loading…" : "load more"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.notifs {
|
||||
padding: 0 0 var(--s-6);
|
||||
}
|
||||
.notifs__err {
|
||||
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: var(--orange-3);
|
||||
color: var(--red);
|
||||
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
}
|
||||
.notifs__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;
|
||||
}
|
||||
.notifs__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
/* One row per notification: the clickable body plus the handle
|
||||
button, which is a separate target so "open the thread" and "go
|
||||
to the author" don't fight over the same click. */
|
||||
.notifs__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: 0 var(--s-4) 0 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
/* Unread rows get the orange left rail + tint the rest of the app
|
||||
uses for "needs attention" (same tokens as the settings hover
|
||||
state), so the read/unread split is visible without a legend. */
|
||||
.notifs__item--unread {
|
||||
background: var(--orange-8);
|
||||
box-shadow: inset 3px 0 0 var(--orange);
|
||||
}
|
||||
.notifs__row {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
transition: background-color var(--dur) var(--ease);
|
||||
}
|
||||
.notifs__row:hover:not(:disabled) {
|
||||
background: var(--orange-3);
|
||||
}
|
||||
.notifs__row:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.notifs__icon {
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
font-size: var(--fs-100);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
/* Per-kind accent: likes red, reposts green, follows/replies cyan
|
||||
— all straight from the token palette, no new hex values. */
|
||||
.notifs__icon[data-kind="like"] { color: var(--red); }
|
||||
.notifs__icon[data-kind="repost"] { color: var(--green); }
|
||||
.notifs__icon[data-kind="follow"] { color: var(--cyan); }
|
||||
.notifs__icon[data-kind="reply"] { color: var(--orange); }
|
||||
.notifs__avatar {
|
||||
flex: 0 0 auto;
|
||||
line-height: 0;
|
||||
}
|
||||
.notifs__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.notifs__line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.notifs__name {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-50);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.notifs__text {
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-50);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notifs__age {
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-50);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* Subject preview — one line, clipped. The full text is a click
|
||||
away in the thread, so wrapping here would only push rows apart. */
|
||||
.notifs__subject {
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-50);
|
||||
font-family: var(--font-sans);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.notifs__handle {
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
cursor: pointer;
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border-radius: var(--r-sm);
|
||||
transition: color var(--dur) var(--ease);
|
||||
}
|
||||
.notifs__handle:hover {
|
||||
color: var(--orange);
|
||||
}
|
||||
.notifs__loadmore {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--s-4) var(--s-5);
|
||||
}
|
||||
.notifs__btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease);
|
||||
}
|
||||
.notifs__btn:hover:not(:disabled) {
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
}
|
||||
.notifs__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
// Regression guard for the actor-navigation path out of the
|
||||
// notifications list.
|
||||
//
|
||||
// The AppView guarantees `author_handle` is non-empty, but it does NOT
|
||||
// guarantee it's a real handle: for an author it has neither a
|
||||
// `profiles` row nor an indexed post for, it synthesises a placeholder
|
||||
// from the DID (`short_did_bare` in the AppView's `routes.rs`) — e.g.
|
||||
// `"did:plc:f5…"`, a truncated DID with an ellipsis. Navigating with
|
||||
// that string resolves to nothing, and `/api/profile/<handle>` answers
|
||||
// with a *synthetic empty profile* rather than an error, so the click
|
||||
// silently dead-ends on a blank page.
|
||||
//
|
||||
// The fix is to hand the navigation callback the real `author_did`
|
||||
// alongside the handle. These tests pin that: the callback must
|
||||
// receive the DID from the DTO, for both a real-handle author and a
|
||||
// placeholder-handle one.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, unmount, tick } from "svelte";
|
||||
|
||||
const fetchNotificationsMock = vi.fn();
|
||||
const markNotificationsSeenMock = vi.fn();
|
||||
|
||||
vi.mock("../api/client", async () => {
|
||||
// Keep the real `notificationText` / `notificationIcon` — the row
|
||||
// copy is part of what we're rendering — and stub only the two
|
||||
// functions that would otherwise need a Tauri runtime.
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../api/client")>("../api/client");
|
||||
return {
|
||||
...actual,
|
||||
fetchNotifications: (...args: unknown[]) => fetchNotificationsMock(...args),
|
||||
markNotificationsSeen: (...args: unknown[]) =>
|
||||
markNotificationsSeenMock(...args),
|
||||
// Avatar resolves blobs through this; every fixture below has a
|
||||
// null avatar CID so it never fires, but stub it so a stray call
|
||||
// can't reach for the Tauri shell.
|
||||
fetchBlob: () => Promise.reject(new Error("no blob in test")),
|
||||
};
|
||||
});
|
||||
|
||||
import NotificationsView from "./NotificationsView.svelte";
|
||||
|
||||
let target: HTMLDivElement;
|
||||
let app: ReturnType<typeof mount> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
target = document.createElement("div");
|
||||
document.body.appendChild(target);
|
||||
fetchNotificationsMock.mockReset();
|
||||
markNotificationsSeenMock.mockReset();
|
||||
markNotificationsSeenMock.mockResolvedValue({ ok: true, updated: 1 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (app) unmount(app);
|
||||
app = null;
|
||||
target.remove();
|
||||
});
|
||||
|
||||
/// Let the component's load effect and its awaited promises settle.
|
||||
/// The mocked fetch resolves immediately, so a handful of microtask
|
||||
/// turns plus a Svelte flush is enough.
|
||||
async function flush(turns = 6) {
|
||||
for (let i = 0; i < turns; i++) {
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
}
|
||||
}
|
||||
|
||||
function row(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
kind: "like",
|
||||
author_did: "did:plc:realauthor",
|
||||
author_handle: "alice.test",
|
||||
author_avatar_cid: null,
|
||||
subject_uri: "at://did:plc:me/app.twi.post/3k2",
|
||||
subject_text: "hello",
|
||||
created_at: "2026-09-09T10:00:00Z",
|
||||
indexed_at: "2026-09-09T10:00:01Z",
|
||||
read_at: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("NotificationsView actor navigation", () => {
|
||||
it("hands the callback the author DID, not just the handle", async () => {
|
||||
fetchNotificationsMock.mockResolvedValue({
|
||||
notifications: [row()],
|
||||
cursor: null,
|
||||
});
|
||||
const onActorClick = vi.fn();
|
||||
|
||||
app = mount(NotificationsView, {
|
||||
target,
|
||||
props: { did: "did:plc:me", on_actor_click: onActorClick },
|
||||
});
|
||||
await flush();
|
||||
|
||||
const handleBtn = target.querySelector("button.notifs__handle");
|
||||
expect(handleBtn).not.toBeNull();
|
||||
handleBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await tick();
|
||||
|
||||
expect(onActorClick).toHaveBeenCalledTimes(1);
|
||||
expect(onActorClick).toHaveBeenCalledWith(
|
||||
"did:plc:realauthor",
|
||||
"alice.test",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves an author whose handle is only a truncated-DID placeholder", async () => {
|
||||
// Exactly what the AppView returns for an author it has never
|
||||
// seen post and has no profile row for: `handle` is
|
||||
// `short_did_bare(author_did)`, which matches nothing on the way
|
||||
// back in. The DID beside it is real.
|
||||
fetchNotificationsMock.mockResolvedValue({
|
||||
notifications: [
|
||||
row({
|
||||
id: 2,
|
||||
kind: "follow",
|
||||
author_did: "did:plc:f5abcdefghijklmnop",
|
||||
author_handle: "did:plc:f5a…",
|
||||
subject_uri: null,
|
||||
subject_text: undefined,
|
||||
}),
|
||||
],
|
||||
cursor: null,
|
||||
});
|
||||
const onActorClick = vi.fn();
|
||||
|
||||
app = mount(NotificationsView, {
|
||||
target,
|
||||
props: { did: "did:plc:me", on_actor_click: onActorClick },
|
||||
});
|
||||
await flush();
|
||||
|
||||
target
|
||||
.querySelector("button.notifs__handle")!
|
||||
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await tick();
|
||||
|
||||
const [didArg, handleArg] = onActorClick.mock.calls[0];
|
||||
// The DID must be the full, real one — never the truncated
|
||||
// display string.
|
||||
expect(didArg).toBe("did:plc:f5abcdefghijklmnop");
|
||||
expect(didArg).not.toContain("…");
|
||||
// The placeholder still travels as the label, which is fine — it
|
||||
// is not what the lookup keys off.
|
||||
expect(handleArg).toBe("did:plc:f5a…");
|
||||
});
|
||||
|
||||
it("renders the kind copy and marks the first page seen at the top row", async () => {
|
||||
fetchNotificationsMock.mockResolvedValue({
|
||||
notifications: [row({ kind: "follow", subject_uri: null })],
|
||||
cursor: null,
|
||||
});
|
||||
const onSeen = vi.fn();
|
||||
|
||||
app = mount(NotificationsView, {
|
||||
target,
|
||||
props: { did: "did:plc:me", on_seen: onSeen },
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(target.textContent).toContain("folgt dir jetzt");
|
||||
// The read watermark is the newest rendered row's `indexed_at` —
|
||||
// never null, so a notification landing mid-scroll survives.
|
||||
expect(markNotificationsSeenMock).toHaveBeenCalledWith(
|
||||
"did:plc:me",
|
||||
"2026-09-09T10:00:01Z",
|
||||
);
|
||||
expect(onSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a follow row has no thread target, so its row button is inert", async () => {
|
||||
fetchNotificationsMock.mockResolvedValue({
|
||||
notifications: [row({ kind: "follow", subject_uri: null })],
|
||||
cursor: null,
|
||||
});
|
||||
const onThreadClick = vi.fn();
|
||||
|
||||
app = mount(NotificationsView, {
|
||||
target,
|
||||
props: { did: "did:plc:me", on_thread_click: onThreadClick },
|
||||
});
|
||||
await flush();
|
||||
|
||||
const rowBtn = target.querySelector<HTMLButtonElement>("button.notifs__row");
|
||||
expect(rowBtn?.disabled).toBe(true);
|
||||
rowBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await tick();
|
||||
expect(onThreadClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the thread for a row that has a subject", async () => {
|
||||
fetchNotificationsMock.mockResolvedValue({
|
||||
notifications: [row()],
|
||||
cursor: null,
|
||||
});
|
||||
const onThreadClick = vi.fn();
|
||||
|
||||
app = mount(NotificationsView, {
|
||||
target,
|
||||
props: { did: "did:plc:me", on_thread_click: onThreadClick },
|
||||
});
|
||||
await flush();
|
||||
|
||||
target
|
||||
.querySelector("button.notifs__row")!
|
||||
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await tick();
|
||||
|
||||
expect(onThreadClick).toHaveBeenCalledWith(
|
||||
"at://did:plc:me/app.twi.post/3k2",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,22 +9,49 @@
|
||||
getAppviewUrl,
|
||||
followUser,
|
||||
unfollowUser,
|
||||
fetchFollowers,
|
||||
fetchFollowing,
|
||||
fetchProfileByDid,
|
||||
showInfo,
|
||||
showError,
|
||||
type ActorProfile,
|
||||
} from "../api/client";
|
||||
import { localStorageKey } from "../utils/localstorage";
|
||||
import { onDestroy, onMount, untrack } from "svelte";
|
||||
import { onDestroy, untrack } from "svelte";
|
||||
|
||||
type Props = {
|
||||
handle: string;
|
||||
/// DID of the profile to show, when the caller already knows it.
|
||||
/// Takes precedence over `handle`: the AppView synthesises a
|
||||
/// placeholder handle (a truncated DID — `short_did_bare`) for
|
||||
/// actors it has neither a `profiles` row nor an indexed post for,
|
||||
/// and feeding that back into the handle lookup resolves to a
|
||||
/// synthetic empty profile. Navigation out of a notification or a
|
||||
/// follower list therefore passes the DID; `handle` is still
|
||||
/// supplied so the header has a label while the fetch is in
|
||||
/// flight. `null`/absent keeps the handle lookup (PostCard header,
|
||||
/// search, the current user's own profile).
|
||||
did?: string | null;
|
||||
on_thread_click?: (uri: string) => void;
|
||||
/// DID of the authenticated user. When this matches the
|
||||
/// profile's DID, the "edit profile" button is shown; the
|
||||
/// /user-profile/<handle> route is then the user's own
|
||||
/// profile (and the avatar / bio are editable).
|
||||
current_user_did?: string | null;
|
||||
/// Navigate to another profile. Receives `(did, handle)` and the
|
||||
/// caller resolves by DID — see the `did` prop above for why the
|
||||
/// handle alone is not a reliable key. Used by the follower /
|
||||
/// following list rows and by the PostCards in the feed (which
|
||||
/// carry a real `post.did`).
|
||||
on_actor_click?: (did: string, handle: string) => void;
|
||||
};
|
||||
let { handle, on_thread_click, current_user_did }: Props = $props();
|
||||
let {
|
||||
handle,
|
||||
did = null,
|
||||
on_thread_click,
|
||||
current_user_did,
|
||||
on_actor_click,
|
||||
}: Props = $props();
|
||||
|
||||
type State =
|
||||
| { kind: "loading" }
|
||||
@@ -91,6 +118,15 @@
|
||||
async function load() {
|
||||
viewModel = { kind: "loading" };
|
||||
try {
|
||||
// DID lookup wins when the caller supplied one — see the `did`
|
||||
// prop. `/api/profile?did=…` is an exact match on the `posts` /
|
||||
// `profiles` DID column, so it works for an actor whose only
|
||||
// known "handle" is the server's truncated-DID placeholder.
|
||||
if (did) {
|
||||
const data = (await fetchProfileByDid(did)) as AppViewProfile;
|
||||
viewModel = { kind: "ready", data };
|
||||
return;
|
||||
}
|
||||
// Absolute URL because the Tauri webview's origin is the Vite
|
||||
// dev server (port 1430), not the AppView (port 2584) — a
|
||||
// relative `/api/profile/…` would resolve against Vite, hit a
|
||||
@@ -145,8 +181,25 @@
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
/// Identity of the profile currently loaded, as an opaque key. A
|
||||
/// plain (non-`$state`) variable on purpose: the effect below reads
|
||||
/// it, and making it reactive would turn "remember what we loaded"
|
||||
/// into a self-write that trips Svelte 5's depth guard — the same
|
||||
/// pattern the banner effect documents around `untrack`.
|
||||
let loadedKey: string | null = null;
|
||||
|
||||
// Reload whenever the profile *identity* changes, not just on
|
||||
// mount. App.svelte keeps this component mounted across a
|
||||
// profile→profile navigation (the view stays `"user"`, only the
|
||||
// props change), so an `onMount`-only load would leave the previous
|
||||
// user's posts and counts on screen under the new name — which is
|
||||
// exactly the path a follower-list or notification click takes.
|
||||
$effect(() => {
|
||||
const key = did ? `did:${did}` : `handle:${handle}`;
|
||||
if (!did && !handle) return;
|
||||
if (loadedKey === key) return;
|
||||
loadedKey = key;
|
||||
untrack(() => void load());
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -299,6 +352,93 @@
|
||||
|
||||
type Tab = "posts" | "replies" | "likes";
|
||||
let activeTab: Tab = $state("posts");
|
||||
|
||||
// ─── follower / following list ──────────────────────────────────
|
||||
//
|
||||
// Clicking a count opens an inline list under the counts row rather
|
||||
// than navigating away — the user came here for this profile, and a
|
||||
// separate route would cost a second profile fetch on the way back.
|
||||
// Clicking the same count again closes it (the counts double as the
|
||||
// toggle, the way a disclosure does).
|
||||
type ActorListKind = "followers" | "following";
|
||||
let actorKind: ActorListKind | null = $state(null);
|
||||
let actors: ActorProfile[] = $state([]);
|
||||
let actorCursor: string | null = $state(null);
|
||||
let actorLoading: boolean = $state(false);
|
||||
let actorError: string | null = $state(null);
|
||||
|
||||
async function toggleActorList(kind: ActorListKind) {
|
||||
if (actorKind === kind) {
|
||||
closeActorList();
|
||||
return;
|
||||
}
|
||||
if (viewModel.kind !== "ready") return;
|
||||
const did = viewModel.data.did;
|
||||
actorKind = kind;
|
||||
actors = [];
|
||||
actorCursor = null;
|
||||
actorError = null;
|
||||
actorLoading = true;
|
||||
try {
|
||||
const r =
|
||||
kind === "followers"
|
||||
? await fetchFollowers(did, null, 30)
|
||||
: await fetchFollowing(did, null, 30);
|
||||
// Guard against a fast double-click on the other count: only
|
||||
// commit if we're still the list the user asked for.
|
||||
if (actorKind !== kind) return;
|
||||
actors = r.profiles;
|
||||
actorCursor = r.cursor;
|
||||
} catch (e) {
|
||||
if (actorKind === kind) actorError = String(e);
|
||||
} finally {
|
||||
if (actorKind === kind) actorLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreActors() {
|
||||
if (viewModel.kind !== "ready") return;
|
||||
const kind = actorKind;
|
||||
if (!kind || !actorCursor || actorLoading) return;
|
||||
const did = viewModel.data.did;
|
||||
actorLoading = true;
|
||||
try {
|
||||
const r =
|
||||
kind === "followers"
|
||||
? await fetchFollowers(did, actorCursor, 30)
|
||||
: await fetchFollowing(did, actorCursor, 30);
|
||||
if (actorKind !== kind) return;
|
||||
// De-dupe on DID — a follow edge indexed between two page
|
||||
// fetches can otherwise repeat a row across the boundary.
|
||||
const seen = new Set(actors.map((a) => a.did));
|
||||
actors = [...actors, ...r.profiles.filter((a) => !seen.has(a.did))];
|
||||
actorCursor = r.cursor;
|
||||
} catch (e) {
|
||||
if (actorKind === kind) actorError = String(e);
|
||||
} finally {
|
||||
if (actorKind === kind) actorLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeActorList() {
|
||||
actorKind = null;
|
||||
actors = [];
|
||||
actorCursor = null;
|
||||
actorError = null;
|
||||
actorLoading = false;
|
||||
}
|
||||
|
||||
// Switching to another profile must not leave the previous user's
|
||||
// follower list on screen. Keyed on the same `(did, handle)`
|
||||
// identity the load effect uses, so the two can't disagree about
|
||||
// when "the profile changed".
|
||||
let actorListKey: string | null = null;
|
||||
$effect(() => {
|
||||
const key = did ? `did:${did}` : `handle:${handle}`;
|
||||
if (actorListKey === key) return;
|
||||
actorListKey = key;
|
||||
untrack(() => closeActorList());
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="profile">
|
||||
@@ -426,17 +566,105 @@
|
||||
<dt>posts</dt>
|
||||
<dd>{viewModel.data.post_count}</dd>
|
||||
</div>
|
||||
<!--
|
||||
The follower / following counts are buttons: they open the
|
||||
matching actor list inline (and close it on a second click).
|
||||
The post count stays inert — there's no endpoint behind it
|
||||
that the feed below doesn't already show.
|
||||
|
||||
The button lives *inside* the `<dd>` rather than wrapping the
|
||||
`dt`/`dd` pair, because a `<dl>` may only contain `dt`/`dd`
|
||||
(via an optional `div`) — a button in between would be invalid
|
||||
markup. `aria-label` puts the term back on the control so a
|
||||
screen reader still hears "followers", not a bare number.
|
||||
-->
|
||||
<div>
|
||||
<dt>followers</dt>
|
||||
<dd>{viewModel.data.followers}</dd>
|
||||
<dd>
|
||||
<button
|
||||
class="profile__count-btn"
|
||||
class:profile__count-btn--open={actorKind === "followers"}
|
||||
type="button"
|
||||
aria-expanded={actorKind === "followers"}
|
||||
aria-label={`${viewModel.data.followers} followers anzeigen`}
|
||||
onclick={() => void toggleActorList("followers")}
|
||||
>{viewModel.data.followers}</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>following</dt>
|
||||
<dd>{viewModel.data.following}</dd>
|
||||
<dd>
|
||||
<button
|
||||
class="profile__count-btn"
|
||||
class:profile__count-btn--open={actorKind === "following"}
|
||||
type="button"
|
||||
aria-expanded={actorKind === "following"}
|
||||
aria-label={`${viewModel.data.following} following anzeigen`}
|
||||
onclick={() => void toggleActorList("following")}
|
||||
>{viewModel.data.following}</button>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
<!-- ─── follower / following list ─────────────────────────────── -->
|
||||
{#if actorKind}
|
||||
<div class="actors">
|
||||
<header class="actors__head">
|
||||
<span class="actors__title">// {actorKind}</span>
|
||||
<button
|
||||
class="actors__close"
|
||||
type="button"
|
||||
onclick={closeActorList}
|
||||
>close</button>
|
||||
</header>
|
||||
{#if actorError}
|
||||
<div class="actors__err">err: {actorError}</div>
|
||||
{/if}
|
||||
{#if actorLoading && actors.length === 0}
|
||||
<div class="actors__empty">// loading…</div>
|
||||
{:else if actors.length === 0}
|
||||
<div class="actors__empty">// niemand hier.</div>
|
||||
{:else}
|
||||
<ul class="actors__list">
|
||||
{#each actors as a (a.did)}
|
||||
<li>
|
||||
<button
|
||||
class="actors__row"
|
||||
type="button"
|
||||
title={a.did}
|
||||
onclick={() => on_actor_click?.(a.did, a.handle)}
|
||||
>
|
||||
<Avatar
|
||||
did={a.did}
|
||||
cid={a.avatar_cid ?? null}
|
||||
name={a.display_name ?? a.handle}
|
||||
size={32}
|
||||
/>
|
||||
<span class="actors__names">
|
||||
<span class="actors__name">{a.display_name ?? a.handle}</span>
|
||||
<span class="actors__handle">@{a.handle}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if actorCursor}
|
||||
<div class="actors__loadmore">
|
||||
<button
|
||||
class="actors__more"
|
||||
type="button"
|
||||
disabled={actorLoading}
|
||||
onclick={() => void loadMoreActors()}
|
||||
>
|
||||
{actorLoading ? "loading…" : "load more"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ─── tabs ──────────────────────────────────────────────────── -->
|
||||
<nav class="profile__tabs" aria-label="Profile sections">
|
||||
<button
|
||||
@@ -465,7 +693,17 @@
|
||||
<div class="profile__feed">
|
||||
{#if viewModel.kind === "ready"}
|
||||
{#each viewModel.data.posts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={on_thread_click} />
|
||||
<!--
|
||||
The feed's PostCards carry a real `post.did`, so we route
|
||||
their header clicks through the same DID-first path as the
|
||||
actor list rather than through the (possibly decorated)
|
||||
`post.handle`.
|
||||
-->
|
||||
<PostCard
|
||||
post={p}
|
||||
on_thread_click={on_thread_click}
|
||||
on_handle_click={(h) => on_actor_click?.(p.did, h)}
|
||||
/>
|
||||
{/each}
|
||||
{#if viewModel.data.posts.length === 0}
|
||||
<div class="profile__empty">// no posts yet.</div>
|
||||
@@ -705,6 +943,157 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The count button inherits the `dd` typography so the clickable
|
||||
followers/following numbers read identically to the inert post
|
||||
count — only the hover/open colour marks them as interactive. */
|
||||
.profile__count-btn {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition:
|
||||
color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease);
|
||||
}
|
||||
.profile__count-btn:hover {
|
||||
color: var(--orange);
|
||||
}
|
||||
.profile__count-btn--open {
|
||||
color: var(--orange);
|
||||
border-bottom-color: var(--orange);
|
||||
}
|
||||
|
||||
/* ─── follower / following list ─────────────────────────── */
|
||||
.actors {
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.actors__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.actors__title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--orange);
|
||||
letter-spacing: var(--tracking-label);
|
||||
font-weight: 700;
|
||||
}
|
||||
.actors__close {
|
||||
background: transparent;
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease);
|
||||
}
|
||||
.actors__close:hover {
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
}
|
||||
.actors__err {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--red);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
}
|
||||
.actors__empty {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
padding: var(--s-3) var(--s-4);
|
||||
}
|
||||
.actors__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* Cap the inline list so it never pushes the feed off screen —
|
||||
"load more" keeps the rest reachable. */
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.actors__row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
transition: background-color var(--dur) var(--ease);
|
||||
}
|
||||
.actors__row:hover {
|
||||
background: var(--orange-8);
|
||||
}
|
||||
.actors__names {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.actors__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.actors__handle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.actors__loadmore {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--s-3);
|
||||
}
|
||||
.actors__more {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease);
|
||||
}
|
||||
.actors__more:hover:not(:disabled) {
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
}
|
||||
.actors__more:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── tabs ──────────────────────────────────────────────── */
|
||||
.profile__tabs {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user