From ffee5c66858e47393d19cd257edf76a67170563e Mon Sep 17 00:00:00 2001 From: tomdebone Date: Sat, 18 Jul 2026 17:57:15 +0200 Subject: [PATCH] feat(tauri-app): profile view, Avatar component, handle navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI half of the profile feature. Mirrors the previous three commits so the user can browse and edit profiles. * `` — 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. * `` — public profile page. Fetches `GET /api/profile/` 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 `` + clickable handle button that calls a new `on_handle_click` prop. The clickable area replaces the previous dead `` (Tauri webviews have no router). * `App.svelte` adds an `openUserProfile(handle)` handler that sets `selectedHandle` + `view = "user"` and mounts ``. * 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. --- crates/tauri-app/src-tauri/src/lib.rs | 58 +++ crates/tauri-app/src-tauri/src/pds_client.rs | 61 ++++ crates/tauri-app/src/App.svelte | 181 +++++++++- crates/tauri-app/src/lib/api/client.ts | 35 ++ .../src/lib/components/Avatar.svelte | 92 +++++ .../src/lib/components/NavRail.svelte | 2 +- .../src/lib/components/NavRailHarness.svelte | 2 +- .../src/lib/components/PostCard.svelte | 54 ++- .../src/lib/components/UserProfileView.svelte | 334 ++++++++++++++++++ 9 files changed, 798 insertions(+), 21 deletions(-) create mode 100644 crates/tauri-app/src/lib/components/Avatar.svelte create mode 100644 crates/tauri-app/src/lib/components/UserProfileView.svelte diff --git a/crates/tauri-app/src-tauri/src/lib.rs b/crates/tauri-app/src-tauri/src/lib.rs index 66ed0b9..0643205 100644 --- a/crates/tauri-app/src-tauri/src/lib.rs +++ b/crates/tauri-app/src-tauri/src/lib.rs @@ -720,7 +720,65 @@ pub fn run() { pick_and_upload_image, show_notification, open_external_url, + profile_get_record, + profile_set, ]) .run(tauri::generate_context!()) .expect("error while running maarcadetweet"); } +#[tauri::command] +async fn profile_get_record( + state: tauri::State<'_, AppState>, +) -> Result, 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(()) +} diff --git a/crates/tauri-app/src-tauri/src/pds_client.rs b/crates/tauri-app/src-tauri/src/pds_client.rs index dadee8f..47689f0 100644 --- a/crates/tauri-app/src-tauri/src/pds_client.rs +++ b/crates/tauri-app/src-tauri/src/pds_client.rs @@ -385,3 +385,64 @@ pub struct UploadedBlobRef { #[serde(rename = "$link")] pub link: String, } + /// `POST /xrpc/com.atproto.repo.getRecord?repo=&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> { + 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 { + 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)) + } diff --git a/crates/tauri-app/src/App.svelte b/crates/tauri-app/src/App.svelte index ec34535..e76fa39 100644 --- a/crates/tauri-app/src/App.svelte +++ b/crates/tauri-app/src/App.svelte @@ -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 + /// `` and the avatar/handle buttons in + /// the post header. The actual profile fetch happens inside + /// `` on mount. + function openUserProfile(handle: string) { + selectedHandle = handle; + view = "user"; + threadRoot = null; + threadParent = null; + } function closeThread() { threadRoot = null; threadParent = null; @@ -462,14 +521,14 @@
err: {threadError}
{:else if threadRoot} {#if threadParent && threadParent.uri !== threadRoot.uri} -
+
{/if} - + {/if} {/if} {#each userPosts as p (p.uri)} - + {/each} {#if timelineCursor}
@@ -487,6 +546,17 @@ ⌘↵ to post
+ {:else if view === "user"} +
+ $ + // profile — + @{selectedHandle} +
+ {:else if view === "profile"}
$ @@ -502,8 +572,81 @@
{displayHandle(profile.handle)}
{profile.did}
+ {#if profile.avatar_cid} + + {/if}
+ {#if profile.description} +
{profile.description}
+ {:else} +
+ // no profile yet — click "edit profile" to set one up. +
+ {/if} + +
+ +
+ {#if editingProfile} +
+ + +
+ {#if editProfileAvatarCid} + cid: {editProfileAvatarCid.slice(0, 10)}… + + {:else} + no avatar + {/if} + +
+
+ +
+
+ {/if} + +
+ {profile.post_count} posts + {profile.followers} followers + {profile.following} following +
+
+ {timeAgo(post.created_at)} cid: {shortCid(post.cid)} {shortDid(post.did)} @@ -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); } diff --git a/crates/tauri-app/src/lib/components/UserProfileView.svelte b/crates/tauri-app/src/lib/components/UserProfileView.svelte new file mode 100644 index 0000000..33a52f4 --- /dev/null +++ b/crates/tauri-app/src/lib/components/UserProfileView.svelte @@ -0,0 +1,334 @@ + + + + +