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,
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<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")]
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))
}