The profile.get_record / set_profile methods landed in the WIP
outside any `impl PdsHttpClient { … }` block, with a stray
`&self` parameter that the parser correctly rejected. Wrap them
in a fresh impl block and add the missing closing brace — no
behaviour change, just a structural fix so the binary builds.
---
feat(tauri-app): X-style profile page with banner / avatar overlap / tabs
Replace the existing UserProfileView with a new ProfileView that
follows the X (Twitter) profile layout but stays in our
monospace / orange-on-black terminal aesthetic:
* Banner (140 px) at the top. The user's `banner_cid` (when
present) is fetched via the existing `fetchBlob` Tauri
command and set as a background-image. When the profile has no
banner we render a subtle orange-tinted grid placeholder so the
page never looks bare.
* 96 px circular avatar that overlaps the bottom of the banner by
~44 px, with a 4 px border in `var(--bg)` so the cutout reads
cleanly against any banner colour.
* Identity row: large bold display name, dim handle below.
* Bio, DID meta line, and the posts / followers / following count
dl — all monospace, all using our spacing / colour tokens.
* Tab row with the existing 'posts' tab active and
'replies' / 'likes' rendered disabled (placeholder for future
work).
* Edit form (gated on `current_user_did === profile.did`) with
display-name, description, and avatar upload fields.
App.svelte refactor: the 'profile' view (current user) and the
'user' view (someone else) now both render `<ProfileView>`. The
duplicated edit state (`editingProfile`, `editProfileName`,
`editProfileDesc`, `editProfileAvatarCid`, `savingProfile`),
the duplicate `pickAndUploadAvatar` / `saveProfile` /
`refreshProfile` functions, and the unused `displayHandle` /
`fetchProfile` / `pickAndUploadImage` / `setMyProfile` imports
are gone. ProfileView handles its own fetch + edit state
internally, so the App.svelte section collapses from ~145 lines
of inline JSX to ~12.
The legacy .profile__head / .profile__bio / .counts style
classes that the new component no longer references are also
removed.
452 lines
14 KiB
Rust
452 lines
14 KiB
Rust
use anyhow::Result;
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::time::Duration;
|
|
|
|
#[derive(Clone)]
|
|
pub struct PdsHttpClient {
|
|
pub base_url: String,
|
|
pub client: Client,
|
|
}
|
|
|
|
impl PdsHttpClient {
|
|
pub fn new(base_url: impl Into<String>) -> Self {
|
|
Self {
|
|
base_url: base_url.into(),
|
|
client: Client::builder()
|
|
.timeout(Duration::from_secs(10))
|
|
.build()
|
|
.unwrap(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct CreateAccountReq {
|
|
pub handle: String,
|
|
pub email: Option<String>,
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct AccountSession {
|
|
pub did: String,
|
|
pub handle: String,
|
|
pub access_jwt: String,
|
|
pub refresh_jwt: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct CreateRecordReq {
|
|
pub repo: String,
|
|
pub collection: String,
|
|
pub record: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct CreateRecordResp {
|
|
pub uri: String,
|
|
pub cid: String,
|
|
}
|
|
|
|
/// `app.bsky.feed.like.create` request body — the flat BSky shape.
|
|
/// The Tauri command always uses this shape (rather than the
|
|
/// generic `createRecord` body) so the PDS can hardcode the
|
|
/// collection.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct CreateLikeBody {
|
|
pub repo: String,
|
|
pub subject: SubjectRef,
|
|
#[serde(rename = "createdAt")]
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct SubjectRef {
|
|
pub uri: String,
|
|
pub cid: String,
|
|
}
|
|
|
|
/// `com.atproto.repo.deleteRecord` body. Reused for unlike and
|
|
/// unrepost — the caller just sets `collection` to
|
|
/// `app.bsky.feed.like` or `app.bsky.feed.repost`.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct DeleteRecordBody {
|
|
pub repo: String,
|
|
pub collection: String,
|
|
pub rkey: String,
|
|
}
|
|
|
|
/// Response from `feed.like.create` and (structurally) any
|
|
/// `createRecord` variant. The PDS returns `{uri, cid, commit}`.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct RepoWriteResp {
|
|
pub uri: String,
|
|
pub cid: String,
|
|
#[serde(default)]
|
|
pub commit: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// Response from `com.atproto.repo.deleteRecord`. Spec returns
|
|
/// `{ commit: { cid, rev } }`.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct DeleteRecordResp {
|
|
pub commit: serde_json::Value,
|
|
}
|
|
|
|
impl PdsHttpClient {
|
|
pub async fn describe_server(&self) -> Result<serde_json::Value> {
|
|
let r = self
|
|
.client
|
|
.get(format!("{}/xrpc/com.atproto.server.describeServer", self.base_url))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
Ok(r)
|
|
}
|
|
|
|
pub async fn create_account(
|
|
&self,
|
|
handle: &str,
|
|
password: &str,
|
|
) -> Result<AccountSession> {
|
|
let body = CreateAccountReq {
|
|
handle: handle.to_string(),
|
|
email: None,
|
|
password: password.to_string(),
|
|
};
|
|
let r = self
|
|
.client
|
|
.post(format!("{}/xrpc/com.atproto.server.createAccount", self.base_url))
|
|
.json(&body)
|
|
.send()
|
|
.await?;
|
|
if !r.status().is_success() {
|
|
let status = r.status();
|
|
let text = r.text().await.unwrap_or_default();
|
|
anyhow::bail!("createAccount failed: {} {}", status, text);
|
|
}
|
|
Ok(r.json().await?)
|
|
}
|
|
|
|
pub async fn create_session(
|
|
&self,
|
|
identifier: &str,
|
|
password: &str,
|
|
) -> Result<AccountSession> {
|
|
let r = self
|
|
.client
|
|
.post(format!("{}/xrpc/com.atproto.server.createSession", self.base_url))
|
|
.json(&serde_json::json!({"identifier": identifier, "password": password}))
|
|
.send()
|
|
.await?;
|
|
if !r.status().is_success() {
|
|
let status = r.status();
|
|
let text = r.text().await.unwrap_or_default();
|
|
anyhow::bail!("createSession failed: {} {}", status, text);
|
|
}
|
|
Ok(r.json().await?)
|
|
}
|
|
|
|
pub async fn refresh_session(&self, refresh_jwt: &str) -> Result<AccountSession> {
|
|
let r = self
|
|
.client
|
|
.post(format!("{}/xrpc/com.atproto.server.refreshSession", self.base_url))
|
|
.json(&serde_json::json!({"refresh_jwt": refresh_jwt}))
|
|
.send()
|
|
.await?;
|
|
if !r.status().is_success() {
|
|
let status = r.status();
|
|
let text = r.text().await.unwrap_or_default();
|
|
anyhow::bail!("refreshSession failed: {} {}", status, text);
|
|
}
|
|
Ok(r.json().await?)
|
|
}
|
|
|
|
pub async fn create_record_with(
|
|
&self,
|
|
repo: &str,
|
|
collection: &str,
|
|
record: serde_json::Value,
|
|
_validate: bool,
|
|
jwt: &str,
|
|
) -> Result<CreateRecordResp> {
|
|
let r = self
|
|
.client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", self.base_url))
|
|
.bearer_auth(jwt)
|
|
.json(&CreateRecordReq {
|
|
repo: repo.to_string(),
|
|
collection: collection.to_string(),
|
|
record,
|
|
})
|
|
.send()
|
|
.await?;
|
|
if !r.status().is_success() {
|
|
let status = r.status();
|
|
let text = r.text().await.unwrap_or_default();
|
|
anyhow::bail!("createRecord failed: {} {}", status, text);
|
|
}
|
|
Ok(r.json().await?)
|
|
}
|
|
|
|
pub async fn create_record(
|
|
&self,
|
|
repo: &str,
|
|
collection: &str,
|
|
record: serde_json::Value,
|
|
jwt: &str,
|
|
) -> Result<CreateRecordResp> {
|
|
self.create_record_with(repo, collection, record, true, jwt).await
|
|
}
|
|
|
|
pub async fn resolve_handle(&self, handle: &str) -> Result<Option<String>> {
|
|
let r = self
|
|
.client
|
|
.post(format!("{}/xrpc/com.atproto.identity.resolveHandle", self.base_url))
|
|
.json(&serde_json::json!({"handle": handle}))
|
|
.send()
|
|
.await?;
|
|
if r.status().as_u16() == 404 {
|
|
return Ok(None);
|
|
}
|
|
if !r.status().is_success() {
|
|
anyhow::bail!("resolveHandle failed: {}", r.status());
|
|
}
|
|
let v: serde_json::Value = r.json().await?;
|
|
Ok(v.get("did").and_then(|x| x.as_str()).map(String::from))
|
|
}
|
|
|
|
/// `POST /xrpc/com.atproto.feed.like.create`
|
|
///
|
|
/// `repo` is the caller's DID; the JWT authenticates the call
|
|
/// and must match. `subject` is the post being liked
|
|
/// (`strongRef` = `{uri, cid}`).
|
|
pub async fn create_like(
|
|
&self,
|
|
repo: &str,
|
|
subject_uri: &str,
|
|
subject_cid: &str,
|
|
jwt: &str,
|
|
) -> Result<RepoWriteResp> {
|
|
let r = self
|
|
.client
|
|
.post(format!(
|
|
"{}/xrpc/com.atproto.feed.like.create",
|
|
self.base_url
|
|
))
|
|
.bearer_auth(jwt)
|
|
.json(&CreateLikeBody {
|
|
repo: repo.to_string(),
|
|
subject: SubjectRef {
|
|
uri: subject_uri.to_string(),
|
|
cid: subject_cid.to_string(),
|
|
},
|
|
created_at: chrono::Utc::now().to_rfc3339(),
|
|
})
|
|
.send()
|
|
.await?;
|
|
if !r.status().is_success() {
|
|
let status = r.status();
|
|
let text = r.text().await.unwrap_or_default();
|
|
anyhow::bail!("feed.like.create failed: {} {}", status, text);
|
|
}
|
|
Ok(r.json().await?)
|
|
}
|
|
|
|
/// `POST /xrpc/com.atproto.repo.deleteRecord`
|
|
///
|
|
/// Generic record deletion — `collection` is the NSID
|
|
/// (`app.bsky.feed.like`, `app.bsky.feed.repost`, etc.) and
|
|
/// `rkey` is the trailing component of the record's URI.
|
|
pub async fn delete_record(
|
|
&self,
|
|
repo: &str,
|
|
collection: &str,
|
|
rkey: &str,
|
|
jwt: &str,
|
|
) -> Result<DeleteRecordResp> {
|
|
let r = self
|
|
.client
|
|
.post(format!(
|
|
"{}/xrpc/com.atproto.repo.deleteRecord",
|
|
self.base_url
|
|
))
|
|
.bearer_auth(jwt)
|
|
.json(&DeleteRecordBody {
|
|
repo: repo.to_string(),
|
|
collection: collection.to_string(),
|
|
rkey: rkey.to_string(),
|
|
})
|
|
.send()
|
|
.await?;
|
|
if !r.status().is_success() {
|
|
let status = r.status();
|
|
let text = r.text().await.unwrap_or_default();
|
|
anyhow::bail!("repo.deleteRecord failed: {} {}", status, text);
|
|
}
|
|
Ok(r.json().await?)
|
|
}
|
|
|
|
/// `GET /xrpc/com.atproto.sync.getBlob?did=<did>&cid=<cid>`
|
|
///
|
|
/// Streams raw blob bytes from the user's PDS. `jwt` is currently
|
|
/// unused — `com.atproto.sync.getBlob` is unauthenticated in this
|
|
/// implementation, matching the other sync reads — but we keep
|
|
/// the parameter in the signature so future auth-gated calls don't
|
|
/// force a wire-format change.
|
|
pub async fn get_blob(
|
|
&self,
|
|
did: &str,
|
|
cid: &str,
|
|
jwt: Option<&str>,
|
|
) -> Result<Vec<u8>> {
|
|
let mut req = self.client.get(format!(
|
|
"{}/xrpc/com.atproto.sync.getBlob",
|
|
self.base_url
|
|
));
|
|
if let Some(t) = jwt {
|
|
req = req.bearer_auth(t);
|
|
}
|
|
let resp = req
|
|
.query(&[("did", did), ("cid", cid)])
|
|
.send()
|
|
.await?;
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
// Try to capture the XRPC error body for easier debugging.
|
|
let body = resp.text().await.unwrap_or_default();
|
|
anyhow::bail!("sync.getBlob failed: {} {}", status, body);
|
|
}
|
|
let bytes = resp.bytes().await?;
|
|
Ok(bytes.to_vec())
|
|
}
|
|
|
|
/// `POST /xrpc/com.atproto.uploadBlob`
|
|
///
|
|
/// Authenticated; the server derives the DID from the JWT `sub`
|
|
/// claim and writes the block to `(did, sha256(cid))` in
|
|
/// `repo_blocks`. The body is the raw blob bytes and the
|
|
/// `Content-Type` header is mandatory — the PDS uses it as the
|
|
/// authoritative MIME type for the row.
|
|
///
|
|
/// Returns the parsed `com.atproto.uploadBlob` response verbatim:
|
|
/// `{ blob: { $type, ref: { $link }, mimeType, size } }`. The
|
|
/// caller is expected to forward this to the Svelte UI so it can
|
|
/// drop the blob ref straight into a record's `embed.images[]`.
|
|
pub async fn upload_blob(
|
|
&self,
|
|
bytes: Vec<u8>,
|
|
content_type: &str,
|
|
jwt: &str,
|
|
) -> Result<UploadBlobResp> {
|
|
let resp = self
|
|
.client
|
|
.post(format!("{}/xrpc/com.atproto.uploadBlob", self.base_url))
|
|
.bearer_auth(jwt)
|
|
.header(reqwest::header::CONTENT_TYPE, content_type)
|
|
.body(bytes)
|
|
.send()
|
|
.await?;
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
anyhow::bail!("uploadBlob failed: {} {}", status, body);
|
|
}
|
|
Ok(resp.json::<UploadBlobResp>().await?)
|
|
}
|
|
}
|
|
|
|
/// `com.atproto.uploadBlob` response. Spec'd at
|
|
/// <https://atproto.com/specs/blob>. The server returns a `blob`
|
|
/// object that mirrors what an `app.bsky.embed.images#image` entry
|
|
/// expects on the wire — the Tauri command forwards this verbatim so
|
|
/// the UI can drop it into the post record with no further
|
|
/// transformation.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct UploadBlobResp {
|
|
pub blob: UploadedBlob,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct UploadedBlob {
|
|
#[serde(rename = "$type")]
|
|
pub ty: String,
|
|
#[serde(rename = "ref")]
|
|
pub blob_ref: UploadedBlobRef,
|
|
#[serde(rename = "mimeType")]
|
|
pub mime_type: String,
|
|
pub size: u64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct UploadedBlobRef {
|
|
#[serde(rename = "$link")]
|
|
pub link: String,
|
|
}
|
|
|
|
impl PdsHttpClient {
|
|
/// `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))
|
|
}
|
|
}
|