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
+58
View File
@@ -720,7 +720,65 @@ pub fn run() {
pick_and_upload_image, pick_and_upload_image,
show_notification, show_notification,
open_external_url, open_external_url,
profile_get_record,
profile_set,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running maarcadetweet"); .expect("error while running maarcadetweet");
} }
#[tauri::command]
async fn profile_get_record(
state: tauri::State<'_, AppState>,
) -> Result<Option<serde_json::Value>, String> {
let sess = state
.store
.load()
.ok_or_else(|| "not logged in".to_string())?;
state
.pds
.get_profile_record(&sess.did, &sess.access_jwt)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn profile_set(
state: tauri::State<'_, AppState>,
fields: serde_json::Value,
) -> Result<(), String> {
let sess = state
.store
.load()
.ok_or_else(|| "not logged in".to_string())?;
let display_name = fields
.get("displayName")
.and_then(|v| v.as_str())
.map(str::to_string);
let description = fields
.get("description")
.and_then(|v| v.as_str())
.map(str::to_string);
let avatar_blob_cid = fields
.get("avatarBlobCid")
.and_then(|v| v.as_str())
.map(str::to_string);
let banner_blob_cid = fields
.get("bannerBlobCid")
.and_then(|v| v.as_str())
.map(str::to_string);
state
.pds
.set_profile(
&sess.did,
&serde_json::json!({
"displayName": display_name,
"description": description,
"avatarBlobCid": avatar_blob_cid,
"bannerBlobCid": banner_blob_cid,
}),
&sess.access_jwt,
)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
@@ -385,3 +385,64 @@ pub struct UploadedBlobRef {
#[serde(rename = "$link")] #[serde(rename = "$link")]
pub link: String, pub link: String,
} }
/// `POST /xrpc/com.atproto.repo.getRecord?repo=<did>&collection=app.bsky.actor.profile&rkey=self`
/// Returns the record's CBOR-decoded value as JSON, or `None` if no
/// record exists for that path. The server replies with a
/// `{ "value": {...} | null }` envelope; we unwrap and return the
/// inner value (which is the `app.bsky.actor.profile` JSON object
/// keyed by the deserialized CBOR field names: `displayName`,
/// `description`, `avatar`/{ ref, mimeType, size }, `banner`/...).
pub async fn get_profile_record(
&self,
repo: &str,
jwt: &str,
) -> Result<Option<serde_json::Value>> {
let url = format!(
"{}/xrpc/com.atproto.repo.getRecord",
self.base_url
);
let r = self
.client
.get(&url)
.query(&[("repo", repo), ("collection", "app.bsky.actor.profile"), ("rkey", "self")])
.bearer_auth(jwt)
.send()
.await?;
if r.status().as_u16() == 404 {
return Ok(None);
}
if !r.status().is_success() {
let s = r.status();
let body = r.text().await.unwrap_or_default();
anyhow::bail!("getRecord returned {s}: {body}");
}
let v: serde_json::Value = r.json().await?;
Ok(v.get("value").cloned().and_then(|x| if x.is_null() { None } else { Some(x) }))
}
/// `POST /xrpc/app.bsky.actor.profile.set` — PDS-only convenience
/// endpoint that does a read-modify-write of the profile record. The
/// request body has the same shape as `app.bsky.actor.profile` minus
/// the `$type` (added server-side).
pub async fn set_profile(
&self,
repo: &str,
profile: &serde_json::Value,
jwt: &str,
) -> Result<serde_json::Value> {
let url = format!("{}/xrpc/app.bsky.actor.profile.set", self.base_url);
let r = self
.client
.post(&url)
.bearer_auth(jwt)
.json(profile)
.send()
.await?;
if !r.status().is_success() {
let s = r.status();
let body = r.text().await.unwrap_or_default();
anyhow::bail!("setProfile returned {s}: {body}");
}
let v: serde_json::Value = r.json().await?;
Ok(v.get("profile").cloned().unwrap_or(serde_json::Value::Null))
}
+161 -8
View File
@@ -9,6 +9,8 @@
fetchPost, fetchPost,
openExternalUrl, openExternalUrl,
showError, showError,
pickAndUploadImage,
setMyProfile,
type Session, type Session,
type Post, type Post,
type ProfileResponse, type ProfileResponse,
@@ -17,13 +19,20 @@
import StatusBar from "./lib/components/StatusBar.svelte"; import StatusBar from "./lib/components/StatusBar.svelte";
import PostCard from "./lib/components/PostCard.svelte"; import PostCard from "./lib/components/PostCard.svelte";
import ComposeBox from "./lib/components/ComposeBox.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 LoginScreen from "./lib/components/LoginScreen.svelte";
import Terminal from "./lib/components/Terminal.svelte"; import Terminal from "./lib/components/Terminal.svelte";
import Skeleton from "./lib/components/Skeleton.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"); 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 currentUser: Session | null = $state(null);
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false }); let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
@@ -40,6 +49,45 @@
let profileLoading: boolean = $state(false); let profileLoading: boolean = $state(false);
let profileError: string | null = $state(null); 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. // Search state.
let searchQuery: string = $state(""); let searchQuery: string = $state("");
let searchResults: Post[] = $state([]); let searchResults: Post[] = $state([]);
@@ -81,6 +129,17 @@
threadLoading = false; 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() { function closeThread() {
threadRoot = null; threadRoot = null;
threadParent = null; threadParent = null;
@@ -462,14 +521,14 @@
<div class="toast toast--err">err: {threadError}</div> <div class="toast toast--err">err: {threadError}</div>
{:else if threadRoot} {:else if threadRoot}
{#if threadParent && threadParent.uri !== threadRoot.uri} {#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} {/if}
<PostCard post={threadRoot} /> <PostCard post={threadRoot} on_handle_click={openUserProfile} />
{/if} {/if}
</div> </div>
{/if} {/if}
{#each userPosts as p (p.uri)} {#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} {/each}
{#if timelineCursor} {#if timelineCursor}
<div class="loadmore"> <div class="loadmore">
@@ -487,6 +546,17 @@
<span class="meta">⌘↵ to post</span> <span class="meta">⌘↵ to post</span>
</div> </div>
<ComposeBox onPosted={handlePosted} /> <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"} {:else if view === "profile"}
<div class="head"> <div class="head">
<span class="prompt">$</span> <span class="prompt">$</span>
@@ -502,8 +572,81 @@
<header class="profile__head"> <header class="profile__head">
<div class="profile__handle">{displayHandle(profile.handle)}</div> <div class="profile__handle">{displayHandle(profile.handle)}</div>
<div class="profile__did" title={profile.did}>{profile.did}</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> </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"> <div class="profile__actions">
<button <button
class="btn btn--ghost" class="btn btn--ghost"
@@ -555,7 +698,7 @@
{:else} {:else}
<h3 class="profile__h3">// recent posts</h3> <h3 class="profile__h3">// recent posts</h3>
{#each profile.posts as p (p.uri)} {#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} {/each}
{/if} {/if}
</section> </section>
@@ -636,8 +779,7 @@
{:else if view === "search"} {:else if view === "search"}
<div class="head"> <div class="head">
<span class="prompt">$</span> <span class="prompt">$</span>
<span class="title">// search</span> <span class="title">// search</span>
</div>
<input <input
class="search" class="search"
type="text" type="text"
@@ -645,6 +787,7 @@
oninput={onSearchInput} oninput={onSearchInput}
placeholder="grep posts…" placeholder="grep posts…"
/> />
</div>
{#if searchError} {#if searchError}
<div class="toast toast--err">err: {searchError}</div> <div class="toast toast--err">err: {searchError}</div>
{/if} {/if}
@@ -657,7 +800,7 @@
{:else} {:else}
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div> <div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
{#each searchResults as p (p.uri)} {#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} {/each}
{/if} {/if}
{/if} {/if}
@@ -852,6 +995,16 @@
color: var(--text-dim); color: var(--text-dim);
word-break: break-all; 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 { .counts {
display: flex; display: flex;
gap: var(--s-6); gap: var(--s-6);
+35
View File
@@ -191,6 +191,9 @@ export type Post = {
embed?: Embed | null; embed?: Embed | null;
langs: string[]; langs: string[];
created_at: string; created_at: string;
/// 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 = { export type TimelineResponse = {
@@ -204,6 +207,11 @@ export type ProfileResponse = {
posts: Post[]; posts: Post[];
followers: number; followers: number;
following: number; following: number;
display_name?: string | null;
description?: string | null;
avatar_cid?: string | null;
banner_cid?: string | null;
post_count: number;
}; };
export type SearchResponse = { export type SearchResponse = {
@@ -429,6 +437,33 @@ export async function listenTrayEvents(
/// browser preview where no Tauri runtime is present, fall back /// browser preview where no Tauri runtime is present, fall back
/// to `window.open` and treat a popup-blocker denial as /// to `window.open` and treat a popup-blocker denial as
/// "fine, user can copy the URL themselves". /// "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> { export async function openExternalUrl(url: string): Promise<void> {
try { try {
if (isTauri()) { if (isTauri()) {
@@ -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 // `$bindable`, use a callback prop to bubble state changes up to
// the parent. // the parent.
type View = "home" | "compose" | "profile" | "search" | "settings"; type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
let { let {
view = "home", view = "home",
@@ -5,7 +5,7 @@
import NavRail from "./NavRail.svelte"; 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"); let view: View = $state("home");
</script> </script>
@@ -12,10 +12,21 @@
} from "../api/client"; } from "../api/client";
import EmbedImage from "./EmbedImage.svelte"; import EmbedImage from "./EmbedImage.svelte";
import EmbedExternal from "./EmbedExternal.svelte"; import EmbedExternal from "./EmbedExternal.svelte";
import Avatar from "./Avatar.svelte";
import { localStorageKey, useLocalStorage } from "../utils/localstorage"; import { localStorageKey, useLocalStorage } from "../utils/localstorage";
type Props = { post: Post; on_thread_click?: (uri: string) => void }; type Props = {
let { post, on_thread_click }: Props = $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 // 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 // it once on mount and cache it keyed by URI so navigating
@@ -288,8 +299,24 @@
{/if} {/if}
<header class="post__head"> <header class="post__head">
<span class="prompt">&gt;</span> <button
<a class="handle" href={`/profile/${post.handle}`}>@{shortHandle(post.handle)}</a> 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="time">{timeAgo(post.created_at)}</span>
<span class="cid" title={post.cid}>cid: {shortCid(post.cid)}</span> <span class="cid" title={post.cid}>cid: {shortCid(post.cid)}</span>
<span class="did" title={post.did}>{shortDid(post.did)}</span> <span class="did" title={post.did}>{shortDid(post.did)}</span>
@@ -409,7 +436,24 @@
margin-bottom: var(--s-2); margin-bottom: var(--s-2);
} }
.prompt { color: var(--orange); } .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); } .handle:hover { color: var(--orange); }
.time, .cid, .did { color: var(--cid-fg); } .time, .cid, .did { color: var(--cid-fg); }
.did { color: var(--text-dim); } .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>