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
+167 -14
View File
@@ -9,6 +9,8 @@
fetchPost,
openExternalUrl,
showError,
pickAndUploadImage,
setMyProfile,
type Session,
type Post,
type ProfileResponse,
@@ -17,13 +19,20 @@
import StatusBar from "./lib/components/StatusBar.svelte";
import PostCard from "./lib/components/PostCard.svelte";
import ComposeBox from "./lib/components/ComposeBox.svelte";
import UserProfileView from "./lib/components/UserProfileView.svelte";
import Avatar from "./lib/components/Avatar.svelte";
import LoginScreen from "./lib/components/LoginScreen.svelte";
import Terminal from "./lib/components/Terminal.svelte";
import Skeleton from "./lib/components/Skeleton.svelte";
type View = "home" | "compose" | "profile" | "search" | "settings";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
let view: View = $state("home");
// Handle for the "user" view (i.e. someone else's profile). The
// "profile" view remains the current-user view (the NavRail icon
// goes there). Selecting a handle (via the PostCard avatar link or
// a future deep-link) navigates to "user" with `selectedHandle` set.
let selectedHandle: string = $state("");
let currentUser: Session | null = $state(null);
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
@@ -40,6 +49,45 @@
let profileLoading: boolean = $state(false);
let profileError: string | null = $state(null);
// Edit-profile state.
let editingProfile: boolean = $state(false);
let editProfileName: string = $state("");
let editProfileDesc: string = $state("");
let editProfileAvatarCid: string | null = $state(null);
let savingProfile: boolean = $state(false);
async function pickAndUploadAvatar() {
try {
const r = await pickAndUploadImage();
if (r) editProfileAvatarCid = r.cid;
} catch (e) {
console.warn("avatar upload failed", e);
}
}
async function saveProfile() {
if (!currentUser) return;
savingProfile = true;
try {
const updated = await setMyProfile({
displayName: editProfileName || undefined,
description: editProfileDesc || undefined,
avatarBlobCid: editProfileAvatarCid || undefined,
});
// Refresh the cached profile from the response (or re-fetch).
if (updated) {
profile = { ...profile, ...updated } as ProfileResponse | null;
} else {
await refreshProfile(currentUser.handle);
}
editingProfile = false;
} catch (e) {
console.warn("profile save failed", e);
} finally {
savingProfile = false;
}
}
// Search state.
let searchQuery: string = $state("");
let searchResults: Post[] = $state([]);
@@ -81,6 +129,17 @@
threadLoading = false;
}
}
/// Navigate to the "user" profile view for `handle`. Called from
/// `<PostCard on_handle_click>` and the avatar/handle buttons in
/// the post header. The actual profile fetch happens inside
/// `<UserProfileView>` on mount.
function openUserProfile(handle: string) {
selectedHandle = handle;
view = "user";
threadRoot = null;
threadParent = null;
}
function closeThread() {
threadRoot = null;
threadParent = null;
@@ -462,14 +521,14 @@
<div class="toast toast--err">err: {threadError}</div>
{:else if threadRoot}
{#if threadParent && threadParent.uri !== threadRoot.uri}
<div class="thread-parent"><PostCard post={threadParent} /></div>
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} /></div>
{/if}
<PostCard post={threadRoot} />
<PostCard post={threadRoot} on_handle_click={openUserProfile} />
{/if}
</div>
{/if}
{#each userPosts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} />
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
{/each}
{#if timelineCursor}
<div class="loadmore">
@@ -487,6 +546,17 @@
<span class="meta">⌘↵ to post</span>
</div>
<ComposeBox onPosted={handlePosted} />
{:else if view === "user"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// profile —</span>
<span class="as">@{selectedHandle}</span>
</div>
<UserProfileView
handle={selectedHandle}
on_thread_click={openThread}
current_user_did={currentUser?.did ?? null}
/>
{:else if view === "profile"}
<div class="head">
<span class="prompt">$</span>
@@ -502,8 +572,81 @@
<header class="profile__head">
<div class="profile__handle">{displayHandle(profile.handle)}</div>
<div class="profile__did" title={profile.did}>{profile.did}</div>
{#if profile.avatar_cid}
<Avatar
did={profile.did}
cid={profile.avatar_cid}
name={profile.display_name ?? profile.handle}
size={64}
/>
{/if}
</header>
{#if profile.description}
<div class="profile__bio">{profile.description}</div>
{:else}
<div class="profile__bio profile__bio--empty">
// no profile yet — click "edit profile" to set one up.
</div>
{/if}
<div class="profile__actions">
<button
class="btn btn--ghost"
onclick={() => (editingProfile = !editingProfile)}
>
{editingProfile ? "cancel" : "edit profile"}
</button>
</div>
{#if editingProfile}
<div class="profile__edit">
<input
type="text"
placeholder="display name"
maxlength="64"
bind:value={editProfileName}
/>
<textarea
placeholder="description"
rows="3"
maxlength="300"
bind:value={editProfileDesc}
></textarea>
<div class="profile__edit-avatar">
{#if editProfileAvatarCid}
<span class="meta">cid: {editProfileAvatarCid.slice(0, 10)}…</span>
<button
class="btn btn--ghost"
onclick={() => (editProfileAvatarCid = null)}
>
clear
</button>
{:else}
<span class="meta">no avatar</span>
{/if}
<button
class="btn btn--ghost"
onclick={pickAndUploadAvatar}
>upload…</button>
</div>
<div class="profile__edit-actions">
<button
class="btn btn--primary"
disabled={savingProfile}
onclick={saveProfile}
>
{savingProfile ? "saving…" : "save"}
</button>
</div>
</div>
{/if}
<div class="profile__stats">
<span>{profile.post_count} posts</span>
<span>{profile.followers} followers</span>
<span>{profile.following} following</span>
</div>
<div class="profile__actions">
<button
class="btn btn--ghost"
@@ -555,7 +698,7 @@
{:else}
<h3 class="profile__h3">// recent posts</h3>
{#each profile.posts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} />
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
{/each}
{/if}
</section>
@@ -636,15 +779,15 @@
{:else if view === "search"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// search</span>
<span class="title">// search</span>
<input
class="search"
type="text"
bind:value={searchQuery}
oninput={onSearchInput}
placeholder="grep posts…"
/>
</div>
<input
class="search"
type="text"
bind:value={searchQuery}
oninput={onSearchInput}
placeholder="grep posts…"
/>
{#if searchError}
<div class="toast toast--err">err: {searchError}</div>
{/if}
@@ -657,7 +800,7 @@
{:else}
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
{#each searchResults as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} />
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} />
{/each}
{/if}
{/if}
@@ -852,6 +995,16 @@
color: var(--text-dim);
word-break: break-all;
}
.profile__bio {
font-family: var(--font-mono);
font-size: var(--fs-100);
color: var(--text);
padding: var(--s-2) 0;
}
.profile__bio--empty {
color: var(--text-dim);
font-style: italic;
}
.counts {
display: flex;
gap: var(--s-6);