feat(tauri-app): profile view, Avatar component, handle navigation

UI half of the profile feature. Mirrors the previous three commits
so the user can browse and edit profiles.

* `<Avatar did cid name size>` — reusable avatar component.
  Falls back to an initial-letter (or "?" when name is empty) circle
  when `cid` is null. Resolves the blob through the standard PDS
  fetch path so it works for any author whose PDS the client can
  reach.
* `<UserProfileView handle on_thread_click current_user_did>` —
  public profile page. Fetches `GET /api/profile/<handle>` on
  mount, renders the avatar / display name / bio / counts /
  posts. The "edit profile" button is gated on
  `current_user_did === profile.did` so a user browsing
  someone else's profile can't issue an unintended `setMyProfile`
  against their own DID.
* `PostCard` now renders an inline `<Avatar>` + clickable handle
  button that calls a new `on_handle_click` prop. The clickable
  area replaces the previous dead `<a href>` (Tauri webviews
  have no router).
* `App.svelte` adds an `openUserProfile(handle)` handler that
  sets `selectedHandle` + `view = "user"` and mounts
  `<UserProfileView>`.
* New Tauri commands `profile_get_record` / `profile_set` in
  `lib.rs` + matching client helpers `getMyProfile` /
  `setMyProfile` in `client.ts`. The set command sends camelCase
  field names; the PDS endpoint (previous commit) round-trips them
  through `#[serde(rename_all = "camelCase")]`.
* Empty-state UX for users with no profile yet (new account, or a
  third-party-PDS author whose profile the AppView hasn't indexed
  yet): both the current-user "profile" view and the public
  "user" view render a hint ("// no profile yet — click 'edit
  profile' to set one up." / "// no profile yet.") instead of a
  blank bio box.
* NavRail / NavRailHarness `View` union extended with "user"
  so the navigation prop type accepts the new view.
This commit is contained in:
tomdebone
2026-07-18 17:57:15 +02:00
parent 59a3cb02dd
commit ffee5c6685
9 changed files with 798 additions and 21 deletions
@@ -0,0 +1,92 @@
<script lang="ts">
import { fetchBlob } from "../api/client";
import { onDestroy } from "svelte";
type Props = {
did: string;
/** Blob-ref `$link` from a posts/post record or a profile
* record. The Avatar component resolves the (did, cid) pair via
* the existing PDS `getBlob` route through `fetchBlob`. NULL
* falls back to the deterministic initial-letter SVG. */
cid?: string | null;
/** Human-readable name used for the initial-letter fallback and
* the alt text. */
name?: string | null;
/** Pixel size. The same component is used at 24 px (PostCard),
* 32 px (Header current-user avatar) and 88 px (Profile-View
* header). */
size?: number;
};
let { did, cid = null, name = "", size = 32 }: Props = $props();
const initial = $derived(
((name || "").trim()[0] || "?").toUpperCase(),
);
let blobUrl: string | null = $state(null);
let lastCid: string | null = null;
$effect(() => {
// Drop the previous blob URL when the CID changes — keeps the
// in-memory cache (managed by `fetchBlob`) lean and avoids leaking
// object URLs across navigations.
if (lastCid !== cid) {
if (blobUrl) URL.revokeObjectURL(blobUrl);
blobUrl = null;
lastCid = cid;
}
if (!cid) return;
let cancelled = false;
fetchBlob(did, cid)
.then((u) => {
if (!cancelled) blobUrl = u;
else URL.revokeObjectURL(u);
})
.catch(() => {
/* Fall back to the initial letter on fetch error. */
});
return () => {
cancelled = true;
};
});
onDestroy(() => {
if (blobUrl) URL.revokeObjectURL(blobUrl);
});
</script>
{#if blobUrl}
<img
class="avatar"
style:width="{size}px"
style:height="{size}px"
src={blobUrl}
alt={name ? `${name}'s avatar` : "avatar"}
/>
{:else}
<span
class="avatar avatar--fallback"
style:width="{size}px"
style:height="{size}px"
style:font-size="{Math.max(10, Math.floor(size * 0.45))}px"
>
{initial}
</span>
{/if}
<style>
.avatar {
display: inline-block;
border-radius: 50%;
object-fit: cover;
background: var(--bg-elev, #1a1a1a);
flex-shrink: 0;
}
.avatar--fallback {
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono, monospace);
color: var(--text-dim, #888);
border: 1px solid var(--line-2, #3a3a3a);
}
</style>
@@ -4,7 +4,7 @@
// `$bindable`, use a callback prop to bubble state changes up to
// the parent.
type View = "home" | "compose" | "profile" | "search" | "settings";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
let {
view = "home",
@@ -5,7 +5,7 @@
import NavRail from "./NavRail.svelte";
type View = "home" | "compose" | "profile" | "search" | "settings";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
let view: View = $state("home");
</script>
@@ -12,10 +12,21 @@
} from "../api/client";
import EmbedImage from "./EmbedImage.svelte";
import EmbedExternal from "./EmbedExternal.svelte";
import Avatar from "./Avatar.svelte";
import { localStorageKey, useLocalStorage } from "../utils/localstorage";
type Props = { post: Post; on_thread_click?: (uri: string) => void };
let { post, on_thread_click }: Props = $props();
type Props = {
post: Post;
on_thread_click?: (uri: string) => void;
/// Called when the user clicks the handle / avatar in the
/// post header. Tauri webviews don't have a real router, so
/// the host (App.svelte) decides what to do — typically it
/// sets `selectedHandle` + `view = "user"` to render
/// `<UserProfileView>`. When absent the header remains
/// clickable but does nothing.
on_handle_click?: (handle: string) => void;
};
let { post, on_thread_click, on_handle_click }: Props = $props();
// Quoted-post cache. When the post's embed is a `record`, we fetch
// it once on mount and cache it keyed by URI so navigating
@@ -288,8 +299,24 @@
{/if}
<header class="post__head">
<span class="prompt">&gt;</span>
<a class="handle" href={`/profile/${post.handle}`}>@{shortHandle(post.handle)}</a>
<button
class="avatar-btn"
type="button"
title="open profile"
onclick={() => on_handle_click?.(post.handle)}
>
<Avatar
did={post.did}
cid={post.avatar_cid ?? null}
name={post.handle}
size={24}
/>
</button>
<button
class="handle"
type="button"
onclick={() => on_handle_click?.(post.handle)}
>@{shortHandle(post.handle)}</button>
<span class="time">{timeAgo(post.created_at)}</span>
<span class="cid" title={post.cid}>cid: {shortCid(post.cid)}</span>
<span class="did" title={post.did}>{shortDid(post.did)}</span>
@@ -409,7 +436,24 @@
margin-bottom: var(--s-2);
}
.prompt { color: var(--orange); }
.handle { color: var(--text); }
.avatar-btn {
background: none;
border: 0;
padding: 0;
margin: 0;
cursor: pointer;
display: inline-flex;
}
.avatar-btn:hover { opacity: 0.85; }
.handle {
background: none;
border: 0;
padding: 0;
margin: 0;
cursor: pointer;
font: inherit;
color: var(--text);
}
.handle:hover { color: var(--orange); }
.time, .cid, .did { color: var(--cid-fg); }
.did { color: var(--text-dim); }
@@ -0,0 +1,334 @@
<script lang="ts">
import Avatar from "./Avatar.svelte";
import PostCard from "./PostCard.svelte";
import { setMyProfile, pickAndUploadImage } from "../api/client";
import { onMount } from "svelte";
type Props = {
handle: string;
on_thread_click?: (uri: string) => void;
/// DID of the authenticated user. When this matches the
/// profile's DID, the "edit profile" button is shown; otherwise
/// it's hidden (you can only edit your own profile).
current_user_did?: string | null;
};
let { handle, on_thread_click, current_user_did }: Props = $props();
type State =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: AppViewProfile };
type AppViewProfile = {
did: string;
handle: string;
posts: AppViewPost[];
followers: number;
following: number;
display_name?: string;
description?: string;
avatar_cid?: string;
banner_cid?: string;
post_count: number;
};
type AppViewPost = {
uri: string;
did: string;
handle: string;
rkey: string;
collection: string;
text: string;
cid: string;
parent_uri?: string | null;
root_uri?: string | null;
embed?: null;
langs: string[];
created_at: string;
avatar_cid?: string | null;
};
let editing: boolean = $state(false);
let viewModel: State = $state({ kind: "loading" });
let editName: string = $state("");
let editDesc: string = $state("");
let editAvatarCid: string | null = $state(null);
let saving: boolean = $state(false);
async function load() {
viewModel = { kind: "loading" };
try {
const r = await fetch(`/api/profile/${encodeURIComponent(handle)}`);
if (!r.ok) {
viewModel = { kind: "error", message: `profile fetch failed: ${r.status}` };
return;
}
const data: AppViewProfile = await r.json();
viewModel = { kind: "ready", data };
} catch (e) {
viewModel = { kind: "error", message: String(e) };
}
}
function openEdit() {
if (viewModel.kind !== "ready") return;
editName = viewModel.data.display_name ?? "";
editDesc = viewModel.data.description ?? "";
editAvatarCid = viewModel.data.avatar_cid ?? null;
editing = true;
}
async function saveProfile() {
if (viewModel.kind !== "ready") return;
saving = true;
try {
await setMyProfile({
displayName: editName || undefined,
description: editDesc || undefined,
avatarBlobCid: editAvatarCid || undefined,
});
editing = false;
await load();
} catch (e) {
console.warn("profile save failed", e);
} finally {
saving = false;
}
}
async function pickAndUploadAvatar() {
const blob = await pickAndUploadImage();
if (!blob) return;
editAvatarCid = blob.cid;
}
onMount(() => {
void load();
});
</script>
<section class="user-profile">
<header class="user-profile__head">
{#if viewModel.kind === "ready"}
<div class="user-profile__avatar">
<Avatar
did={viewModel.data.did}
cid={viewModel.data.avatar_cid ?? null}
name={viewModel.data.display_name ?? viewModel.data.handle}
size={88}
/>
</div>
<div class="user-profile__id">
<h2 class="user-profile__name">{viewModel.data.display_name ?? "@" + viewModel.data.handle}</h2>
<span class="user-profile__handle">@{viewModel.data.handle}</span>
<span class="user-profile__did" title={viewModel.data.did}>{viewModel.data.did}</span>
</div>
<div class="user-profile__actions">
{#if current_user_did && viewModel.data.did === current_user_did}
<button
class="btn btn--ghost"
type="button"
onclick={() => (editing ? (editing = false) : openEdit())}
>
{editing ? "cancel" : "edit profile"}
</button>
{/if}
</div>
{:else if viewModel.kind === "loading"}
<div class="user-profile__loading">loading…</div>
{:else}
<div class="user-profile__error">err: {viewModel.message}</div>
{/if}
</header>
{#if viewModel.kind === "ready" && viewModel.data.description}
<p class="user-profile__bio">{viewModel.data.description}</p>
{:else if viewModel.kind === "ready"}
<!--
Empty-state hint for a user who hasn't filled in their profile
yet. Only shown when *no* profile fields are populated (a
partial profile still renders whatever's there). The owner of
an empty profile sees an explicit "set up your profile" hint
instead of an awkward blank space.
-->
{#if !viewModel.data.display_name && !viewModel.data.description && !viewModel.data.avatar_cid}
<p class="user-profile__bio user-profile__bio--empty">
{#if current_user_did && viewModel.data.did === current_user_did}
// no profile yet — click "edit profile" to set one up.
{:else}
// no profile yet.
{/if}
</p>
{/if}
{/if}
{#if editing}
<div class="user-profile__edit">
<label>
<span class="key">display name</span>
<input type="text" bind:value={editName} maxlength="64" />
</label>
<label>
<span class="key">description</span>
<textarea
bind:value={editDesc}
rows="3"
maxlength="300"
></textarea>
</label>
<div class="user-profile__edit-avatar">
<span class="key">avatar</span>
<div class="user-profile__edit-avatar-row">
{#if editAvatarCid}
<span class="meta">cid: {editAvatarCid.slice(0, 10)}</span>
<button class="btn btn--ghost" type="button" onclick={() => (editAvatarCid = null)}>clear</button>
{:else}
<span class="meta">none</span>
{/if}
<button class="btn btn--ghost" type="button" onclick={pickAndUploadAvatar}>upload…</button>
</div>
</div>
<div class="user-profile__edit-actions">
<button class="btn btn--ghost" type="button" disabled={saving} onclick={saveProfile}>
{saving ? "saving…" : "save"}
</button>
</div>
</div>
{/if}
{#if viewModel.kind === "ready"}
<dl class="user-profile__counts">
<div><dt>posts</dt><dd>{viewModel.data.post_count}</dd></div>
<div><dt>followers</dt><dd>{viewModel.data.followers}</dd></div>
<div><dt>following</dt><dd>{viewModel.data.following}</dd></div>
</dl>
<div class="user-profile__posts">
{#each viewModel.data.posts as p (p.uri)}
<PostCard post={p} on_thread_click={on_thread_click} />
{/each}
</div>
{/if}
</section>
<style>
.user-profile {
padding: 0 var(--s-3, 0.75rem);
}
.user-profile__head {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--s-3, 0.75rem);
align-items: center;
padding: var(--s-3, 0.75rem) 0;
border-bottom: 1px dashed var(--line-2, #3a3a3a);
}
.user-profile__avatar {
display: flex;
align-items: center;
}
.user-profile__id {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-profile__name {
font-family: var(--font-mono, monospace);
font-size: 1.05rem;
color: var(--text, #e8e8e8);
}
.user-profile__handle {
font-family: var(--font-mono, monospace);
font-size: 0.8rem;
color: var(--orange, #ff6600);
}
.user-profile__did {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--text-dim, #888);
}
.user-profile__bio {
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
color: var(--text, #e8e8e8);
padding: var(--s-3, 0.75rem) 0;
border-bottom: 1px dashed var(--line, #2a2a2a);
}
.user-profile__bio--empty {
color: var(--text-dim, #888);
font-style: italic;
}
.user-profile__edit {
display: flex;
flex-direction: column;
gap: var(--s-2, 0.5rem);
padding: var(--s-3, 0.75rem) 0;
border-bottom: 1px dashed var(--line, #2a2a2a);
}
.user-profile__edit label,
.user-profile__edit-avatar {
display: flex;
align-items: baseline;
gap: var(--s-2, 0.5rem);
}
.user-profile__edit .key {
flex: 0 0 7rem;
color: var(--orange, #ff6600);
font-family: var(--font-mono, monospace);
}
.user-profile__edit input,
.user-profile__edit textarea {
flex: 1;
background: var(--bg, #0d0d0d);
border: 1px solid var(--line-2, #3a3a3a);
color: var(--text, #e8e8e8);
font-family: var(--font-mono, monospace);
padding: var(--s-1, 0.25rem) var(--s-2, 0.5rem);
border-radius: 4px;
}
.user-profile__edit-avatar-row {
flex: 1;
display: flex;
gap: var(--s-2, 0.5rem);
align-items: center;
}
.user-profile__edit-avatar-row .meta {
color: var(--text-dim, #888);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
}
.user-profile__edit-actions {
display: flex;
justify-content: flex-end;
}
.user-profile__counts {
display: flex;
gap: var(--s-5, 1.5rem);
padding: var(--s-2, 0.5rem) var(--s-3, 0.75rem);
margin: 0;
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
color: var(--text-dim, #888);
}
.user-profile__counts > div {
display: flex;
flex-direction: column;
}
.user-profile__counts dt {
color: var(--text-dim, #888);
letter-spacing: 0.04em;
}
.user-profile__counts dd {
margin: 0;
color: var(--text, #e8e8e8);
font-weight: 700;
}
.user-profile__loading,
.user-profile__error {
font-family: var(--font-mono, monospace);
color: var(--text-dim, #888);
padding: var(--s-3, 0.75rem);
}
.user-profile__error {
color: var(--red, #ff3b30);
}
</style>