maarcadetweet: initial commit
AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
//! Thin HTTP client the Tauri commands use to talk to the AppView.
|
||||
//!
|
||||
//! All four methods return parsed JSON or a stringified error that the
|
||||
//! Tauri command layer surfaces to the Svelte frontend as the
|
||||
//! `Result::Err` payload.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
/// One post as returned by the AppView read API.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PostDto {
|
||||
pub uri: String,
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub rkey: String,
|
||||
pub collection: String,
|
||||
pub text: String,
|
||||
pub cid: String,
|
||||
#[serde(default)]
|
||||
pub parent_uri: Option<String>,
|
||||
#[serde(default)]
|
||||
pub root_uri: Option<String>,
|
||||
#[serde(default)]
|
||||
pub embed: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub langs: Vec<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimelineResponse {
|
||||
pub posts: Vec<PostDto>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileResponse {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub posts: Vec<PostDto>,
|
||||
pub followers: i64,
|
||||
pub following: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResponse {
|
||||
pub posts: Vec<PostDto>,
|
||||
pub q: String,
|
||||
}
|
||||
|
||||
/// `GET /api/post/{uri}` response. The server hands back the post plus
|
||||
/// its `parent_uri` and `root_uri` rows in one round trip so the UI can
|
||||
/// expand a thread without three sequential fetches.
|
||||
///
|
||||
/// `like_count` and `repost_count` are included when the server
|
||||
/// resolves a real post; they're `None` for the "not in index"
|
||||
/// sentinel response (where `post` is null). The AppView has no
|
||||
/// auth yet, so we don't get `viewer_liked` / `viewer_reposted`
|
||||
/// from the server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadResponse {
|
||||
pub post: Option<PostDto>,
|
||||
pub thread: ThreadView,
|
||||
#[serde(default)]
|
||||
pub like_count: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub repost_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadView {
|
||||
pub parent: Option<PostDto>,
|
||||
pub root: Option<PostDto>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppViewClient {
|
||||
pub base_url: String,
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl AppViewClient {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/timeline/home?did=&limit=&cursor=`
|
||||
pub async fn fetch_timeline(
|
||||
&self,
|
||||
did: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<TimelineResponse> {
|
||||
let mut req = self
|
||||
.client
|
||||
.get(format!("{}/api/timeline/home", self.base_url))
|
||||
.query(&[("did", did), ("limit", &limit.to_string())]);
|
||||
if let Some(c) = cursor {
|
||||
req = req.query(&[("cursor", c)]);
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send timeline request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: timeline home returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<TimelineResponse>()
|
||||
.await
|
||||
.context("appview: timeline home JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/profile/<handle>` — accepts `@handle` or `handle`, and
|
||||
/// accepts a bare DID (the path param is opaque to the server).
|
||||
/// For DIDs containing `:`, prefer [`Self::fetch_profile_by_did`]
|
||||
/// which uses the query-param form.
|
||||
pub async fn fetch_profile(&self, handle: &str) -> Result<ProfileResponse> {
|
||||
let trimmed = handle.trim_start_matches('@');
|
||||
// If it looks like a DID, prefer the query-param form so the
|
||||
// colons don't have to be URL-encoded in the path.
|
||||
if trimmed.starts_with("did:") {
|
||||
return self.fetch_profile_by_did(trimmed).await;
|
||||
}
|
||||
let url = format!("{}/api/profile/{}", self.base_url, trimmed);
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send profile request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: profile returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<ProfileResponse>()
|
||||
.await
|
||||
.context("appview: profile JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/profile?did=...` — safest way to fetch a profile by DID.
|
||||
pub async fn fetch_profile_by_did(&self, did: &str) -> Result<ProfileResponse> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/profile", self.base_url))
|
||||
.query(&[("did", did)])
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send profile-by-did request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: profile-by-did returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<ProfileResponse>()
|
||||
.await
|
||||
.context("appview: profile-by-did JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/search?q=&limit=`
|
||||
pub async fn fetch_search(&self, q: &str, limit: u32) -> Result<SearchResponse> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/search", self.base_url))
|
||||
.query(&[("q", q), ("limit", &limit.to_string())])
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send search request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: search returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<SearchResponse>()
|
||||
.await
|
||||
.context("appview: search JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/post/{uri}` — thread hydration in one round trip.
|
||||
///
|
||||
/// `uri` is the verbatim `at://...` URI. We can't paste it directly
|
||||
/// into the path because the `://` looks like a scheme separator
|
||||
/// to the URL parser; instead we percent-encode the whole URI and
|
||||
/// append it as a single path segment. The server's
|
||||
/// `axum::extract::Path<String>` decodes it back to the verbatim
|
||||
/// string.
|
||||
pub async fn fetch_post(&self, uri: &str) -> Result<ThreadResponse> {
|
||||
let encoded = percent_encode_path(uri);
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/post/{}", self.base_url, encoded))
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send post request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: post returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<ThreadResponse>()
|
||||
.await
|
||||
.context("appview: post JSON parse")
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode every byte of `s` for use as a URL path segment.
|
||||
/// `axum`'s path extractor will decode it back. We use this rather
|
||||
/// than `url::Url::parse(...).path_segments()` because AT-Protocol
|
||||
/// URIs contain `://` which the URL parser mistakes for a scheme.
|
||||
fn percent_encode_path(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() * 3);
|
||||
for b in s.bytes() {
|
||||
// RFC 3986 unreserved characters plus a few safe ones we want
|
||||
// to leave alone. Encode everything else to be conservative.
|
||||
let is_unreserved = matches!(
|
||||
b,
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~'
|
||||
);
|
||||
if is_unreserved {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{:02X}", b));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percent_encode_path_at_uri() {
|
||||
let s = "at://did:plc:abc/app.twi.post/3k2";
|
||||
let e = percent_encode_path(s);
|
||||
assert_eq!(
|
||||
e,
|
||||
"at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.twi.post%2F3k2"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user