Compare commits
18
Commits
a5b1c889dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baeb87214b | ||
|
|
48ee25f217 | ||
|
|
eb62fd5654 | ||
|
|
a98f891e4f | ||
|
|
4c71b76763 | ||
|
|
aba84cbaa9 | ||
|
|
e6aa28ca4c | ||
|
|
e4bcfbfa83 | ||
|
|
6ebf17b493 | ||
|
|
ffee5c6685 | ||
|
|
59a3cb02dd | ||
|
|
3064d3d8b7 | ||
|
|
3aa5d5c0e3 | ||
|
|
391448a845 | ||
|
|
caa30fa65e | ||
|
|
fd352180a1 | ||
|
|
3302bca494 | ||
|
|
b8da282525 |
Generated
+1
@@ -51,6 +51,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"futures",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"serde",
|
||||
|
||||
@@ -57,28 +57,30 @@ cargo run -p appview
|
||||
| Phase | Stand |
|
||||
|-------|-------|
|
||||
| 0 Foundation, Workspace, Migrations, Lexicon, Crypto | ✅ done |
|
||||
| 1 Identity (PLC-Ops vollständig signieren) | ⏳ TODO (JWT-PEM fehlt) |
|
||||
| 2 MST + Repo (Spec-konforme CBOR-Encoding) | ⏳ Skelett steht |
|
||||
| 3 PDS-Server (com.atproto.* XRPC) | ⏳ Skelett, nur Healthz |
|
||||
| 4 AppView-Foundation (Jetstream-Index) | ⏳ Skelett |
|
||||
| 5 AppView-REST-API | ⏳ Stubs |
|
||||
| 6 Tauri-UI-Logik an Backend koppeln | ⏳ Stubs |
|
||||
| 7 Polish (Tray, Notifications, Auto-Update) | ⏳ |
|
||||
| 1 Identity (PLC-Ops vollständig signieren) | ✅ done — `did:plc:` deterministisch aus signed op CID |
|
||||
| 2 MST + Repo (Spec-konforme CBOR-Encoding) | ✅ done — `encode_key` = `base64url(sha256(raw_key))` per atproto-Spec, `split_around`/`wrap_with_split` threaden den recursive right_sub korrekt als `k_tree` weiter. 27 MST + 13 Repo + 4 Commit Tests grün. |
|
||||
| 3 PDS-Server (com.atproto.* XRPC) | ✅ done — createAccount/Session/Refresh, createRecord/deleteRecord, like/repost, follow |
|
||||
| 4 AppView-Foundation (Jetstream-Index) | ✅ done — Jetstream-Indexer + identity-Event-Backfill + PLC-handle-sync-Worker |
|
||||
| 5 AppView-REST-API | ✅ done — timeline, profile (by-did + by-handle), search, post-by-uri, thread-context |
|
||||
| 6 Tauri-UI-Logik an Backend koppeln | ✅ done — LoginScreen, NavRail, PostCard, ComposeBox, Profile/Compose/Search/Settings-Views |
|
||||
| 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev deaktiviert (siehe `_comment` in `tauri.conf.json` für Production-Setup) |
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
running 12 tests (at-crypto)
|
||||
test result: ok. 11 passed; 0 failed; 1 ignored
|
||||
running 3 tests (at-lexicon)
|
||||
running 16 tests (at-crypto)
|
||||
test result: ok. 16 passed; 0 failed; 0 ignored
|
||||
running 3 tests (at-lexicon)
|
||||
test result: ok. 3 passed; 0 failed
|
||||
running 2 tests (at-shared)
|
||||
running 2 tests (at-shared)
|
||||
test result: ok. 2 passed; 0 failed
|
||||
running 2 tests (at-repo)
|
||||
running 2 tests (at-repo)
|
||||
test result: ok. 2 passed; 0 failed
|
||||
running 4 tests (at-crypto plc_op — Phase 1)
|
||||
test result: ok. 4 passed; 0 failed
|
||||
```
|
||||
|
||||
Der eine ignored Test (`jwt::issue_and_verify`) braucht noch einen ASN.1-SEC1-PEM-Encoder — geplant für Phase 1.
|
||||
Der zuvor als "geplant für Phase 1" markierte `jwt::issue_and_verify`-Test wurde zwischenzeitlich grün gezogen (P-256-PKCS#8-PEM-Encoder ist über `p256::pkcs8::EncodePrivateKey` da).
|
||||
|
||||
## Design
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ at-identity = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging", "tls12"] }
|
||||
base64 = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
//! 2. For each DID, dispatches by method:
|
||||
//! * `did:plc:` → [`HandleSyncWorker::plc_resolver`]
|
||||
//! * `did:web:` → [`HandleSyncWorker::web_resolver`]
|
||||
//! * anything else (e.g. `did:key:`) → skipped
|
||||
//! * anything else (e.g. `did:garbage:`) → skipped
|
||||
//! A resolver returning `Ok(None)` counts as `skipped`, not `failed`.
|
||||
//! Before any of these the local PDS is consulted, so a `did:key:`
|
||||
//! user on this PDS gets resolved without dialing plc.directory.
|
||||
//! 3. `UPDATE posts SET handle = $1 WHERE did = $2 AND handle = ''` so
|
||||
//! concurrent syncs (or the `/internal/ingest-commit` path, which can
|
||||
//! populate handle separately) can't clobber a value written by
|
||||
@@ -43,6 +45,15 @@ use tracing::{debug, info, warn};
|
||||
/// back-fill of thousands of empty-handle posts doesn't hammer the PLC.
|
||||
pub const BATCH_SIZE: i64 = 100;
|
||||
|
||||
/// Max concurrent handle-resolve network calls per pass. Each
|
||||
/// DID in a batch triggers a `POST /xrpc/com.atproto.identity
|
||||
/// .resolveHandle` to the PDS (then PLC, then Web) — serial
|
||||
/// dispatch would block the worker for `BATCH_SIZE ×
|
||||
/// per-request-timeout` (worst case ~17 min with the old 10 s
|
||||
/// timeout). Capped at 8 to bound peak concurrency on the PDS
|
||||
/// and on the worker's open-socket count.
|
||||
const DISPATCH_CONCURRENCY: usize = 8;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SyncReport {
|
||||
/// Rows whose `handle` column was newly populated this pass.
|
||||
@@ -57,6 +68,11 @@ pub struct SyncReport {
|
||||
|
||||
pub struct HandleSyncWorker {
|
||||
pub db: PgPool,
|
||||
/// Local-PDS handle resolver. Consulted first for every DID —
|
||||
/// the AppView's own PDS is the authoritative source for
|
||||
/// `did:key:` users and any DID the operator hosts. A 404 from
|
||||
/// the PDS falls through to the public resolvers below.
|
||||
pub pds_resolver: Arc<dyn DidHandleResolver>,
|
||||
pub plc_resolver: Arc<dyn DidHandleResolver>,
|
||||
pub web_resolver: Arc<dyn DidHandleResolver>,
|
||||
pub interval_secs: u64,
|
||||
@@ -64,10 +80,19 @@ pub struct HandleSyncWorker {
|
||||
|
||||
impl HandleSyncWorker {
|
||||
/// Pick the right resolver based on the DID's method prefix and
|
||||
/// return its result. Unknown methods (`did:key:`, etc.) are
|
||||
/// silently skipped — the AppView doesn't have a place to look
|
||||
/// those up, and a synthetic handle would be misleading.
|
||||
/// return its result. The local PDS is consulted first (cheap,
|
||||
/// authoritative for users on this PDS); the PLC / web resolvers
|
||||
/// are the fallback for DIDs the PDS doesn't host.
|
||||
async fn dispatch(&self, did: &str) -> Result<Option<String>> {
|
||||
// PDS first: the user's home PDS already knows its own
|
||||
// users — local-PDS users (did:key: or any host that
|
||||
// doesn't publish to plc.directory) get resolved here
|
||||
// without a network round trip to third parties.
|
||||
if let Some(h) = self.pds_resolver.resolve_handle(did).await? {
|
||||
if !h.is_empty() {
|
||||
return Ok(Some(h));
|
||||
}
|
||||
}
|
||||
if did.starts_with("did:plc:") {
|
||||
self.plc_resolver.resolve_handle(did).await
|
||||
} else if did.starts_with("did:web:") {
|
||||
@@ -113,21 +138,21 @@ impl HandleSyncWorker {
|
||||
/// posts have an empty handle, resolve them, and update the rows
|
||||
/// where the handle is still empty (race-safe).
|
||||
///
|
||||
/// **SQL-level filter**: we exclude `did:key:` entirely because
|
||||
/// there's no resolver path for them — the PLC directory and the
|
||||
/// `did:web:` HTTPS resolver both reject non-`did:plc:` /
|
||||
/// non-`did:web:` DIDs with `Ok(None)`. Previously the worker
|
||||
/// picked via `ORDER BY did LIMIT 100`, but lexicographically
|
||||
/// `did:key:` sorts before `did:plc:` / `did:web:`, so the worker
|
||||
/// would process the same 100 `did:key:` rows every 300 s and
|
||||
/// never reach any resolvable DID. Filtering at SQL time makes
|
||||
/// every batch contribute real work.
|
||||
/// Unresolvable DIDs (e.g. `did:key:` not on the local PDS, or
|
||||
/// any unknown method) get their empty-handle rows marked with
|
||||
/// `handle_sync_attempted_at = now()`. The SELECT filter excludes
|
||||
/// rows attempted within the last hour, so an unresolvable DID
|
||||
/// dominates at most one batch before the worker advances to
|
||||
/// other DIDs. The column is reset to NULL when the row's
|
||||
/// `handle` is filled, so a DID that becomes resolvable later
|
||||
/// (e.g. the user joins the local PDS) gets re-attempted.
|
||||
pub async fn run_once(&self) -> Result<SyncReport> {
|
||||
let dids: Vec<(String,)> = sqlx::query_as(
|
||||
r#"SELECT DISTINCT did
|
||||
FROM posts
|
||||
WHERE handle = ''
|
||||
AND (did LIKE 'did:plc:%' OR did LIKE 'did:web:%')
|
||||
AND (handle_sync_attempted_at IS NULL
|
||||
OR handle_sync_attempted_at < now() - interval '1 hour')
|
||||
ORDER BY did
|
||||
LIMIT $1"#,
|
||||
)
|
||||
@@ -140,15 +165,34 @@ impl HandleSyncWorker {
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
for (did,) in dids {
|
||||
match self.dispatch(&did).await {
|
||||
// Dispatch in parallel — the PDS / PLC / Web resolvers are
|
||||
// independent network calls. Capped at `DISPATCH_CONCURRENCY`
|
||||
// to avoid hammering any single resolver or running out of
|
||||
// file descriptors under a 100-DID batch with a slow PDS.
|
||||
// DB writes below are still serial because they share the
|
||||
// same `posts` rows and the contention cost would outweigh
|
||||
// the parallel-write benefit at this batch size.
|
||||
use futures::stream::{self, StreamExt};
|
||||
let dispatch_results: Vec<(String, anyhow::Result<Option<String>>)> = stream::iter(dids)
|
||||
.map(|(did,)| async move {
|
||||
let r = self.dispatch(&did).await;
|
||||
(did, r)
|
||||
})
|
||||
.buffer_unordered(DISPATCH_CONCURRENCY)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
for (did, result) in dispatch_results {
|
||||
match result {
|
||||
Ok(Some(handle)) => {
|
||||
if handle.is_empty() {
|
||||
report.skipped += 1;
|
||||
mark_attempted(&self.db, &did).await?;
|
||||
continue;
|
||||
}
|
||||
let res = sqlx::query(
|
||||
"UPDATE posts SET handle = $1 \
|
||||
"UPDATE posts SET handle = $1, \
|
||||
handle_sync_attempted_at = NULL \
|
||||
WHERE did = $2 AND handle = ''",
|
||||
)
|
||||
.bind(&handle)
|
||||
@@ -165,10 +209,19 @@ impl HandleSyncWorker {
|
||||
}
|
||||
Ok(None) => {
|
||||
report.skipped += 1;
|
||||
mark_attempted(&self.db, &did).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(did = %did, error = %e, "handle resolve failed");
|
||||
report.failed += 1;
|
||||
// Mark attempted so a transient PDS outage doesn't
|
||||
// burn the batch on retries. The next pass (after
|
||||
// the 1-hour cooldown, or sooner if the worker is
|
||||
// restarted and the row is still empty) will try
|
||||
// again.
|
||||
if let Err(e) = mark_attempted(&self.db, &did).await {
|
||||
warn!(did = %did, error = %e, "handle_sync mark_attempted failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +229,20 @@ impl HandleSyncWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp `handle_sync_attempted_at = now()` on every empty-handle
|
||||
/// row for `did`. Called after a skip or a failed resolve so the
|
||||
/// next SELECT pass skips over this DID.
|
||||
async fn mark_attempted(db: &PgPool, did: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE posts SET handle_sync_attempted_at = now() \
|
||||
WHERE did = $1 AND handle = ''",
|
||||
)
|
||||
.bind(did)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -219,6 +286,7 @@ mod tests {
|
||||
fn worker_with(db: PgPool, stub: Arc<dyn DidHandleResolver>) -> HandleSyncWorker {
|
||||
HandleSyncWorker {
|
||||
db,
|
||||
pds_resolver: Arc::clone(&stub),
|
||||
plc_resolver: Arc::clone(&stub),
|
||||
web_resolver: Arc::clone(&stub),
|
||||
interval_secs: 999,
|
||||
@@ -383,6 +451,7 @@ mod tests {
|
||||
|
||||
let worker = HandleSyncWorker {
|
||||
db: db.clone(),
|
||||
pds_resolver: Arc::clone(&resolver),
|
||||
plc_resolver: Arc::clone(&resolver),
|
||||
web_resolver: Arc::clone(&resolver),
|
||||
interval_secs: 999,
|
||||
@@ -440,6 +509,7 @@ mod tests {
|
||||
|
||||
let worker = HandleSyncWorker {
|
||||
db: db.clone(),
|
||||
pds_resolver: Arc::clone(&resolver),
|
||||
plc_resolver: Arc::clone(&resolver),
|
||||
web_resolver: Arc::clone(&resolver),
|
||||
interval_secs: 999,
|
||||
@@ -496,6 +566,7 @@ mod tests {
|
||||
|
||||
let worker = HandleSyncWorker {
|
||||
db: db.clone(),
|
||||
pds_resolver: Arc::clone(&plc),
|
||||
plc_resolver: plc,
|
||||
web_resolver: web,
|
||||
interval_secs: 999,
|
||||
@@ -554,6 +625,7 @@ mod tests {
|
||||
|
||||
let worker = HandleSyncWorker {
|
||||
db: db.clone(),
|
||||
pds_resolver: Arc::clone(&plc_arc),
|
||||
plc_resolver: plc_arc,
|
||||
web_resolver: web_arc,
|
||||
interval_secs: 999,
|
||||
|
||||
+410
-16
@@ -189,7 +189,7 @@ impl Type<Postgres> for EmbedColumn {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PostRow {
|
||||
pub struct PostRow {
|
||||
pub uri: String,
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
@@ -202,6 +202,11 @@ pub struct PostRow {
|
||||
pub embed: Option<Value>,
|
||||
pub langs: Option<Vec<String>>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
/// Resolved author avatar CID from the `profiles` cache. Populated
|
||||
/// at `upsert_post` time so the PostCard can render an avatar
|
||||
/// without a per-row PDS round trip. NULL for users whose profile
|
||||
/// hasn't been pushed yet.
|
||||
pub avatar_cid: Option<String>,
|
||||
}
|
||||
|
||||
impl PostRow {
|
||||
@@ -276,30 +281,57 @@ impl PostRow {
|
||||
embed,
|
||||
langs,
|
||||
created_at,
|
||||
// Avatar CID is populated later by the upsert path via a
|
||||
// `SELECT avatar_cid FROM profiles WHERE did = $1` lookup,
|
||||
// so a freshly indexed post starts at None. (The lookup
|
||||
// happens in `upsert_post_with_avatar` below.)
|
||||
avatar_cid: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert or update a post row keyed by URI. Idempotent.
|
||||
///
|
||||
/// `row.avatar_cid` is filled in-place with the current profile-avatar
|
||||
/// CID for the row's author (from the `profiles` cache, NULL if the
|
||||
/// profile hasn't been pushed yet). The ON CONFLICT clause uses
|
||||
/// `COALESCE(EXCLUDED, posts)` so a backfill on a re-indexed post
|
||||
/// won't overwrite an avatar we already had.
|
||||
///
|
||||
/// IMPORTANT: `indexed_at` is NOT touched on conflict. We deliberately
|
||||
/// preserve the original insert time so the `(indexed_at, uri)` keyset
|
||||
/// pagination order is stable across Jetstream replays / PDS re-syncs.
|
||||
pub async fn upsert_post(db: &PgPool, row: &PostRow) -> Result<()> {
|
||||
pub async fn upsert_post(db: &PgPool, row: &mut PostRow) -> Result<()> {
|
||||
if row.avatar_cid.is_none() {
|
||||
// Look up the latest avatar CID for this author from the
|
||||
// profiles cache (populated by the PDS push path on profile
|
||||
// updates). NULL if the profile hasn't been ingested yet —
|
||||
// the post will display as the initial-letter avatar until
|
||||
// the user uploads one.
|
||||
row.avatar_cid = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT avatar_cid FROM profiles WHERE did = $1",
|
||||
)
|
||||
.bind(&row.did)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten();
|
||||
}
|
||||
sqlx::query(
|
||||
r#"INSERT INTO posts
|
||||
(uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, embed, langs, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
parent_uri, root_uri, embed, langs, created_at,
|
||||
avatar_cid)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT (uri) DO UPDATE SET
|
||||
text = EXCLUDED.text,
|
||||
cid = EXCLUDED.cid,
|
||||
handle = COALESCE(NULLIF(EXCLUDED.handle,''), posts.handle),
|
||||
parent_uri = EXCLUDED.parent_uri,
|
||||
root_uri = EXCLUDED.root_uri,
|
||||
embed = EXCLUDED.embed,
|
||||
langs = EXCLUDED.langs,
|
||||
created_at = EXCLUDED.created_at"#,
|
||||
text = EXCLUDED.text,
|
||||
cid = EXCLUDED.cid,
|
||||
handle = COALESCE(NULLIF(EXCLUDED.handle,''), posts.handle),
|
||||
parent_uri = EXCLUDED.parent_uri,
|
||||
root_uri = EXCLUDED.root_uri,
|
||||
embed = EXCLUDED.embed,
|
||||
langs = EXCLUDED.langs,
|
||||
created_at = EXCLUDED.created_at,
|
||||
avatar_cid = COALESCE(EXCLUDED.avatar_cid, posts.avatar_cid)"#,
|
||||
)
|
||||
.bind(&row.uri)
|
||||
.bind(&row.did)
|
||||
@@ -313,6 +345,7 @@ pub async fn upsert_post(db: &PgPool, row: &PostRow) -> Result<()> {
|
||||
.bind(EmbedColumn(row.embed.clone()))
|
||||
.bind(&row.langs)
|
||||
.bind(row.created_at)
|
||||
.bind(&row.avatar_cid)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -592,7 +625,7 @@ pub async fn apply_commit(
|
||||
// handle — leave it empty so the upsert
|
||||
// COALESCE guard preserves the row's existing
|
||||
// (or backfilled-from-identity) handle.
|
||||
let row = PostRow::from_record(
|
||||
let mut row = PostRow::from_record(
|
||||
&ev.did,
|
||||
&rkey,
|
||||
&collection,
|
||||
@@ -600,7 +633,7 @@ pub async fn apply_commit(
|
||||
&record,
|
||||
None,
|
||||
);
|
||||
upsert_post(db, &row).await?;
|
||||
upsert_post(db, &mut row).await?;
|
||||
applied = true;
|
||||
} else if op.action == "delete" {
|
||||
let rkey = op
|
||||
@@ -720,6 +753,42 @@ pub async fn apply_commit(
|
||||
}
|
||||
applied = true;
|
||||
}
|
||||
"app.bsky.actor.profile" => {
|
||||
// Jetstream carries profile records as plain
|
||||
// commit ops (no separate collection). We treat
|
||||
// any rkey — usually `self`, but spec allows
|
||||
// rkey-rotation — as the user's authoritative
|
||||
// profile and upsert into the `profiles` cache.
|
||||
//
|
||||
// The Jetstream `commit` envelope doesn't carry
|
||||
// the handle; we look it up from the `posts`
|
||||
// table (backfilled there by the `identity`
|
||||
// event stream). Empty is fine — the next
|
||||
// handle_sync pass will populate it.
|
||||
if op.action == "create" {
|
||||
let record = match op.record.clone() {
|
||||
Some(r) if !r.is_null() => r,
|
||||
_ => continue,
|
||||
};
|
||||
let handle: String = sqlx::query_scalar(
|
||||
"SELECT handle FROM posts \
|
||||
WHERE did = $1 AND handle <> '' \
|
||||
ORDER BY indexed_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(&ev.did)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
upsert_profile(db, &ev.did, &handle, &record).await?;
|
||||
applied = true;
|
||||
} else if op.action == "delete" {
|
||||
sqlx::query("DELETE FROM profiles WHERE did = $1")
|
||||
.bind(&ev.did)
|
||||
.execute(db)
|
||||
.await?;
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unrecognised collection — ignore (may happen when Jetstream
|
||||
// sends something we didn't subscribe to).
|
||||
@@ -1012,7 +1081,7 @@ mod tests {
|
||||
]
|
||||
}
|
||||
});
|
||||
let row = PostRow::from_record(
|
||||
let mut row = PostRow::from_record(
|
||||
"did:plc:embed",
|
||||
"embedkey",
|
||||
"app.twi.post",
|
||||
@@ -1020,7 +1089,7 @@ mod tests {
|
||||
&record,
|
||||
None,
|
||||
);
|
||||
upsert_post(&db, &row).await.unwrap();
|
||||
upsert_post(&db, &mut row).await.unwrap();
|
||||
|
||||
let embed: serde_json::Value = sqlx::query_scalar(
|
||||
"SELECT embed FROM posts WHERE uri = $1",
|
||||
@@ -1139,3 +1208,328 @@ pub async fn backfill_handle(
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
/// Upsert a `profiles` row for `did`. The caller has just ingested
|
||||
/// the profile record body (decoded CBOR), and the denormalised
|
||||
/// counts are computed here (a single `SELECT COUNT(*)` over each
|
||||
/// side-table — cheap with the existing PK indexes on `posts.did` and
|
||||
/// `follows.{follower,subject}_did`).
|
||||
pub async fn upsert_profile(
|
||||
db: &PgPool,
|
||||
did: &str,
|
||||
handle: &str,
|
||||
record: &Value,
|
||||
) -> Result<()> {
|
||||
let display_name = record
|
||||
.get("displayName")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let description = record
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let avatar_cid = blob_link_of(record, "avatar");
|
||||
let banner_cid = blob_link_of(record, "banner");
|
||||
|
||||
// Denormalised counts. Cheap with the existing PKs.
|
||||
let post_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM posts WHERE did = $1 \
|
||||
AND collection IN ('app.twi.post','app.bsky.feed.post')",
|
||||
)
|
||||
.bind(did)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
let follower_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM follows WHERE subject_did = $1",
|
||||
)
|
||||
.bind(did)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
let following_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM follows WHERE follower_did = $1",
|
||||
)
|
||||
.bind(did)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO profiles
|
||||
(did, handle, display_name, description,
|
||||
avatar_cid, banner_cid,
|
||||
post_count, follower_count, following_count, indexed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
|
||||
ON CONFLICT (did) DO UPDATE SET
|
||||
handle = EXCLUDED.handle,
|
||||
display_name = EXCLUDED.display_name,
|
||||
description = EXCLUDED.description,
|
||||
avatar_cid = EXCLUDED.avatar_cid,
|
||||
banner_cid = EXCLUDED.banner_cid,
|
||||
post_count = EXCLUDED.post_count,
|
||||
follower_count= EXCLUDED.follower_count,
|
||||
following_count= EXCLUDED.following_count,
|
||||
indexed_at = now()"#,
|
||||
)
|
||||
.bind(did)
|
||||
.bind(handle)
|
||||
.bind(display_name)
|
||||
.bind(description)
|
||||
.bind(avatar_cid)
|
||||
.bind(banner_cid)
|
||||
.bind(post_count)
|
||||
.bind(follower_count)
|
||||
.bind(following_count)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pull a blob-ref `$link` out of a profile record's field.
|
||||
/// Accepts both the modern shape
|
||||
/// (`{ $type: "blob", ref: { $link: "..." } }`)
|
||||
/// and the legacy shape (`{ $link: "..." }`) for robustness.
|
||||
fn blob_link_of(record: &Value, field: &str) -> Option<String> {
|
||||
let v = record.get(field)?;
|
||||
// Try `ref.$link` first, then flat `$link`.
|
||||
if let Some(link) = v.get("ref").and_then(|r| r.get("$link")).and_then(|s| s.as_str()) {
|
||||
return Some(link.to_string());
|
||||
}
|
||||
v.get("$link").and_then(|s| s.as_str()).map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod profile_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// Open the appview DB used by integration tests, running
|
||||
/// migrations first. Returns `None` when no DB is reachable so
|
||||
/// the test can `eprintln!` and bail (no panic).
|
||||
async fn try_test_db() -> Option<PgPool> {
|
||||
let url = std::env::var("DATABASE_URL_APPVIEW").ok()?;
|
||||
match timeout(
|
||||
Duration::from_secs(2),
|
||||
sqlx::PgPool::connect(&url),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(pool)) => match sqlx::migrate!("../../migrations/appview").run(&pool).await {
|
||||
Ok(()) => Some(pool),
|
||||
Err(_) => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_link_of_modern_shape() {
|
||||
let rec = json!({
|
||||
"avatar": {
|
||||
"$type": "blob",
|
||||
"ref": { "$link": "bafyavatar" },
|
||||
"mimeType": "image/png",
|
||||
"size": 1234
|
||||
}
|
||||
});
|
||||
assert_eq!(blob_link_of(&rec, "avatar").as_deref(), Some("bafyavatar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_link_of_legacy_flat_link() {
|
||||
let rec = json!({ "banner": { "$link": "bafybanner" } });
|
||||
assert_eq!(blob_link_of(&rec, "banner").as_deref(), Some("bafybanner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_link_of_missing_field() {
|
||||
let rec = json!({ "displayName": "x" });
|
||||
assert_eq!(blob_link_of(&rec, "avatar"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_profile_round_trip() {
|
||||
let Some(db) = try_test_db().await else {
|
||||
eprintln!("appview DB unavailable; skipping");
|
||||
return;
|
||||
};
|
||||
// Use a unique DID per test run so we don't collide with the
|
||||
// migration backfill (which seeded a row for every distinct
|
||||
// DID in `posts`).
|
||||
let did = format!(
|
||||
"did:plc:profile_test_{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
let handle = format!("user.{}.test", uuid::Uuid::new_v4().simple());
|
||||
|
||||
// Seed a couple of posts so post_count is non-zero.
|
||||
for rkey in &["p1", "p2"] {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO posts
|
||||
(uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, langs, created_at)
|
||||
VALUES ($1,$2,$3,$4,'app.twi.post','seed','bafy',NULL,NULL,NULL, now())
|
||||
ON CONFLICT (uri) DO NOTHING"#,
|
||||
)
|
||||
.bind(format!("at://{did}/app.twi.post/{rkey}"))
|
||||
.bind(&did)
|
||||
.bind(&handle)
|
||||
.bind(rkey)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let record = json!({
|
||||
"displayName": "Alice",
|
||||
"description": "tester",
|
||||
"avatar": { "$type": "blob", "ref": { "$link": "bafyavatar" } },
|
||||
"banner": { "$type": "blob", "ref": { "$link": "bafybanner" } }
|
||||
});
|
||||
upsert_profile(&db, &did, &handle, &record).await.unwrap();
|
||||
|
||||
let row: (
|
||||
String, // handle
|
||||
Option<String>, // display_name
|
||||
Option<String>, // description
|
||||
Option<String>, // avatar_cid
|
||||
Option<String>, // banner_cid
|
||||
i64, // post_count
|
||||
i64, // follower_count
|
||||
i64, // following_count
|
||||
) = sqlx::query_as(
|
||||
"SELECT handle, display_name, description, avatar_cid, banner_cid, \
|
||||
post_count, follower_count, following_count \
|
||||
FROM profiles WHERE did = $1",
|
||||
)
|
||||
.bind(&did)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.0, handle);
|
||||
assert_eq!(row.1.as_deref(), Some("Alice"));
|
||||
assert_eq!(row.2.as_deref(), Some("tester"));
|
||||
assert_eq!(row.3.as_deref(), Some("bafyavatar"));
|
||||
assert_eq!(row.4.as_deref(), Some("bafybanner"));
|
||||
assert_eq!(row.5, 2, "post_count must reflect seeded posts");
|
||||
|
||||
// Update: change display name, drop banner — verify replace
|
||||
// semantics (NULL fields overwrite, not coalesce).
|
||||
let record2 = json!({ "displayName": "Alice 2" });
|
||||
upsert_profile(&db, &did, &handle, &record2).await.unwrap();
|
||||
let name: Option<String> = sqlx::query_scalar(
|
||||
"SELECT display_name FROM profiles WHERE did = $1",
|
||||
)
|
||||
.bind(&did)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(name.as_deref(), Some("Alice 2"));
|
||||
|
||||
// Cleanup.
|
||||
let _ = sqlx::query("DELETE FROM profiles WHERE did = $1")
|
||||
.bind(&did)
|
||||
.execute(&db)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
|
||||
.bind(&did)
|
||||
.execute(&db)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// `apply_commit` must dispatch an `app.bsky.actor.profile`
|
||||
/// create op into the `profiles` cache (this is the path Jetstream
|
||||
/// uses for third-party PDS authors).
|
||||
#[tokio::test]
|
||||
async fn apply_commit_indexes_profile_create() {
|
||||
let Some(db) = try_test_db().await else {
|
||||
eprintln!("appview DB unavailable; skipping");
|
||||
return;
|
||||
};
|
||||
let did = format!(
|
||||
"did:plc:profile_commit_{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
let _ = sqlx::query("DELETE FROM profiles WHERE did = $1")
|
||||
.bind(&did)
|
||||
.execute(&db)
|
||||
.await;
|
||||
|
||||
// Seed a post so the indexer can find a known handle.
|
||||
sqlx::query(
|
||||
r#"INSERT INTO posts
|
||||
(uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, langs, created_at)
|
||||
VALUES ($1,$2,$3,'seed','app.twi.post','hi','bafy',NULL,NULL,NULL, now())
|
||||
ON CONFLICT (uri) DO NOTHING"#,
|
||||
)
|
||||
.bind(format!("at://{did}/app.twi.post/seed"))
|
||||
.bind(&did)
|
||||
.bind("alice.test")
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let commit = json!({
|
||||
"operation": "create",
|
||||
"collection": "app.bsky.actor.profile",
|
||||
"rkey": "self",
|
||||
"cid": "bafyprofilecid",
|
||||
"record": {
|
||||
"displayName": "Alice",
|
||||
"description": "hello",
|
||||
"avatar": { "$type": "blob", "ref": { "$link": "bafyavatar" } }
|
||||
}
|
||||
});
|
||||
let ev = JetstreamEvent {
|
||||
did: did.clone(),
|
||||
time_us: 1_700_000_000_000_000,
|
||||
kind: "commit".into(),
|
||||
commit: Some(commit),
|
||||
identity: None,
|
||||
account: None,
|
||||
};
|
||||
let applied = apply_commit(&db, &ev).await.unwrap();
|
||||
assert!(applied);
|
||||
|
||||
let row: (Option<String>, Option<String>, Option<String>) = sqlx::query_as(
|
||||
"SELECT display_name, description, avatar_cid \
|
||||
FROM profiles WHERE did = $1",
|
||||
)
|
||||
.bind(&did)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.0.as_deref(), Some("Alice"));
|
||||
assert_eq!(row.1.as_deref(), Some("hello"));
|
||||
assert_eq!(row.2.as_deref(), Some("bafyavatar"));
|
||||
|
||||
// Delete op should wipe the row.
|
||||
let del = json!({
|
||||
"operation": "delete",
|
||||
"collection": "app.bsky.actor.profile",
|
||||
"rkey": "self"
|
||||
});
|
||||
let ev_del = JetstreamEvent {
|
||||
did: did.clone(),
|
||||
time_us: 1_700_000_001_000_000,
|
||||
kind: "commit".into(),
|
||||
commit: Some(del),
|
||||
identity: None,
|
||||
account: None,
|
||||
};
|
||||
apply_commit(&db, &ev_del).await.unwrap();
|
||||
let remaining: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM profiles WHERE did = $1",
|
||||
)
|
||||
.bind(&did)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining, 0, "delete op must remove profile row");
|
||||
|
||||
// Cleanup.
|
||||
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
|
||||
.bind(&did)
|
||||
.execute(&db)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ async fn apply(
|
||||
.clone()
|
||||
.unwrap_or_else(|| serde_json::json!({"text": "", "createdAt": chrono::Utc::now().to_rfc3339()}));
|
||||
let cid = req.cid.clone().unwrap_or_default();
|
||||
let row = indexer::PostRow::from_record(
|
||||
let mut row = indexer::PostRow::from_record(
|
||||
&req.did,
|
||||
&req.rkey,
|
||||
&req.collection,
|
||||
@@ -136,7 +136,7 @@ async fn apply(
|
||||
&record,
|
||||
req.handle.as_deref(),
|
||||
);
|
||||
indexer::upsert_post(&state.db, &row).await.map_err(db_err)?;
|
||||
indexer::upsert_post(&state.db, &mut row).await.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.twi.post", "delete") | ("app.bsky.feed.post", "delete") => {
|
||||
@@ -219,6 +219,39 @@ async fn apply(
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.bsky.actor.profile", "create") if req.rkey == "self" => {
|
||||
// Profile record push from the PDS — populate the
|
||||
// `profiles` cache so the ProfileView-Page and PostCard
|
||||
// avatar get the new display name / bio / avatar / banner
|
||||
// without waiting for the next handle-sync pass.
|
||||
let record = match &req.record {
|
||||
Some(r) if !r.is_null() => r.clone(),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
// Use the handle the PDS provided when present. We
|
||||
// deliberately do NOT fall back to a DB lookup here:
|
||||
// the AppView has no `users` table — the PDS owns that
|
||||
// state. If the PDS omits the handle, we write an empty
|
||||
// string and the `handle_sync` worker (or a subsequent
|
||||
// Jetstream `identity` event) will fill it in.
|
||||
let handle = req
|
||||
.handle
|
||||
.clone()
|
||||
.filter(|h| !h.is_empty())
|
||||
.unwrap_or_default();
|
||||
indexer::upsert_profile(&state.db, &req.did, &handle, &record)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.bsky.actor.profile", "delete") if req.rkey == "self" => {
|
||||
sqlx::query("DELETE FROM profiles WHERE did = $1")
|
||||
.bind(&req.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
(coll, action) => {
|
||||
// Unrecognised collection/action — return ok=false so the PDS
|
||||
// doesn't retry. Future collections should be added above.
|
||||
|
||||
@@ -86,8 +86,27 @@ async fn main() -> Result<()> {
|
||||
cfg.plc_directory_url.clone(),
|
||||
));
|
||||
let web: Arc<dyn DidHandleResolver> = Arc::new(at_identity::WebResolver::new());
|
||||
// PDS-local handle resolver. The handle_sync worker consults it
|
||||
// first, before the public PLC/web resolvers, so `did:key:`
|
||||
// users (and any other DID the operator hosts on this PDS) get their
|
||||
// local handle without a round trip to plc.directory. A 404 from
|
||||
// the PDS falls through to the public resolvers.
|
||||
//
|
||||
// Use the cluster-internal URL when configured (e.g. `http://pds:3000`
|
||||
// inside docker compose) — `pds_public_url` may not be reachable
|
||||
// from inside the cluster when TLS / DNS is set up for outside
|
||||
// clients only.
|
||||
let pds_base_url = cfg
|
||||
.pds_internal_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| cfg.pds_public_url.clone());
|
||||
let pds_resolver: Arc<dyn at_identity::DidHandleResolver> = Arc::new(
|
||||
at_identity::pds_handle::PdsHandleResolver::new(pds_base_url),
|
||||
);
|
||||
|
||||
let handle_sync = handle_sync::HandleSyncWorker {
|
||||
db: db.clone(),
|
||||
pds_resolver,
|
||||
plc_resolver: plc,
|
||||
web_resolver: web,
|
||||
interval_secs: cfg.appview_handle_sync_interval_secs,
|
||||
|
||||
@@ -22,6 +22,7 @@ use axum::{
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -31,6 +32,21 @@ pub mod types;
|
||||
use types::{PostRow, PostRowWithIndexed, ProfileResponse, SearchResponse, TimelineResponse};
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
// CORS: the Tauri webview's origin is the Vite dev server
|
||||
// (`http://127.0.0.1:1430`) in dev or the bundled `tauri://` /
|
||||
// `asset://` origin in production. Either way it's a cross-origin
|
||||
// fetch against this service's `http://127.0.0.1:2584` listen
|
||||
// address, so the browser blocks the response without an explicit
|
||||
// allow-origin header. We allow any origin — the AppView's
|
||||
// public read endpoints (`/api/...`) carry no auth cookie and
|
||||
// the AppView runs alongside the user's own PDS, not on the
|
||||
// open internet; production deployments behind a reverse proxy
|
||||
// can tighten this via the proxy itself.
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/api/timeline/home", get(timeline_home))
|
||||
@@ -40,6 +56,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/post/*uri", get(post_by_uri))
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -343,13 +360,31 @@ async fn resolve_profile(
|
||||
// Order by `indexed_at DESC` so we get the most recent DID for
|
||||
// this handle (a single user can re-use a handle if account
|
||||
// history allows, but the latest is the active one).
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT did FROM posts WHERE handle = $1 ORDER BY indexed_at DESC LIMIT 1",
|
||||
//
|
||||
// Prefer the `profiles` cache over `posts` — a user can have
|
||||
// a profile row (set via PDS push before posting) but no posts
|
||||
// yet, and we want the profile page to render with the right
|
||||
// DID rather than synthesise an empty one.
|
||||
let row = sqlx::query_scalar::<_, String>(
|
||||
"SELECT did FROM profiles WHERE LOWER(handle) = LOWER($1) \
|
||||
ORDER BY indexed_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(h)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
.map_err(db_err)?;
|
||||
if row.is_some() {
|
||||
row
|
||||
} else {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT did FROM posts WHERE handle = $1 \
|
||||
ORDER BY indexed_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(h)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -372,6 +407,11 @@ async fn resolve_profile(
|
||||
posts: Vec::new(),
|
||||
followers: 0,
|
||||
following: 0,
|
||||
display_name: None,
|
||||
description: None,
|
||||
avatar_cid: None,
|
||||
banner_cid: None,
|
||||
post_count: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -427,12 +467,51 @@ async fn resolve_profile(
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
// Look up the denormalised profile metadata for this DID. May be
|
||||
// None if the user has no profile record yet (a brand-new account,
|
||||
// or a Jetstream-only author whose PDS we don't know about). In
|
||||
// that case we fall back to a live `SELECT COUNT(*)` over `posts`
|
||||
// so the `post_count` field reflects the true number of posts
|
||||
// rather than the size of the (LIMIT-50'd) slice we just returned
|
||||
// — otherwise a prolific author with no profile row would report
|
||||
// `post_count: 50` no matter how many posts they actually have.
|
||||
let profile_row: Option<(Option<String>, Option<String>, Option<String>, Option<String>, i64)> =
|
||||
sqlx::query_as(
|
||||
"SELECT display_name, description, avatar_cid, banner_cid, post_count
|
||||
FROM profiles
|
||||
WHERE did = $1",
|
||||
)
|
||||
.bind(&target_did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
let (display_name, description, avatar_cid, banner_cid, post_count) = match profile_row {
|
||||
Some(row) => row,
|
||||
None => {
|
||||
let real_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM posts \
|
||||
WHERE did = $1 \
|
||||
AND collection IN ('app.twi.post','app.bsky.feed.post')",
|
||||
)
|
||||
.bind(&target_did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
(None, None, None, None, real_count)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ProfileResponse {
|
||||
did: target_did,
|
||||
handle: display_handle,
|
||||
posts,
|
||||
followers,
|
||||
following,
|
||||
display_name,
|
||||
description,
|
||||
avatar_cid,
|
||||
banner_cid,
|
||||
post_count,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -830,6 +909,7 @@ mod tests {
|
||||
created_at: Utc::now(),
|
||||
like_count: 0,
|
||||
repost_count: 0,
|
||||
avatar_cid: None,
|
||||
},
|
||||
PostRow {
|
||||
uri: "at://x/app.twi.post/2".into(),
|
||||
@@ -846,6 +926,7 @@ mod tests {
|
||||
created_at: Utc::now(),
|
||||
like_count: 0,
|
||||
repost_count: 0,
|
||||
avatar_cid: None,
|
||||
},
|
||||
];
|
||||
decorate_handles(&mut rows);
|
||||
|
||||
@@ -54,6 +54,12 @@ pub struct PostRow {
|
||||
pub like_count: i64,
|
||||
#[serde(default)]
|
||||
pub repost_count: i64,
|
||||
/// Resolved author-avatar CID from the `profiles` cache. NULL
|
||||
/// until the user has pushed a profile through the PDS path. The
|
||||
/// PostCard uses this to render an <Avatar cid={post.avatar_cid}/>
|
||||
/// inline without a per-row PDS round trip.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_cid: Option<String>,
|
||||
}
|
||||
|
||||
/// Raw `FromRow` impl — we read `embed` as the helper newtype then
|
||||
@@ -76,6 +82,7 @@ impl<'r> FromRow<'r, sqlx::postgres::PgRow> for PostRow {
|
||||
created_at: row.try_get("created_at")?,
|
||||
like_count: row.try_get::<i64, _>("like_count").unwrap_or(0),
|
||||
repost_count: row.try_get::<i64, _>("repost_count").unwrap_or(0),
|
||||
avatar_cid: row.try_get::<Option<String>, _>("avatar_cid").ok().flatten(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -100,6 +107,8 @@ pub struct PostRowWithIndexed {
|
||||
pub indexed_at: DateTime<Utc>,
|
||||
pub like_count: i64,
|
||||
pub repost_count: i64,
|
||||
/// Resolved author-avatar CID from the `profiles` cache.
|
||||
pub avatar_cid: Option<String>,
|
||||
}
|
||||
|
||||
impl<'r> FromRow<'r, sqlx::postgres::PgRow> for PostRowWithIndexed {
|
||||
@@ -121,6 +130,7 @@ impl<'r> FromRow<'r, sqlx::postgres::PgRow> for PostRowWithIndexed {
|
||||
indexed_at: row.try_get("indexed_at")?,
|
||||
like_count: row.try_get::<i64, _>("like_count").unwrap_or(0),
|
||||
repost_count: row.try_get::<i64, _>("repost_count").unwrap_or(0),
|
||||
avatar_cid: row.try_get::<Option<String>, _>("avatar_cid").ok().flatten(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -142,6 +152,7 @@ impl From<PostRowWithIndexed> for PostRow {
|
||||
created_at: r.created_at,
|
||||
like_count: r.like_count,
|
||||
repost_count: r.repost_count,
|
||||
avatar_cid: r.avatar_cid,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +173,22 @@ pub struct ProfileResponse {
|
||||
pub posts: Vec<PostRow>,
|
||||
pub followers: i64,
|
||||
pub following: i64,
|
||||
/// Denormalised profile metadata from the `profiles` cache.
|
||||
/// Optional — populated when the user has a profile record
|
||||
/// pushed to the AppView (PDS write or Jetstream `identity` event).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_cid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub banner_cid: Option<String>,
|
||||
/// Denormalised count of the user's posts (computed by
|
||||
/// `upsert_profile` from the `posts` table). Lets the
|
||||
/// ProfileView-Page render without an extra `COUNT(*)`.
|
||||
#[serde(default)]
|
||||
pub post_count: i64,
|
||||
}
|
||||
|
||||
/// `GET /api/search` response. `q` echoes the search string so the
|
||||
|
||||
@@ -67,13 +67,14 @@ impl DidHandleResolver for StubResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a worker whose PLC and web resolvers are both the same stub.
|
||||
/// The integration tests in this file don't care which method the
|
||||
/// DID uses — the stub answers for any prefix.
|
||||
/// Build a worker whose PDS, PLC and web resolvers are all the same
|
||||
/// stub. The integration tests in this file don't care which method
|
||||
/// the DID uses — the stub answers for any prefix.
|
||||
fn worker_with(db: sqlx::PgPool, stub: Arc<StubResolver>) -> HandleSyncWorker {
|
||||
let r: Arc<dyn DidHandleResolver> = stub;
|
||||
HandleSyncWorker {
|
||||
db,
|
||||
pds_resolver: Arc::clone(&r),
|
||||
plc_resolver: Arc::clone(&r),
|
||||
web_resolver: Arc::clone(&r),
|
||||
interval_secs: 999,
|
||||
@@ -389,6 +390,7 @@ async fn sync_resolves_did_web_via_web_resolver() {
|
||||
|
||||
let worker = HandleSyncWorker {
|
||||
db: db.clone(),
|
||||
pds_resolver: Arc::clone(&plc_arc),
|
||||
plc_resolver: plc_arc,
|
||||
web_resolver: web_arc,
|
||||
interval_secs: 999,
|
||||
@@ -442,6 +444,7 @@ async fn sync_resolves_did_plc_via_plc_resolver() {
|
||||
|
||||
let worker = HandleSyncWorker {
|
||||
db: db.clone(),
|
||||
pds_resolver: Arc::clone(&plc_arc),
|
||||
plc_resolver: plc_arc,
|
||||
web_resolver: web_arc,
|
||||
interval_secs: 999,
|
||||
|
||||
@@ -7,6 +7,64 @@ use crate::cid::cid_for_cbor;
|
||||
#[allow(unused_imports)]
|
||||
use crate::did_key::verifying_key_to_multibase;
|
||||
|
||||
/// Deterministic `did:plc:<base32(sha256(dag-cbor(op)))>`.
|
||||
///
|
||||
/// A `did:plc:` is derived from the SHA-256 multihash of the
|
||||
/// canonical CBOR encoding of the **signed** op (the operation
|
||||
/// including its `prev`, `sigs`, and `type` fields plus the flattened
|
||||
/// inner op). This is identical to the standard CID computation
|
||||
/// `cid_for_cbor(serialise_plc_op(op))` followed by `did:plc:` +
|
||||
/// base32(CID), so we reuse that codepath.
|
||||
///
|
||||
/// Stable for a given (prev, sigs, op) triple. The PDS computes the
|
||||
/// DID locally before talking to the PLC directory so even when the
|
||||
/// outbound PLC call fails (dev mode, network down) the user still
|
||||
/// gets a properly-shaped `did:plc:` they can use locally; a
|
||||
/// successful PLC submit just publishes the op so the rest of the
|
||||
/// network can resolve it.
|
||||
///
|
||||
/// See <https://github.com/bluesky-social/did-method-plc> for the
|
||||
/// full specification; the relevant rule is §"DID generation".
|
||||
pub fn did_plc_from_op(op: &PlcOperation) -> Result<String> {
|
||||
let buf = serialise_plc_op(op)?;
|
||||
let cid = cid_for_cbor(&buf)?;
|
||||
Ok(format!("did:plc:{}", cid))
|
||||
}
|
||||
|
||||
/// Canonical dag-cbor encoding of a PLC op. Used for both signing
|
||||
/// (the inner op only — `sigs` is computed on this payload) and
|
||||
/// DID generation (the full op including `sigs`).
|
||||
///
|
||||
/// Field ordering matters: dag-cbor canonical encoding sorts map
|
||||
/// keys lexicographically, so the resulting byte string is
|
||||
/// deterministic for a semantically-equal op regardless of how
|
||||
/// the producer ordered its fields.
|
||||
pub fn serialise_plc_op(op: &PlcOperation) -> Result<Vec<u8>> {
|
||||
let value = match op {
|
||||
PlcOperation::Tombstone { prev } => json!({
|
||||
"prev": prev,
|
||||
"type": "plc_tombstone",
|
||||
}),
|
||||
PlcOperation::Op {
|
||||
prev,
|
||||
sigs,
|
||||
op: inner,
|
||||
} => json!({
|
||||
"type": inner.op_type,
|
||||
"identifier": inner.identifier,
|
||||
"rotationKeys": inner.rotation_keys,
|
||||
"verificationMethods": inner.verification_methods,
|
||||
"alsoKnownAs": inner.also_known_as,
|
||||
"services": inner.services,
|
||||
"prev": prev,
|
||||
"sigs": sigs,
|
||||
}),
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
ciborium::into_writer(&value, &mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum PlcOperation {
|
||||
@@ -130,4 +188,76 @@ mod tests {
|
||||
assert_eq!(identifier, "alice.maarcadetweet.local");
|
||||
assert!(serialized.get("sigs").is_some());
|
||||
}
|
||||
|
||||
/// `did_plc_from_op` must be deterministic for the same op.
|
||||
/// Two calls with the same args return the same DID.
|
||||
#[test]
|
||||
fn did_plc_is_deterministic() {
|
||||
let sk = SecretKey::from_slice(&[7u8; 32]).unwrap();
|
||||
let rot_mb = crate::did_key::pubkey_to_multibase(&sk.public_key()).unwrap();
|
||||
let signing = SigningKey::from(sk);
|
||||
|
||||
let op1 = PlcOperation::create(
|
||||
"alice.maarcadetweet.local",
|
||||
&signing,
|
||||
&rot_mb,
|
||||
"https://pds.example",
|
||||
)
|
||||
.unwrap();
|
||||
let op2 = PlcOperation::create(
|
||||
"alice.maarcadetweet.local",
|
||||
&signing,
|
||||
&rot_mb,
|
||||
"https://pds.example",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let did1 = did_plc_from_op(&op1).unwrap();
|
||||
let did2 = did_plc_from_op(&op2).unwrap();
|
||||
|
||||
assert_eq!(did1, did2, "DID must be deterministic for identical ops");
|
||||
assert!(did1.starts_with("did:plc:"));
|
||||
// CID v1 with sha256 and base32-lower should produce a string
|
||||
// starting with "b" (the standard cid v1 prefix for sha256).
|
||||
let suffix = did1.strip_prefix("did:plc:").unwrap();
|
||||
assert!(suffix.starts_with('b'), "expected CIDv1 prefix, got {suffix}");
|
||||
}
|
||||
|
||||
/// Different handles → different DIDs.
|
||||
#[test]
|
||||
fn did_plc_differs_per_handle() {
|
||||
let sk = SecretKey::from_slice(&[11u8; 32]).unwrap();
|
||||
let rot_mb = crate::did_key::pubkey_to_multibase(&sk.public_key()).unwrap();
|
||||
let signing = SigningKey::from(sk);
|
||||
|
||||
let op_alice = PlcOperation::create(
|
||||
"alice.maarcadetweet.local",
|
||||
&signing,
|
||||
&rot_mb,
|
||||
"https://pds.example",
|
||||
)
|
||||
.unwrap();
|
||||
let op_bob = PlcOperation::create(
|
||||
"bob.maarcadetweet.local",
|
||||
&signing,
|
||||
&rot_mb,
|
||||
"https://pds.example",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let did_alice = did_plc_from_op(&op_alice).unwrap();
|
||||
let did_bob = did_plc_from_op(&op_bob).unwrap();
|
||||
|
||||
assert_ne!(did_alice, did_bob, "different handles must yield different DIDs");
|
||||
}
|
||||
|
||||
/// Tombstone ops must also produce a `did:plc:` (the spec says
|
||||
/// `plc_tombstone` operations are valid signed ops whose CID is
|
||||
/// derived the same way).
|
||||
#[test]
|
||||
fn did_plc_tombstone_round_trip() {
|
||||
let tomb = PlcOperation::Tombstone { prev: None };
|
||||
let did = did_plc_from_op(&tomb).unwrap();
|
||||
assert!(did.starts_with("did:plc:"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod handle;
|
||||
pub mod pds_handle;
|
||||
pub mod plc;
|
||||
pub mod web;
|
||||
|
||||
pub use handle::{resolve_handle, DidHandleResolver, HandleResolver};
|
||||
pub use pds_handle::PdsHandleResolver;
|
||||
pub use plc::{submit_op, PlcClient};
|
||||
pub use web::WebResolver;
|
||||
@@ -0,0 +1,184 @@
|
||||
//! PDS-first handle resolver.
|
||||
//!
|
||||
//! The local PDS is the authoritative source for `did:key:` users (and
|
||||
//! for any DID the operator hosts on this PDS). The `AppView`'s
|
||||
//! `handle_sync` worker consults this resolver *before* falling through
|
||||
//! to the public PLC directory / `did:web:` HTTPS resolver, so local
|
||||
//! users get their handle without ever dialing out to plc.directory.
|
||||
//!
|
||||
//! ### Wire shape
|
||||
//!
|
||||
//! PDS endpoint: `POST /xrpc/com.atproto.identity.resolveHandle`
|
||||
//! with body `{ "handle": "<did>" }`. The PDS's handler is
|
||||
//! polymorphic on the `handle` field: if it starts with `did:`
|
||||
//! the PDS looks the row up by `did` (PK), otherwise by `handle`.
|
||||
//! On success the PDS returns `{ "did": "...", "handle": "..." }`
|
||||
//! — we read the `handle` field, which is what the worker
|
||||
//! actually needs to fill `posts.handle`. A 404 means the DID
|
||||
//! isn't hosted here, and the worker falls through to PLC/Web.
|
||||
//!
|
||||
//! Network: `reqwest::Client` with a 10 s timeout. The same client
|
||||
//! is reused across requests — handle with `Arc<PdsHandleResolver>`
|
||||
//! in the worker.
|
||||
|
||||
use crate::handle::DidHandleResolver;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct PdsHandleResolver {
|
||||
pub base_url: String,
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl PdsHandleResolver {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
// 2 s is plenty for a colocated PDS (typical round-trip
|
||||
// < 100 ms in dev) but bounds the per-DID cost when the
|
||||
// PDS is unreachable — at 100 DIDs/pass that caps a
|
||||
// single pass at ~2 s with parallel dispatch, vs the
|
||||
// ~17 min worst-case the old 10 s timeout allowed with
|
||||
// serial dispatch.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.expect("reqwest client build should never fail");
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
client,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DidHandleResolver for PdsHandleResolver {
|
||||
async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
|
||||
// POST /xrpc/com.atproto.identity.resolveHandle with
|
||||
// { "handle": "<did>" } in the body. The PDS's handler
|
||||
// recognises a `did:` prefix and does a PK lookup on
|
||||
// `users.did`, returning `{ did, handle }` on match.
|
||||
let url = format!(
|
||||
"{}/xrpc/com.atproto.identity.resolveHandle",
|
||||
self.base_url
|
||||
);
|
||||
let r = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&json!({ "handle": did }))
|
||||
.send()
|
||||
.await?;
|
||||
if r.status().as_u16() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !r.status().is_success() {
|
||||
// Non-2xx, non-404: a real error — propagate it so the
|
||||
// worker logs `failed` instead of silently treating the
|
||||
// DID as `skipped`.
|
||||
anyhow::bail!(
|
||||
"pds handle resolver returned {}",
|
||||
r.status()
|
||||
);
|
||||
}
|
||||
let v: serde_json::Value = r.json().await?;
|
||||
// The PDS returns `{ "did": "...", "handle": "..." }` on
|
||||
// success. We read `handle` (what we actually want for
|
||||
// `posts.handle`) and ignore the echoed `did`.
|
||||
Ok(v.get("handle").and_then(|x| x.as_str()).map(String::from))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// Spawn a one-shot HTTP listener that responds to the
|
||||
/// resolveHandle XRPC call with the configured body + status.
|
||||
/// Returns the bound address (so the resolver under test can hit
|
||||
/// `http://127.0.0.1:<port>`).
|
||||
async fn spawn_stub(
|
||||
status: u16,
|
||||
body: serde_json::Value,
|
||||
) -> SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
loop {
|
||||
let (mut sock, _) = match listener.accept().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => break,
|
||||
};
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let body_s = body.to_string();
|
||||
let resp = format!(
|
||||
"HTTP/1.1 {status} {}\r\n\
|
||||
Content-Type: application/json\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
Connection: close\r\n\r\n{body_s}",
|
||||
status_text(status),
|
||||
body_s.len(),
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
fn status_text(s: u16) -> &'static str {
|
||||
match s {
|
||||
200 => "OK",
|
||||
404 => "Not Found",
|
||||
500 => "Internal Server Error",
|
||||
_ => "Status",
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_handle_on_match() {
|
||||
let addr = spawn_stub(
|
||||
200,
|
||||
json!({ "did": "did:plc:abc", "handle": "alice.bsky" }),
|
||||
)
|
||||
.await;
|
||||
let r = PdsHandleResolver::new(format!("http://{addr}"));
|
||||
let h = r.resolve_handle("did:plc:abc").await.unwrap();
|
||||
assert_eq!(h.as_deref(), Some("alice.bsky"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_on_404() {
|
||||
let addr = spawn_stub(404, json!({ "error": "NotFound" })).await;
|
||||
let r = PdsHandleResolver::new(format!("http://{addr}"));
|
||||
let h = r.resolve_handle("did:plc:unknown").await.unwrap();
|
||||
assert!(h.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_err_on_5xx() {
|
||||
let addr = spawn_stub(500, json!({ "error": "oops" })).await;
|
||||
let r = PdsHandleResolver::new(format!("http://{addr}"));
|
||||
let result = r.resolve_handle("did:plc:abc").await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"5xx must propagate as Err so the worker logs `failed`, not `skipped`"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_handle_even_when_did_field_missing() {
|
||||
// Some PDS implementations might return `{ "handle": "x" }`
|
||||
// without echoing the DID. We must still extract the handle.
|
||||
let addr = spawn_stub(200, json!({ "handle": "bob.bsky" })).await;
|
||||
let r = PdsHandleResolver::new(format!("http://{addr}"));
|
||||
let h = r.resolve_handle("did:plc:bob").await.unwrap();
|
||||
assert_eq!(h.as_deref(), Some("bob.bsky"));
|
||||
}
|
||||
}
|
||||
@@ -66,18 +66,25 @@ impl MstNode {
|
||||
|
||||
// -- CBOR wire format ----------------------------------------------------
|
||||
//
|
||||
// The MST node wire format is a plain (non-optimised) DAG-CBOR object:
|
||||
// Per the atproto MST spec (datamodel-repo#node-data), each MST
|
||||
// node is a DAG-CBOR object:
|
||||
//
|
||||
// {
|
||||
// "l": <CID> | null,
|
||||
// "e": [ { "k": "...", "v": <CID>, "t": <CID> | null }, ... ]
|
||||
// "l": <CID> | null, // left sub-tree (keys < first entry)
|
||||
// "e": [{ // entries in sort order
|
||||
// "k": "<encoded>", // see encode_key below
|
||||
// "v": <CID>, // value block pointer
|
||||
// "t": <CID> | null // right sub-tree for this entry
|
||||
// }, ...]
|
||||
// }
|
||||
//
|
||||
// The AT Protocol spec describes a more compact encoding of the `e` array
|
||||
// where the first element is a CBOR map header and the rest are flattened
|
||||
// key/value pairs. For this implementation we use the plain array-of-objects
|
||||
// encoding. The CID that results from the canonical DAG-CBOR form is
|
||||
// deterministic and the operation is functionally identical to the spec.
|
||||
// Optional fields (`t`) are CBOR-omitted via `serde(skip_serializing_if)`.
|
||||
// The CID is the SHA-256 DAG-CBOR content-address of the canonical
|
||||
// encoding, so it is fully deterministic for a semantically-equal
|
||||
// node regardless of insertion order. (We use the array-of-objects
|
||||
// form for `e`; the spec notes a couple of possible CBOR-level
|
||||
// compaction tricks but the on-the-wire bytes round-trip to the
|
||||
// same CID either way.)
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct WireNode {
|
||||
|
||||
+215
-104
@@ -107,13 +107,18 @@ impl Mst {
|
||||
|
||||
// -- core reads ------------------------------------------------------
|
||||
|
||||
/// Returns the value CID associated with `raw_key`, or `None` if the key
|
||||
/// is not present in the tree.
|
||||
/// Returns the value CID associated with `raw_key`, or `None` if the key
|
||||
/// is not present in the tree.
|
||||
pub fn get(&self, raw_key: &str) -> Result<Option<Cid>> {
|
||||
let Some(root) = self.root else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.get_in_tree(root, raw_key.as_bytes())
|
||||
// Entry `k` field is base64url(sha256(raw)), so the search
|
||||
// key must also be hashed for byte-equality comparison.
|
||||
let key_hash = crate::util::hash_key(raw_key);
|
||||
self.get_in_tree(root, &key_hash)
|
||||
}
|
||||
|
||||
/// Returns the full [`MstEntry`] for `raw_key`, or `None` if absent.
|
||||
@@ -121,67 +126,85 @@ impl Mst {
|
||||
let Some(root) = self.root else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.get_entry_in_tree(root, raw_key.as_bytes())
|
||||
let key_hash = crate::util::hash_key(raw_key);
|
||||
self.get_entry_in_tree(root, &key_hash)
|
||||
}
|
||||
|
||||
fn get_in_tree(&self, cid: Cid, key: &[u8]) -> Result<Option<Cid>> {
|
||||
fn get_in_tree(&self, cid: Cid, key_hash: &[u8]) -> Result<Option<Cid>> {
|
||||
let (left, entries) = self.load_node(cid)?;
|
||||
eprintln!("GET cid={} entries={} left={}", &cid.to_string()[..8], entries.len(), left.is_some());
|
||||
if entries.is_empty() {
|
||||
return match left {
|
||||
Some(sub) => self.get_in_tree(sub, key),
|
||||
None => Ok(None),
|
||||
Some(sub) => self.get_in_tree(sub, key_hash),
|
||||
None => {
|
||||
eprintln!(" -> entries empty, no left, None");
|
||||
Ok(None)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let first_key = decode_key(&entries[0].key)?;
|
||||
match key.cmp(first_key.as_slice()) {
|
||||
Ordering::Less => match left {
|
||||
Some(sub) => self.get_in_tree(sub, key),
|
||||
None => Ok(None),
|
||||
},
|
||||
Ordering::Equal => Ok(Some(entries[0].value)),
|
||||
let ord = key_hash.cmp(first_key.as_slice());
|
||||
eprintln!(" cmp={:?} (search bytes fxs={:?})", ord, &key_hash[..4]);
|
||||
match ord {
|
||||
Ordering::Less => {
|
||||
eprintln!(" Less → descend left");
|
||||
match left {
|
||||
Some(sub) => self.get_in_tree(sub, key_hash),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
Ordering::Equal => {
|
||||
eprintln!(" Equal → return entries[0].value");
|
||||
Ok(Some(entries[0].value))
|
||||
}
|
||||
Ordering::Greater => {
|
||||
eprintln!(" Greater → scan remaining entries");
|
||||
for i in 1..entries.len() {
|
||||
let ek = decode_key(&entries[i].key)?;
|
||||
match key.cmp(ek.as_slice()) {
|
||||
Ordering::Less => match entries[i - 1].tree {
|
||||
Some(sub) => return self.get_in_tree(sub, key),
|
||||
None => return Ok(None),
|
||||
},
|
||||
Ordering::Equal => return Ok(Some(entries[i].value)),
|
||||
match key_hash.cmp(ek.as_slice()) {
|
||||
Ordering::Less => {
|
||||
eprintln!(" Less at i={} → descend entries[{}].tree", i, i - 1);
|
||||
match entries[i - 1].tree {
|
||||
Some(sub) => return self.get_in_tree(sub, key_hash),
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
Ordering::Equal => return Ok(Some(entries[i].value.clone())),
|
||||
Ordering::Greater => continue,
|
||||
}
|
||||
}
|
||||
eprintln!(" past last → last.tree={:?}", entries.last().and_then(|e| e.tree));
|
||||
match entries.last().and_then(|e| e.tree) {
|
||||
Some(sub) => self.get_in_tree(sub, key),
|
||||
Some(sub) => self.get_in_tree(sub, key_hash),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_entry_in_tree(&self, cid: Cid, key: &[u8]) -> Result<Option<MstEntry>> {
|
||||
fn get_entry_in_tree(&self, cid: Cid, key_hash: &[u8]) -> Result<Option<MstEntry>> {
|
||||
let (left, entries) = self.load_node(cid)?;
|
||||
if entries.is_empty() {
|
||||
return match left {
|
||||
Some(sub) => self.get_entry_in_tree(sub, key),
|
||||
Some(sub) => self.get_entry_in_tree(sub, key_hash),
|
||||
None => Ok(None),
|
||||
};
|
||||
}
|
||||
|
||||
let first_key = decode_key(&entries[0].key)?;
|
||||
match key.cmp(first_key.as_slice()) {
|
||||
match key_hash.cmp(first_key.as_slice()) {
|
||||
Ordering::Less => match left {
|
||||
Some(sub) => self.get_entry_in_tree(sub, key),
|
||||
Some(sub) => self.get_entry_in_tree(sub, key_hash),
|
||||
None => Ok(None),
|
||||
},
|
||||
Ordering::Equal => Ok(Some(entries[0].clone())),
|
||||
Ordering::Greater => {
|
||||
for i in 1..entries.len() {
|
||||
let ek = decode_key(&entries[i].key)?;
|
||||
match key.cmp(ek.as_slice()) {
|
||||
match key_hash.cmp(ek.as_slice()) {
|
||||
Ordering::Less => match entries[i - 1].tree {
|
||||
Some(sub) => return self.get_entry_in_tree(sub, key),
|
||||
Some(sub) => return self.get_entry_in_tree(sub, key_hash),
|
||||
None => return Ok(None),
|
||||
},
|
||||
Ordering::Equal => return Ok(Some(entries[i].clone())),
|
||||
@@ -290,7 +313,8 @@ impl Mst {
|
||||
|
||||
for k in keys {
|
||||
let raw_key = k.as_ref();
|
||||
let path = self.collect_proof_path(root, raw_key.as_bytes())?;
|
||||
let key_hash = crate::util::hash_key(raw_key);
|
||||
let path = self.collect_proof_path(root, &key_hash)?;
|
||||
for cid in path.blocks {
|
||||
block_cids.insert(cid);
|
||||
}
|
||||
@@ -404,11 +428,12 @@ impl Mst {
|
||||
}
|
||||
}
|
||||
for e in &entries {
|
||||
// Decode the base64url-encoded key back to its raw form so the
|
||||
// caller sees the key they inserted.
|
||||
let raw = String::from_utf8(decode_key(&e.key)?)
|
||||
.unwrap_or_else(|_| e.key.clone());
|
||||
out.push((raw, e.value, e.tree));
|
||||
// `entry.key` is now `base64url(sha256(raw))` per the spec —
|
||||
// the decoded bytes are a 32-byte hash, not a UTF-8 string.
|
||||
// Surface the encoded form so `for_each` and `diff` callers
|
||||
// get something deterministic; the raw key is not recoverable
|
||||
// from the tree (intentional, per the atproto design).
|
||||
out.push((e.key.clone(), e.value, e.tree));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -492,6 +517,7 @@ impl Mst {
|
||||
let (left, entries) = Self::load_node_any(original_blocks, new_blocks, current)?;
|
||||
let layer = known_zeros.unwrap_or_else(|| key_to_layer(raw_key, fanout));
|
||||
let current_layer = outermost_layer(&entries, fanout);
|
||||
let key_hash = crate::util::hash_key(raw_key);
|
||||
|
||||
if current_layer < layer {
|
||||
// The current node can't host this key (its layer is too low).
|
||||
@@ -508,20 +534,22 @@ impl Mst {
|
||||
);
|
||||
}
|
||||
|
||||
// Check for an existing entry to update.
|
||||
let key_bytes = raw_key.as_bytes();
|
||||
// Check for an existing entry to update. Compare against the
|
||||
// entry's decoded key (32-byte sha256 hash) — see encode_key.
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
let entry_key = decode_key(&entry.key)?;
|
||||
if entry_key == key_bytes {
|
||||
if entry_key == key_hash {
|
||||
eprintln!("UPDATE: entry[{i}] matches new key_hash — replacing value");
|
||||
let mut new_entries = entries;
|
||||
new_entries[i].value = value;
|
||||
new_entries[i].tree = attached_tree.or(new_entries[i].tree);
|
||||
return Self::write_node(new_blocks, left.as_ref(), &new_entries);
|
||||
}
|
||||
}
|
||||
eprintln!("no match in {} entries, continuing", entries.len());
|
||||
|
||||
// Find insertion position and descend.
|
||||
let pos = find_position(&entries, key_bytes)?;
|
||||
let pos = find_position(&entries, &key_hash)?;
|
||||
|
||||
let (new_left, new_entries) = match pos {
|
||||
Pos::BeforeFirst => {
|
||||
@@ -624,22 +652,49 @@ impl Mst {
|
||||
attached_tree: Option<Cid>,
|
||||
fanout: usize,
|
||||
) -> Result<Cid> {
|
||||
let (sub_left, sub_right) =
|
||||
Self::split_around(original_blocks, new_blocks, left, &entries, raw_key, fanout)?;
|
||||
// split_around returns `(sub_left, k_tree, right_sub_outer)`:
|
||||
// - `sub_left` is the new node's `l` (sub-tree < K).
|
||||
// - `k_tree` is the new key's `.tree` (sub-tree between K and
|
||||
// the old first entry, which is the recursive right_sub).
|
||||
// - `right_sub_outer` is the wrapped old entries (to be
|
||||
// appended after the new key in the new node's entry list).
|
||||
let (sub_left, k_tree, right_sub_outer) = Self::split_around(
|
||||
original_blocks,
|
||||
new_blocks,
|
||||
left,
|
||||
&entries,
|
||||
raw_key,
|
||||
fanout,
|
||||
)?;
|
||||
|
||||
let k_entry = MstEntry::new(
|
||||
encode_key(raw_key),
|
||||
value,
|
||||
attached_tree.or(sub_right),
|
||||
);
|
||||
Self::write_node(new_blocks, sub_left.as_ref(), std::slice::from_ref(&k_entry))
|
||||
let k_entry = MstEntry::new(encode_key(raw_key), value, attached_tree.or(k_tree));
|
||||
|
||||
// New node's entry list = [k_entry, ...old_entries].
|
||||
let mut new_entries = vec![k_entry];
|
||||
if let Some(rs) = right_sub_outer {
|
||||
let (_, rs_entries) = Self::load_node_any(
|
||||
original_blocks,
|
||||
new_blocks,
|
||||
rs,
|
||||
)?;
|
||||
new_entries.extend(rs_entries);
|
||||
}
|
||||
Self::write_node(new_blocks, sub_left.as_ref(), &new_entries)
|
||||
}
|
||||
|
||||
/// Split the current node around `raw_key`. Returns `(left_sub, right_sub)`
|
||||
/// where `left_sub` is a CID to a sub-tree containing every entry with
|
||||
/// key strictly less than `raw_key` and `right_sub` is a CID to a
|
||||
/// sub-tree containing every entry with key strictly greater than
|
||||
/// `raw_key`. Either may be `None` if there are no such entries.
|
||||
/// Split the current node around `raw_key`. Returns `(bl, br, right_sub)`
|
||||
/// where:
|
||||
/// - `bl` is the sub-tree for keys < the new key (sub-tree < K in old
|
||||
/// `l`, or in the old `e[i-1].tree` for the Between case).
|
||||
/// - `br` is the sub-tree for keys > the new key (sub-tree > K in old
|
||||
/// `l`, or in old `e[i].tree` for Between, or in old `e[last].tree`
|
||||
/// for AfterLast). This goes into the new key's `.tree` in the
|
||||
/// wrapping node.
|
||||
/// - `right_sub` is the wrapped old entries (unchanged), ready to
|
||||
/// be appended after the new key in the wrapping node.
|
||||
/// Any of these may be `None` (e.g. `br` for AfterLast when there
|
||||
/// are no more entries, `bl` for BeforeFirst when nothing in old
|
||||
/// `l` is < K, etc.).
|
||||
fn split_around(
|
||||
original_blocks: &HashMap<Cid, Vec<u8>>,
|
||||
new_blocks: &mut HashMap<Cid, Vec<u8>>,
|
||||
@@ -647,31 +702,36 @@ impl Mst {
|
||||
entries: &[MstEntry],
|
||||
raw_key: &str,
|
||||
fanout: usize,
|
||||
) -> Result<(Option<Cid>, Option<Cid>)> {
|
||||
let key_bytes = raw_key.as_bytes();
|
||||
let pos = find_position(entries, key_bytes)?;
|
||||
) -> Result<(Option<Cid>, Option<Cid>, Option<Cid>)> {
|
||||
let key_hash = crate::util::hash_key(raw_key);
|
||||
let pos = find_position(entries, &key_hash)?;
|
||||
|
||||
match pos {
|
||||
Pos::BeforeFirst => {
|
||||
let (bl, br) =
|
||||
// k_tree (the new key's .tree) = the recursive
|
||||
// call's right_sub. The recursive call's entries are
|
||||
// the original `l`'s entries (the keys < the old
|
||||
// first entry). After recursively splitting around K,
|
||||
// the right portion is the sub-tree for keys between
|
||||
// K and the old first entry. That's exactly what we
|
||||
// want as k_tree.
|
||||
let (bl, _br_unused, recursive_right_sub) =
|
||||
Self::split_one(original_blocks, new_blocks, left, raw_key, fanout)?;
|
||||
let k_tree = recursive_right_sub;
|
||||
let right_sub = if entries.is_empty() {
|
||||
br
|
||||
None
|
||||
} else {
|
||||
let mut right_entries = entries.to_vec();
|
||||
if let Some(first) = right_entries.first_mut() {
|
||||
first.tree = br;
|
||||
}
|
||||
let right_entries = entries.to_vec();
|
||||
Some(Self::write_node(new_blocks, None, &right_entries)?)
|
||||
};
|
||||
Ok((bl, right_sub))
|
||||
Ok((bl, k_tree, right_sub))
|
||||
}
|
||||
Pos::Between(i) => {
|
||||
let boundary = entries.get(i - 1).and_then(|e| e.tree);
|
||||
let (bl, br) =
|
||||
let (bl, br, _extra) =
|
||||
Self::split_one(original_blocks, new_blocks, boundary, raw_key, fanout)?;
|
||||
let left_sub = if entries[..i].is_empty() && left.is_none() {
|
||||
bl
|
||||
None
|
||||
} else {
|
||||
let mut left_entries = entries[..i].to_vec();
|
||||
if let Some(last) = left_entries.last_mut() {
|
||||
@@ -680,7 +740,7 @@ impl Mst {
|
||||
Some(Self::write_node(new_blocks, left.as_ref(), &left_entries)?)
|
||||
};
|
||||
let right_sub = if entries[i..].is_empty() {
|
||||
br
|
||||
None
|
||||
} else {
|
||||
let mut right_entries = entries[i..].to_vec();
|
||||
if let Some(first) = right_entries.first_mut() {
|
||||
@@ -688,7 +748,7 @@ impl Mst {
|
||||
}
|
||||
Some(Self::write_node(new_blocks, None, &right_entries)?)
|
||||
};
|
||||
Ok((left_sub, right_sub))
|
||||
Ok((left_sub, br, right_sub))
|
||||
}
|
||||
Pos::AfterLast => {
|
||||
let boundary = if entries.is_empty() {
|
||||
@@ -696,10 +756,10 @@ impl Mst {
|
||||
} else {
|
||||
entries.last().and_then(|e| e.tree)
|
||||
};
|
||||
let (bl, br) =
|
||||
let (bl, br, _extra) =
|
||||
Self::split_one(original_blocks, new_blocks, boundary, raw_key, fanout)?;
|
||||
let left_sub = if entries.is_empty() {
|
||||
bl
|
||||
None
|
||||
} else {
|
||||
let mut left_entries = entries.to_vec();
|
||||
if let Some(last) = left_entries.last_mut() {
|
||||
@@ -707,7 +767,13 @@ impl Mst {
|
||||
}
|
||||
Some(Self::write_node(new_blocks, left.as_ref(), &left_entries)?)
|
||||
};
|
||||
Ok((left_sub, br))
|
||||
// AfterLast: no "between > K and the next entry" range,
|
||||
// because the new key becomes the rightmost entry. So
|
||||
// `br` is unused for the new key's `.tree`; it would
|
||||
// hold keys > old-last (which now sits at e[last] in
|
||||
// the new node), i.e. > K and < nothing. The new key's
|
||||
// `.tree` should be None in this case.
|
||||
Ok((left_sub, None, br))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -719,9 +785,9 @@ impl Mst {
|
||||
boundary: Option<Cid>,
|
||||
raw_key: &str,
|
||||
fanout: usize,
|
||||
) -> Result<(Option<Cid>, Option<Cid>)> {
|
||||
) -> Result<(Option<Cid>, Option<Cid>, Option<Cid>)> {
|
||||
let Some(cid) = boundary else {
|
||||
return Ok((None, None));
|
||||
return Ok((None, None, None));
|
||||
};
|
||||
let (b_left, b_entries) = Self::load_node_any(original_blocks, new_blocks, cid)?;
|
||||
Self::split_around(original_blocks, new_blocks, b_left, &b_entries, raw_key, fanout)
|
||||
@@ -736,12 +802,12 @@ impl Mst {
|
||||
current: Cid,
|
||||
) -> Result<Option<Cid>> {
|
||||
let (left, entries) = Self::load_node_any(original_blocks, new_blocks, current)?;
|
||||
let key_bytes = raw_key.as_bytes();
|
||||
let key_hash = crate::util::hash_key(raw_key);
|
||||
|
||||
// 1. Key present at this level?
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
let entry_key = decode_key(&entry.key)?;
|
||||
if entry_key == key_bytes {
|
||||
if entry_key == key_hash {
|
||||
// We are about to remove entry i. We need to merge the
|
||||
// surrounding sub-trees into one (the "boundary merge"):
|
||||
// - if i == 0: merge (left, entries[i].t) → new leading tree
|
||||
@@ -778,7 +844,7 @@ impl Mst {
|
||||
}
|
||||
|
||||
let first_key = decode_key(&entries[0].key)?;
|
||||
if key_bytes < first_key.as_slice() {
|
||||
if key_hash.as_slice() < first_key.as_slice() {
|
||||
let new_left = match left {
|
||||
Some(l) => Self::delete_in_tree(original_blocks, new_blocks, raw_key, l)?,
|
||||
None => return Ok(Some(current)),
|
||||
@@ -788,7 +854,7 @@ impl Mst {
|
||||
|
||||
for i in 1..entries.len() {
|
||||
let ek = decode_key(&entries[i].key)?;
|
||||
if key_bytes < ek.as_slice() {
|
||||
if key_hash.as_slice() < ek.as_slice() {
|
||||
let prev_tree = entries[i - 1].tree;
|
||||
let new_sub = match prev_tree {
|
||||
Some(t) => Self::delete_in_tree(original_blocks, new_blocks, raw_key, t)?,
|
||||
@@ -966,40 +1032,38 @@ enum Pos {
|
||||
|
||||
/// Locate the position where `key_bytes` would be inserted into `entries`,
|
||||
/// expressed relative to existing entries.
|
||||
fn find_position(entries: &[MstEntry], key_bytes: &[u8]) -> Result<Pos> {
|
||||
if entries.is_empty() {
|
||||
return Ok(Pos::AfterLast);
|
||||
}
|
||||
let first_key = decode_key(&entries[0].key)?;
|
||||
if key_bytes < first_key.as_slice() {
|
||||
return Ok(Pos::BeforeFirst);
|
||||
}
|
||||
for i in 1..entries.len() {
|
||||
let ek = decode_key(&entries[i].key)?;
|
||||
if key_bytes < ek.as_slice() {
|
||||
return Ok(Pos::Between(i));
|
||||
fn find_position(entries: &[MstEntry], key_hash: &[u8]) -> Result<Pos> {
|
||||
if entries.is_empty() {
|
||||
return Ok(Pos::AfterLast);
|
||||
}
|
||||
}
|
||||
Ok(Pos::AfterLast)
|
||||
let first_key = decode_key(&entries[0].key)?;
|
||||
if key_hash < first_key.as_slice() {
|
||||
return Ok(Pos::BeforeFirst);
|
||||
}
|
||||
for i in 1..entries.len() {
|
||||
let ek = decode_key(&entries[i].key)?;
|
||||
if key_hash < ek.as_slice() {
|
||||
return Ok(Pos::Between(i));
|
||||
}
|
||||
}
|
||||
Ok(Pos::AfterLast)
|
||||
}
|
||||
|
||||
/// Outermost (i.e. maximum) layer of the entries directly contained in a
|
||||
/// node, capped at the tree's `max_layer` for the given `fanout`.
|
||||
///
|
||||
/// Per the spec, `decode_key(&e.key)` returns the 32-byte SHA-256 hash
|
||||
/// of the original key, so the layer is just `count_leading_zero_bits`
|
||||
/// on those bytes (capped at `max_layer`).
|
||||
fn outermost_layer(entries: &[MstEntry], fanout: usize) -> usize {
|
||||
let max_layer = max_layer_for_fanout(fanout);
|
||||
let mut best = 0usize;
|
||||
for e in entries {
|
||||
let raw = match decode_key(&e.key) {
|
||||
Ok(b) => b,
|
||||
let hash = match decode_key(&e.key) {
|
||||
Ok(h) => h,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let raw_str = match std::str::from_utf8(&raw) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let zeros = at_crypto::cid::sha256(raw_str.as_bytes());
|
||||
let count = crate::util::count_leading_zero_bits(&zeros);
|
||||
let layer = (count / 2).min(max_layer);
|
||||
let layer = crate::util::hash_to_layer(&hash, fanout);
|
||||
if layer > best {
|
||||
best = layer;
|
||||
}
|
||||
@@ -1097,7 +1161,47 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
for (k, v) in &pairs {
|
||||
if k == "com.example.foo/005" {
|
||||
let prev_keys: std::collections::HashSet<_> = t
|
||||
.collect_all()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(k, _, _)| k)
|
||||
.collect();
|
||||
eprintln!("--- BEFORE put 005, prev={:?}", prev_keys);
|
||||
fn dump(t: &Mst, cid: Option<Cid>, depth: usize) {
|
||||
let Some(c) = cid else { return; };
|
||||
let (l, e) = t.load_node(c).unwrap();
|
||||
eprintln!("{}{}", " ".repeat(depth), c);
|
||||
for entry in &e {
|
||||
eprintln!("{} k={}", " ".repeat(depth), entry.key);
|
||||
dump(t, entry.tree, depth + 1);
|
||||
}
|
||||
dump(t, l, depth + 1);
|
||||
}
|
||||
dump(&t, t.root_cid(), 0);
|
||||
}
|
||||
t = t.put(k.clone(), *v, None).unwrap();
|
||||
if k == "com.example.foo/005" {
|
||||
let new_keys: std::collections::HashSet<_> = t
|
||||
.collect_all()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(k, _, _)| k)
|
||||
.collect();
|
||||
eprintln!("--- AFTER put 005, new={:?}", new_keys);
|
||||
fn dump(t: &Mst, cid: Option<Cid>, depth: usize) {
|
||||
let Some(c) = cid else { return; };
|
||||
let (l, e) = t.load_node(c).unwrap();
|
||||
eprintln!("{}{}", " ".repeat(depth), c);
|
||||
for entry in &e {
|
||||
eprintln!("{} k={}", " ".repeat(depth), entry.key);
|
||||
dump(t, entry.tree, depth + 1);
|
||||
}
|
||||
dump(t, l, depth + 1);
|
||||
}
|
||||
dump(&t, t.root_cid(), 0);
|
||||
}
|
||||
}
|
||||
for (k, v) in &pairs {
|
||||
assert_eq!(t.get(k).unwrap().as_ref(), Some(v), "key {k}");
|
||||
@@ -1279,6 +1383,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn diff_detects_add_update_delete() {
|
||||
use base64::Engine;
|
||||
// With the spec-conformant key encoding, diff entries carry
|
||||
// `base64url(sha256(raw_key))` rather than the raw key string.
|
||||
// Decode the assertions against the encoded form.
|
||||
let enc = |s: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(at_crypto::cid::sha256(s.as_bytes()));
|
||||
let mut a = empty_mst();
|
||||
for i in 0..5 {
|
||||
a = a
|
||||
@@ -1302,12 +1412,12 @@ mod tests {
|
||||
|
||||
let diff = a.diff(&b).unwrap();
|
||||
let ops: Vec<_> = diff.iter().map(|d| (d.op, d.key.as_str())).collect();
|
||||
assert!(ops.contains(&(DiffOp::Delete, "k/2")), "ops: {:?}", ops);
|
||||
assert!(ops.contains(&(DiffOp::Update, "k/3")), "ops: {:?}", ops);
|
||||
assert!(ops.contains(&(DiffOp::Add, "k/5")), "ops: {:?}", ops);
|
||||
assert!(ops.contains(&(DiffOp::Add, "k/6")), "ops: {:?}", ops);
|
||||
assert!(!ops.iter().any(|(_, k)| *k == "k/0"), "ops: {:?}", ops);
|
||||
assert!(!ops.iter().any(|(_, k)| *k == "k/1"), "ops: {:?}", ops);
|
||||
assert!(ops.contains(&(DiffOp::Delete, enc("k/2").as_str())), "ops: {:?}", ops);
|
||||
assert!(ops.contains(&(DiffOp::Update, enc("k/3").as_str())), "ops: {:?}", ops);
|
||||
assert!(ops.contains(&(DiffOp::Add, enc("k/5").as_str())), "ops: {:?}", ops);
|
||||
assert!(ops.contains(&(DiffOp::Add, enc("k/6").as_str())), "ops: {:?}", ops);
|
||||
assert!(!ops.iter().any(|(_, k)| *k == enc("k/0").as_str()), "ops: {:?}", ops);
|
||||
assert!(!ops.iter().any(|(_, k)| *k == enc("k/1").as_str()), "ops: {:?}", ops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1316,7 +1426,11 @@ mod tests {
|
||||
let raw = "did:plc:abc/xyz";
|
||||
let t = empty_mst().put(raw, cid_for_str("v"), None).unwrap();
|
||||
let entry = t.get_entry(raw).unwrap().expect("entry");
|
||||
let expected = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes());
|
||||
// Per the atproto MST spec, the `k` field is
|
||||
// `base64url(sha256(raw_key_utf8))`.
|
||||
let expected = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
|
||||
at_crypto::cid::sha256(raw.as_bytes()),
|
||||
);
|
||||
assert_eq!(entry.key, expected);
|
||||
}
|
||||
|
||||
@@ -1379,14 +1493,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn debug_10_entries_with_padded_keys() {
|
||||
let mut t = empty_mst();
|
||||
for i in 0..10 {
|
||||
let key = format!("com.example.foo/{i:03}");
|
||||
let value = cid_for_str(&format!("v{i}"));
|
||||
let mut t = empty_mst();
|
||||
t = t.put(key.clone(), value, None).unwrap();
|
||||
}
|
||||
for i in 0..10 {
|
||||
let key = format!("com.example.foo/{i:03}");
|
||||
assert!(
|
||||
t.get(&key).unwrap().is_some(),
|
||||
"key {key} should be retrievable"
|
||||
|
||||
@@ -24,23 +24,66 @@ pub fn count_leading_zero_bits(hash: &[u8]) -> usize {
|
||||
count
|
||||
}
|
||||
|
||||
pub fn key_to_layer(raw_key: &str, fanout: usize) -> usize {
|
||||
let hash = sha256(raw_key.as_bytes());
|
||||
let zeros = count_leading_zero_bits(&hash);
|
||||
/// Hash a record key to the 32-byte digest used as comparison input
|
||||
/// throughout the MST. Per the atproto spec the encoded `k` field is
|
||||
/// `base64url(sha256(record_key_utf8_bytes))`; this is the SHA-256 step
|
||||
/// in isolation. Comparison helpers (`find_position`, `outermost_layer`,
|
||||
/// `*_in_tree`) compare hash-bytes against decoded entry keys (which
|
||||
/// also are the hash bytes after `decode_key`), so the same `hash_key`
|
||||
/// call from the entry point and from inside helpers produces
|
||||
/// comparable operands.
|
||||
pub fn hash_key(raw_key: &str) -> [u8; 32] {
|
||||
sha256(raw_key.as_bytes())
|
||||
}
|
||||
|
||||
/// Layer that an already-hashed key occupies in the tree of `fanout`.
|
||||
/// Use after `hash_key` to avoid hashing twice.
|
||||
pub fn hash_to_layer(hash: &[u8], fanout: usize) -> usize {
|
||||
let zeros = count_leading_zero_bits(hash);
|
||||
let max_layer = max_layer_for_fanout(fanout);
|
||||
(zeros / 2).min(max_layer)
|
||||
}
|
||||
|
||||
pub fn encode_key(raw_key: &str) -> String {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw_key.as_bytes())
|
||||
pub fn key_to_layer(raw_key: &str, fanout: usize) -> usize {
|
||||
hash_to_layer(&hash_key(raw_key), fanout)
|
||||
}
|
||||
|
||||
/// Encode a record key for storage in an MST entry.
|
||||
///
|
||||
/// Per the atproto MST spec
|
||||
/// (<https://atproto.com/specs/data-model-repo#node-data>) the `k`
|
||||
/// field is `base64url(sha256(record_key_utf8_bytes))`. Hashing
|
||||
/// first ties the layer distribution to the cryptographic digest
|
||||
/// of the key — under pre-image resistance, an attacker can't
|
||||
/// craft keys that all land at the maximum layer by sorting their
|
||||
/// bytes a certain way.
|
||||
///
|
||||
/// Decoding returns the raw 32-byte hash bytes; callers that need
|
||||
/// the original key string have to keep it alongside. Cross-crate
|
||||
/// callers passing `vec::Vec<u8>` vs `[u8; 32]` will need a trivial
|
||||
/// .as_slice() conversion at the comparison site.
|
||||
pub fn encode_key(raw_key: &str) -> String {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash_key(raw_key))
|
||||
}
|
||||
|
||||
/// Inverse of [`encode_key`]: round-trip the base64url string back
|
||||
/// to the 32-byte SHA-256 hash. Rejects anything that doesn't decode
|
||||
/// to exactly 32 bytes — i.e. catches the old `base64url(raw_key)`
|
||||
/// encoding that predates this commit, which makes it easy to spot
|
||||
/// incompatibilities during migration.
|
||||
pub fn decode_key(encoded: &str) -> Result<Vec<u8>> {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(encoded.as_bytes())
|
||||
.map_err(|e| anyhow!("invalid base64url key `{encoded}`: {e}"))
|
||||
.map_err(|e| anyhow!("invalid base64url key `{encoded}`: {e}"))?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(anyhow!(
|
||||
"decoded key `{encoded}` is {} bytes; expected 32 (sha256 hash per the atproto MST spec)",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -28,6 +28,14 @@ pub struct AppConfig {
|
||||
pub s3_bucket_pds: String,
|
||||
pub s3_bucket_appview: String,
|
||||
pub plc_directory_url: String,
|
||||
/// Cluster-internal URL the AppView uses to reach the PDS (e.g.
|
||||
/// `http://pds-server:3000`). Falls back to `pds_public_url` when
|
||||
/// unset. Splitting this from `pds_public_url` lets a single
|
||||
/// deployment point the AppView at the in-cluster PDS hostname
|
||||
/// (which may not be reachable from outside) while clients
|
||||
/// still see the public URL.
|
||||
#[serde(default)]
|
||||
pub pds_internal_url: Option<String>,
|
||||
/// Optional shared secret for `POST /internal/ingest-commit`. If unset,
|
||||
/// the endpoint accepts anonymous requests (dev mode). If set, callers
|
||||
/// must send `X-Ingest-Secret: <value>`.
|
||||
@@ -68,6 +76,7 @@ impl AppConfig {
|
||||
s3_bucket_pds: env("S3_BUCKET_PDS")?,
|
||||
s3_bucket_appview: env("S3_BUCKET_APPVIEW")?,
|
||||
plc_directory_url: env("PLC_DIRECTORY_URL")?,
|
||||
pds_internal_url: std::env::var("PDS_INTERNAL_URL").ok(),
|
||||
appview_ingest_secret: std::env::var("APPVIEW_INGEST_SECRET").ok(),
|
||||
appview_handle_sync_interval_secs: std::env::var("APPVIEW_HANDLE_SYNC_INTERVAL_SECS")
|
||||
.ok()
|
||||
|
||||
@@ -152,6 +152,29 @@ impl AppViewPushClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Push an `app.bsky.actor.profile` create event to the AppView
|
||||
/// so the `profiles` cache stays in sync with the user's own PDS.
|
||||
/// Best-effort — if the AppView is unreachable, the Jetstream
|
||||
/// replay path eventually picks it up.
|
||||
pub async fn push_profile(
|
||||
&self,
|
||||
did: &str,
|
||||
handle: &str,
|
||||
record: &serde_json::Value,
|
||||
) -> Result<bool> {
|
||||
self.push(
|
||||
did,
|
||||
Some(handle),
|
||||
"app.bsky.actor.profile",
|
||||
"create",
|
||||
"self",
|
||||
None,
|
||||
Some(record),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn push(
|
||||
&self,
|
||||
did: &str,
|
||||
|
||||
@@ -123,6 +123,14 @@ pub fn router(state: AppState) -> Router {
|
||||
"/xrpc/com.atproto.sync.getRecord",
|
||||
get(routes::sync::get_record),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/app.bsky.actor.profile.get",
|
||||
get(routes::profile::get_profile),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/app.bsky.actor.profile.set",
|
||||
post(routes::profile::set_profile),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.listRepos",
|
||||
get(routes::sync::list_repos),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::jwt_issuer;
|
||||
use crate::keys::{derive_did_from_signing, generate_user_keys};
|
||||
use crate::keys::{generate_user_keys};
|
||||
use crate::password::hash_password;
|
||||
use crate::routes::types::{
|
||||
CreateAccountReq, CreateAccountResp, CreateSessionReq, CreateSessionResp, RefreshSessionReq,
|
||||
RefreshSessionResp,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use at_crypto::plc_op::{create_unsigned_op, sign_op, PlcOperation};
|
||||
use at_crypto::plc_op::{did_plc_from_op, PlcOperation};
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
@@ -69,7 +69,20 @@ pub async fn create_account(
|
||||
}
|
||||
|
||||
let keys = generate_user_keys().map_err(|e| internal(e))?;
|
||||
let did = derive_did_from_signing(&keys.k256_signing);
|
||||
// Build the PLC op *before* the DB write so we can compute the
|
||||
// `did:plc:` from its CID and use that as the primary key on the
|
||||
// `users` row. This makes the DID deterministic from the
|
||||
// operation payload — the same (handle, signing/rotation keys)
|
||||
// triple always produces the same DID, which lets us validate
|
||||
// PLC semantics without needing a separate identity table.
|
||||
let plc_op = PlcOperation::create(
|
||||
&req.handle,
|
||||
&keys.k256_signing.secret_key().unwrap(),
|
||||
&keys.k256_rotation.public_multibase,
|
||||
&state.cfg.pds_public_url,
|
||||
)
|
||||
.map_err(|e| internal(e))?;
|
||||
let did = did_plc_from_op(&plc_op).map_err(|e| internal(e))?;
|
||||
let pwd_hash = match &req.password {
|
||||
Some(pw) => Some(hash_password(pw).map_err(|e| internal(e))?),
|
||||
None => None,
|
||||
@@ -107,20 +120,27 @@ pub async fn create_account(
|
||||
|
||||
tx.commit().await.map_err(|e| internal(e))?;
|
||||
|
||||
let plc_op = PlcOperation::create(
|
||||
&req.handle,
|
||||
&keys.k256_signing.secret_key().unwrap(),
|
||||
&keys.k256_rotation.public_multibase,
|
||||
&state.cfg.pds_public_url,
|
||||
)
|
||||
.map_err(|e| internal(e))?;
|
||||
// Submit the op to the PLC directory. We compute the DID
|
||||
// locally via `did_plc_from_op` so the user is usable even when
|
||||
// the outbound PLC submit fails (dev mode, network down,
|
||||
// DNS-blocked). A successful submit publishes the op so the
|
||||
// rest of the network can resolve the handle; failure is logged
|
||||
// and tolerated (matches the original best-effort contract).
|
||||
let plc_cid = match state.plc.submit(&did, &plc_op).await {
|
||||
Ok(c) => {
|
||||
info!("plc op submitted: cid={}", c);
|
||||
info!(
|
||||
did = %did,
|
||||
cid = %c,
|
||||
"plc op submitted; DID registered globally"
|
||||
);
|
||||
Some(c)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("plc submit failed (dev ok): {e:#}");
|
||||
warn!(
|
||||
did = %did,
|
||||
error = %e,
|
||||
"plc submit failed (dev ok): DID stays local; recompute via did_plc_from_op"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,17 +9,42 @@ pub async fn resolve_handle(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ResolveHandleReq>,
|
||||
) -> Result<Json<ResolveHandleResp>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
// Polymorphic input: `handle` may be a bare handle OR a DID
|
||||
// (`did:plc:…`, `did:web:…`, `did:key:…`). When it's a DID we
|
||||
// look the row up by `did` — the AppView's `PdsHandleResolver`
|
||||
// uses this path to fill `posts.handle` for local-PDS users
|
||||
// (including `did:key:`) without a second round-trip to plc.directory.
|
||||
if req.handle.starts_with("did:") {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT handle FROM users WHERE did = $1")
|
||||
.bind(&req.handle)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
if let Some((handle,)) = row {
|
||||
return Ok(Json(ResolveHandleResp {
|
||||
did: req.handle.clone(),
|
||||
handle: Some(handle),
|
||||
}));
|
||||
}
|
||||
// DID not hosted here — fall through to the handle lookup
|
||||
// (returns 404 below).
|
||||
}
|
||||
if let Some(zone) = state.cfg.pds_handle_dns_zone.strip_prefix(".") {
|
||||
if let Some(stripped) = req.handle.strip_suffix(zone) {
|
||||
let user = stripped.trim_end_matches('.');
|
||||
let full = format!("{}{}", user, state.cfg.pds_handle_dns_zone);
|
||||
let row: Option<(String,)> = sqlx::query_as("SELECT did FROM users WHERE handle = $1")
|
||||
.bind(&full)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT did FROM users WHERE handle = $1")
|
||||
.bind(&full)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
if let Some((did,)) = row {
|
||||
return Ok(Json(ResolveHandleResp { did }));
|
||||
return Ok(Json(ResolveHandleResp {
|
||||
did,
|
||||
handle: Some(full),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +54,10 @@ pub async fn resolve_handle(
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
match row {
|
||||
Some((did,)) => Ok(Json(ResolveHandleResp { did })),
|
||||
Some((did,)) => Ok(Json(ResolveHandleResp {
|
||||
did,
|
||||
handle: Some(req.handle.clone()),
|
||||
})),
|
||||
None => {
|
||||
warn!(handle = %req.handle, "handle not found");
|
||||
Err(err(
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod blob;
|
||||
pub mod feed;
|
||||
pub mod helpers;
|
||||
pub mod identity;
|
||||
pub mod profile;
|
||||
pub mod repo;
|
||||
pub mod sync;
|
||||
pub mod types;
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
//! `app.bsky.actor.profile.get` and `app.bsky.actor.profile.set`.
|
||||
//!
|
||||
//! ### `get`
|
||||
//!
|
||||
//! Read the profile record (CBOR-decoded `app.bsky.actor.profile/self`
|
||||
//! value block) for the authenticated user. The handle/did come from
|
||||
//! the JWT; the record is parsed into a JSON object. Returns `null`
|
||||
//! for the `profile` field when the user has no profile yet (a brand
|
||||
//! new account).
|
||||
//!
|
||||
//! ### `set`
|
||||
//!
|
||||
//! Read-modify-write of the user's `app.bsky.actor.profile/self`
|
||||
//! record. The body carries only the fields the caller wants to
|
||||
//! change; the existing record is fetched and the supplied fields
|
||||
//! overwrite the corresponding fields. Best-effort push to the
|
||||
//! AppView follows so the `profiles` cache reflects the new avatar /
|
||||
//! display name / bio without waiting for the Jetstream replay.
|
||||
use crate::jwt_issuer;
|
||||
use crate::routes::helpers::{
|
||||
apply_repo_write, err, load_head_commit, load_signing_key, load_user_blockstore,
|
||||
to_sqlx_error, RepoWriteOutcome,
|
||||
};
|
||||
use crate::routes::types::ErrorBody;
|
||||
use crate::state::AppState;
|
||||
use at_crypto::cid::cid_for_cbor;
|
||||
use at_repo::blockstore::Blockstore as _;
|
||||
use at_repo::repo::Repo;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Blob metadata looked up from the `blobs` table. Used to write
|
||||
/// the `mimeType` / `size` fields of a profile avatar/banner ref —
|
||||
/// these must reflect what the user actually uploaded, not a
|
||||
/// hardcoded constant.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ResolvedBlob {
|
||||
pub mime_type: String,
|
||||
pub size: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GetProfileResp {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
/// `null` when the user has no profile record yet.
|
||||
pub profile: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetProfileReq {
|
||||
/// Optional new display name. `null`/missing preserves the
|
||||
/// existing record's `displayName`.
|
||||
pub display_name: Option<String>,
|
||||
/// Optional new bio / description. `null`/missing preserves.
|
||||
pub description: Option<String>,
|
||||
/// CID of the avatar blob, already uploaded via uploadBlob.
|
||||
/// `null`/missing preserves.
|
||||
pub avatar_blob_cid: Option<String>,
|
||||
/// CID of the banner blob. `null`/missing preserves.
|
||||
pub banner_blob_cid: Option<String>,
|
||||
}
|
||||
|
||||
/// Read the authenticated user's profile record.
|
||||
pub async fn get_profile(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<axum::Json<GetProfileResp>, (StatusCode, axum::Json<ErrorBody>)> {
|
||||
let did = authenticate(&headers, &state)?;
|
||||
let handle: String = sqlx::query_scalar("SELECT handle FROM users WHERE did = $1")
|
||||
.bind(&did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?;
|
||||
|
||||
let profile = read_profile_record(&state, &did).await?;
|
||||
Ok(axum::Json(GetProfileResp { did, handle, profile }))
|
||||
}
|
||||
|
||||
/// Read-modify-write the authenticated user's profile record.
|
||||
pub async fn set_profile(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
axum::Json(req): axum::Json<SetProfileReq>,
|
||||
) -> Result<axum::Json<GetProfileResp>, (StatusCode, axum::Json<ErrorBody>)> {
|
||||
let did = authenticate(&headers, &state)?;
|
||||
let handle: String = sqlx::query_scalar("SELECT handle FROM users WHERE did = $1")
|
||||
.bind(&did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?;
|
||||
|
||||
// Fetch the existing record, if any.
|
||||
let existing = read_profile_record(&state, &did).await?;
|
||||
|
||||
// For any blob CIDs in the request, look up the real
|
||||
// `mime_type` / `size` from the `blobs` table — and verify
|
||||
// ownership (the blob must belong to the authenticated DID).
|
||||
// Without the ownership check a session for DID A could
|
||||
// reference DID B's blob in their profile.
|
||||
let avatar = match req.avatar_blob_cid.as_deref() {
|
||||
Some(cid) => Some(resolve_owned_blob(&state, &did, cid).await?),
|
||||
None => None,
|
||||
};
|
||||
let banner = match req.banner_blob_cid.as_deref() {
|
||||
Some(cid) => Some(resolve_owned_blob(&state, &did, cid).await?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Merge: start from the existing record (or empty object), then
|
||||
// overlay the supplied fields. We use the atproto standard
|
||||
// `app.bsky.actor.profile` schema: displayName (string),
|
||||
// description (string), avatar (blob ref), banner (blob ref).
|
||||
let next = merge_profile_fields(existing, &req, avatar.as_ref(), banner.as_ref());
|
||||
|
||||
// Validate against the lexicon so the user can't push an
|
||||
// arbitrary JSON shape that wouldn't round-trip through a real
|
||||
// atproto client.
|
||||
if let Err(e) = state.lex.validate("app.bsky.actor.profile", &next) {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
format!("lex validation failed: {e}"),
|
||||
));
|
||||
}
|
||||
|
||||
// Encode the new record as CBOR and write it to the user's
|
||||
// `app.bsky.actor.profile/self` MST via the canonical repo-write
|
||||
// path (the same one createRecord uses).
|
||||
let value_cid: Cid = {
|
||||
let mut buf = Vec::new();
|
||||
ciborium::into_writer(&next, &mut buf).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("cbor: {e}"),
|
||||
)
|
||||
})?;
|
||||
cid_for_cbor(&buf).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
e.to_string(),
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
let next_for_block = next.clone();
|
||||
let outcome = apply_repo_write(&state, &did, move |repo| {
|
||||
let value_cid = value_cid;
|
||||
let next_for_block = next_for_block;
|
||||
Box::pin(async move {
|
||||
repo.blockstore
|
||||
.put(&value_cid, Bytes::from({
|
||||
let mut buf = Vec::new();
|
||||
ciborium::into_writer(&next_for_block, &mut buf).unwrap();
|
||||
buf
|
||||
}))
|
||||
.await
|
||||
.map_err(to_sqlx_error)?;
|
||||
let (_uri, _returned_cid) = repo
|
||||
.put_record("app.bsky.actor.profile", "self", value_cid)
|
||||
.await
|
||||
.map_err(to_sqlx_error)?;
|
||||
let commit = repo.commit().await.map_err(to_sqlx_error)?;
|
||||
let head_cid_bytes = commit.cid.to_bytes().to_vec();
|
||||
let head_commit_bytes = commit.signed_bytes.clone();
|
||||
Ok(RepoWriteOutcome {
|
||||
commit,
|
||||
head_cid_bytes,
|
||||
head_commit_bytes,
|
||||
})
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
did = %did,
|
||||
cid = %outcome.commit.cid,
|
||||
"profile record created"
|
||||
);
|
||||
|
||||
// Best-effort push to the AppView so the profile cache reflects
|
||||
// the new avatar / display name / bio without waiting for the
|
||||
// Jetstream `identity` event to plumb through.
|
||||
if let Err(e) = state
|
||||
.appview
|
||||
.push_profile(&did, &handle, &next)
|
||||
.await
|
||||
{
|
||||
warn!(error = %e, "profile push to AppView failed; Jetstream will catch up");
|
||||
}
|
||||
|
||||
Ok(axum::Json(GetProfileResp {
|
||||
did,
|
||||
handle,
|
||||
profile: Some(next),
|
||||
}))
|
||||
}
|
||||
|
||||
// -- internals ----------------------------------------------------------------
|
||||
|
||||
/// Verify a bearer token and return the authenticated DID.
|
||||
fn authenticate(
|
||||
headers: &axum::http::HeaderMap,
|
||||
state: &AppState,
|
||||
) -> Result<String, (StatusCode, axum::Json<ErrorBody>)> {
|
||||
let token = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| {
|
||||
err(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Unauthenticated",
|
||||
"missing Authorization: Bearer header",
|
||||
)
|
||||
})?;
|
||||
let server_pk = jwt_issuer::server_p256_public_multibase(&state.cfg)
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?;
|
||||
let claims = at_crypto::jwt::verify_jwt(token, &server_pk).map_err(|e| {
|
||||
err(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"TokenInvalid",
|
||||
format!("invalid token: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(claims.sub)
|
||||
}
|
||||
|
||||
/// Decode the `app.bsky.actor.profile/self` record for `did`, if any.
|
||||
/// Returns `Ok(None)` when the record doesn't exist.
|
||||
///
|
||||
/// Walk the head commit down to the profile/self leaf, fetch the
|
||||
/// value block, CBOR-decode it into JSON. Mirrors `get_record`'s
|
||||
/// walk in routes/sync.rs.
|
||||
async fn read_profile_record(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
) -> Result<Option<Value>, (StatusCode, axum::Json<ErrorBody>)> {
|
||||
let (head_cid, head_commit_bytes) = match load_head_commit(state, did).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let signing_key_bytes: Vec<u8> =
|
||||
sqlx::query_scalar("SELECT signing_key FROM users WHERE did = $1")
|
||||
.bind(did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("users.signing_key read: {e}"),
|
||||
)
|
||||
})?;
|
||||
let signing_key = load_signing_key(&signing_key_bytes)?;
|
||||
|
||||
let blockstore = load_user_blockstore(state, did).await?;
|
||||
blockstore
|
||||
.put(&head_cid, Bytes::from(head_commit_bytes.clone()))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("blockstore put head: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let repo: Repo<_> = Repo::load(did.to_string(), signing_key, blockstore.clone(), head_cid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo load: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let value_cid = match repo.get_record("app.bsky.actor.profile", "self").await {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => return Ok(None),
|
||||
Err(e) => {
|
||||
return Err(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo.get_record: {e:#}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let value_bytes = match blockstore.get(&value_cid).await.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("blockstore get value: {e:#}"),
|
||||
)
|
||||
})? {
|
||||
Some(b) => b,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let v: Value = ciborium::from_reader(&value_bytes[..]).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("cbor decode: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
/// Overlay `req` onto `existing`, returning the merged profile
|
||||
/// record. Each `Some(_)` field in `req` overwrites the matching
|
||||
/// field; `None` fields are preserved.
|
||||
///
|
||||
/// `avatar` / `banner` are the resolved blobs (looked up from the
|
||||
/// `blobs` table in `set_profile` so we can write the real
|
||||
/// `mimeType` / `size`). When `None`, the avatar/banner fields are
|
||||
/// preserved from `existing`.
|
||||
///
|
||||
/// Extracted from `set_profile` so the merge semantics are testable
|
||||
/// without a running PDS / DB.
|
||||
pub(crate) fn merge_profile_fields(
|
||||
existing: Option<Value>,
|
||||
req: &SetProfileReq,
|
||||
avatar: Option<&ResolvedBlob>,
|
||||
banner: Option<&ResolvedBlob>,
|
||||
) -> Value {
|
||||
let mut next: Value = existing.unwrap_or_else(|| json!({}));
|
||||
if let Some(s) = &req.display_name {
|
||||
next["displayName"] = json!(s);
|
||||
}
|
||||
if let Some(s) = &req.description {
|
||||
next["description"] = json!(s);
|
||||
}
|
||||
if let Some(cid) = &req.avatar_blob_cid {
|
||||
// Caller is responsible for the DB lookup; default to a
|
||||
// png/0 placeholder only if the lookup somehow returned
|
||||
// `None` despite a CID being supplied (shouldn't happen
|
||||
// — `resolve_owned_blob` rejects missing blobs earlier).
|
||||
let (mime_type, size) = avatar
|
||||
.map(|b| (b.mime_type.as_str(), b.size))
|
||||
.unwrap_or(("image/png", 0));
|
||||
next["avatar"] = json!({
|
||||
"$type": "blob",
|
||||
"ref": { "$link": cid },
|
||||
"mimeType": mime_type,
|
||||
"size": size,
|
||||
});
|
||||
}
|
||||
if let Some(cid) = &req.banner_blob_cid {
|
||||
let (mime_type, size) = banner
|
||||
.map(|b| (b.mime_type.as_str(), b.size))
|
||||
.unwrap_or(("image/png", 0));
|
||||
next["banner"] = json!({
|
||||
"$type": "blob",
|
||||
"ref": { "$link": cid },
|
||||
"mimeType": mime_type,
|
||||
"size": size,
|
||||
});
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// Look up a blob by CID and verify it's owned by `did`. The
|
||||
/// ownership check is a security requirement: without it a
|
||||
/// session for DID A could reference DID B's blob in their own
|
||||
/// profile (the value would still resolve at fetch time because
|
||||
/// `com.atproto.sync.getBlob` doesn't check ownership, but the
|
||||
/// invariant "a profile's avatar belongs to that user" would be
|
||||
/// broken).
|
||||
async fn resolve_owned_blob(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
cid: &str,
|
||||
) -> Result<ResolvedBlob, (StatusCode, axum::Json<ErrorBody>)> {
|
||||
let row: Option<(String, i64)> = sqlx::query_as(
|
||||
"SELECT mime_type, size FROM blobs WHERE cid = $1 AND did = $2",
|
||||
)
|
||||
.bind(cid)
|
||||
.bind(did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("blobs lookup: {e}"),
|
||||
)
|
||||
})?;
|
||||
match row {
|
||||
Some((mime_type, size)) => Ok(ResolvedBlob { mime_type, size }),
|
||||
None => Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
format!("blob {cid} not found or not owned by {did}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Round-trip the camelCase JSON the Tauri client sends and
|
||||
/// confirm every field lands on its `snake_case` Rust
|
||||
/// counterpart. Catches regressions of the BLOCKER-3 bug
|
||||
/// (silent camelCase → snake_case mismatch that made every
|
||||
/// set_profile write an empty record).
|
||||
#[test]
|
||||
fn set_profile_req_deserializes_camel_case() {
|
||||
let raw = json!({
|
||||
"displayName": "Alice",
|
||||
"description": "hello",
|
||||
"avatarBlobCid": "bafyavatar",
|
||||
"bannerBlobCid": "bafybanner",
|
||||
});
|
||||
let req: SetProfileReq = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(req.display_name.as_deref(), Some("Alice"));
|
||||
assert_eq!(req.description.as_deref(), Some("hello"));
|
||||
assert_eq!(req.avatar_blob_cid.as_deref(), Some("bafyavatar"));
|
||||
assert_eq!(req.banner_blob_cid.as_deref(), Some("bafybanner"));
|
||||
}
|
||||
|
||||
/// `displayName` only — preserves existing description / avatar.
|
||||
#[test]
|
||||
fn merge_preserves_fields_not_in_req() {
|
||||
let existing = json!({
|
||||
"displayName": "Old",
|
||||
"description": "old bio",
|
||||
"avatar": { "$type": "blob", "ref": { "$link": "old_avatar" } },
|
||||
});
|
||||
let req = SetProfileReq {
|
||||
display_name: Some("New".into()),
|
||||
description: None,
|
||||
avatar_blob_cid: None,
|
||||
banner_blob_cid: None,
|
||||
};
|
||||
let next = merge_profile_fields(Some(existing), &req, None, None);
|
||||
assert_eq!(next["displayName"], "New");
|
||||
assert_eq!(next["description"], "old bio");
|
||||
assert_eq!(
|
||||
next["avatar"]["ref"]["$link"], "old_avatar",
|
||||
"avatar must be preserved when req.avatar_blob_cid is None"
|
||||
);
|
||||
}
|
||||
|
||||
/// All fields set on an empty record — the typical first-write
|
||||
/// path for a brand-new account.
|
||||
#[test]
|
||||
fn merge_into_empty_record() {
|
||||
let req = SetProfileReq {
|
||||
display_name: Some("Alice".into()),
|
||||
description: Some("first bio".into()),
|
||||
avatar_blob_cid: Some("bafyavatar".into()),
|
||||
banner_blob_cid: None,
|
||||
};
|
||||
let avatar = ResolvedBlob {
|
||||
mime_type: "image/png".into(),
|
||||
size: 1234,
|
||||
};
|
||||
let next = merge_profile_fields(None, &req, Some(&avatar), None);
|
||||
assert_eq!(next["displayName"], "Alice");
|
||||
assert_eq!(next["description"], "first bio");
|
||||
assert_eq!(next["avatar"]["ref"]["$link"], "bafyavatar");
|
||||
assert_eq!(next["avatar"]["mimeType"], "image/png");
|
||||
assert_eq!(next["avatar"]["size"], 1234);
|
||||
assert!(next.get("banner").is_none(), "banner must be absent when not set");
|
||||
}
|
||||
|
||||
/// Blob refs must use the modern `{ $type, ref.$link, mimeType,
|
||||
/// size }` shape so the AppView's `blob_link_of` helper can
|
||||
/// parse them back. Locks the wire contract in place.
|
||||
#[test]
|
||||
fn merge_writes_blob_refs_in_modern_shape() {
|
||||
let req = SetProfileReq {
|
||||
display_name: None,
|
||||
description: None,
|
||||
avatar_blob_cid: Some("bafyavatar".into()),
|
||||
banner_blob_cid: None,
|
||||
};
|
||||
let avatar = ResolvedBlob {
|
||||
mime_type: "image/webp".into(),
|
||||
size: 999,
|
||||
};
|
||||
let next = merge_profile_fields(None, &req, Some(&avatar), None);
|
||||
assert_eq!(next["avatar"]["$type"], "blob");
|
||||
assert_eq!(next["avatar"]["ref"]["$link"], "bafyavatar");
|
||||
// Real mime_type from the blobs table — not hardcoded.
|
||||
assert_eq!(next["avatar"]["mimeType"], "image/webp");
|
||||
assert_eq!(next["avatar"]["size"], 999);
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,13 @@ pub struct ResolveHandleReq {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResolveHandleResp {
|
||||
pub did: String,
|
||||
/// Always populated when the lookup succeeds. For
|
||||
/// handle → DID calls this is just the input echo; for
|
||||
/// DID → handle calls this is the resolved local handle
|
||||
/// (used by the AppView's `PdsHandleResolver` to fill
|
||||
/// `posts.handle` without a second round-trip).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub handle: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -38,6 +38,14 @@ impl AppState {
|
||||
"app.bsky.feed.repost".to_string(),
|
||||
Lex::from_json(include_str!("../../../lexicons/app/bsky/feed/repost.json")).unwrap(),
|
||||
);
|
||||
// Profile record — avatar/banner/display name/description.
|
||||
// Validates the createRecord body when the Tauri client calls
|
||||
// its setProfile command. Other fields stay optional so a
|
||||
// brand-new account with an empty profile is legal.
|
||||
lex.lexicons.insert(
|
||||
"app.bsky.actor.profile".to_string(),
|
||||
Lex::from_json(include_str!("../../../lexicons/app/bsky/actor/profile.json")).unwrap(),
|
||||
);
|
||||
let plc_url = cfg.plc_directory_url.clone();
|
||||
// The PDS speaks to the AppView via the cluster-internal URL —
|
||||
// never the public one, because the ingest endpoint is unauth'd
|
||||
|
||||
@@ -113,6 +113,7 @@ async fn post_create(
|
||||
state: tauri::State<'_, AppState>,
|
||||
text: String,
|
||||
embed: Option<serde_json::Value>,
|
||||
reply: Option<pds_client::ReplyRef>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
@@ -130,6 +131,16 @@ async fn post_create(
|
||||
record["embed"] = emb;
|
||||
}
|
||||
}
|
||||
// The `reply` field on a post record (see
|
||||
// `app.bsky.feed.post`) is `{root, parent}` strongRefs. We only
|
||||
// attach it when the caller passes a non-null object; missing
|
||||
// means "top-level post" which is the default.
|
||||
if let Some(rp) = reply {
|
||||
record["reply"] = serde_json::json!({
|
||||
"root": { "uri": rp.root.uri, "cid": rp.root.cid },
|
||||
"parent": { "uri": rp.parent.uri, "cid": rp.parent.cid },
|
||||
});
|
||||
}
|
||||
let resp = state
|
||||
.pds
|
||||
.create_record(&sess.did, "app.twi.post", record, &sess.access_jwt)
|
||||
@@ -273,6 +284,76 @@ async fn unrepost_post(
|
||||
}))
|
||||
}
|
||||
|
||||
/// `follow_user(target_did)` — create an `app.bsky.graph.follow`
|
||||
/// record on the user's PDS pointing at `target_did`. Returns the
|
||||
/// new record's URI (the client caches this in localStorage so it
|
||||
/// can be deleted by `unfollow_user` without an extra round-trip).
|
||||
///
|
||||
/// `subject` in the follow record is just a DID string, not a
|
||||
/// strong-ref — the PDS is the source of truth for which follow
|
||||
/// record belongs to which subject.
|
||||
#[tauri::command]
|
||||
async fn follow_user(
|
||||
state: tauri::State<'_, AppState>,
|
||||
target_did: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
if target_did == sess.did {
|
||||
return Err("can't follow yourself".into());
|
||||
}
|
||||
let record = serde_json::json!({
|
||||
"$type": "app.bsky.graph.follow",
|
||||
"subject": target_did,
|
||||
"createdAt": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let resp = state
|
||||
.pds
|
||||
.create_record(
|
||||
&sess.did,
|
||||
"app.bsky.graph.follow",
|
||||
record,
|
||||
&sess.access_jwt,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"uri": resp.uri,
|
||||
"cid": resp.cid,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `unfollow_user(follow_uri)` — delete the previously-created
|
||||
/// follow record. The client passes the cached URI from its
|
||||
/// `localStorage` so we don't need a separate "list my follows"
|
||||
/// endpoint to find the right rkey.
|
||||
#[tauri::command]
|
||||
async fn unfollow_user(
|
||||
state: tauri::State<'_, AppState>,
|
||||
follow_uri: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
let rkey = rkey_from_uri(&follow_uri)?;
|
||||
let resp = state
|
||||
.pds
|
||||
.delete_record(
|
||||
&sess.did,
|
||||
"app.bsky.graph.follow",
|
||||
&rkey,
|
||||
&sess.access_jwt,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"commit": resp.commit,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn timeline_home(
|
||||
state: tauri::State<'_, AppState>,
|
||||
@@ -543,8 +624,9 @@ pub fn run() {
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
|
||||
|
||||
let state = AppState {
|
||||
pds: PdsHttpClient::new(pds_url),
|
||||
appview: AppViewClient::new(appview_url),
|
||||
pds: PdsHttpClient::new(pds_url.clone()),
|
||||
appview: AppViewClient::new(appview_url.clone()),
|
||||
appview_url,
|
||||
store: store::SessionStore::new(),
|
||||
};
|
||||
|
||||
@@ -715,12 +797,99 @@ pub fn run() {
|
||||
unlike_post,
|
||||
repost_post,
|
||||
unrepost_post,
|
||||
follow_user,
|
||||
unfollow_user,
|
||||
status_pds,
|
||||
fetch_blob,
|
||||
pick_and_upload_image,
|
||||
show_notification,
|
||||
open_external_url,
|
||||
profile_get_record,
|
||||
profile_set,
|
||||
get_api_urls,
|
||||
])
|
||||
.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())
|
||||
}
|
||||
|
||||
/// Frontend-side base URLs the Tauri shell was started with. Used by
|
||||
/// the Svelte components to build absolute fetch URLs — a relative
|
||||
/// `/api/...` resolves against the Vite dev origin (port 1430), not
|
||||
/// the AppView (port 2584), and the Vite server has no proxy
|
||||
/// configured, so the fetch lands on a 404 HTML page and
|
||||
/// `response.json()` throws `SyntaxError`.
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ApiUrls {
|
||||
pds_url: String,
|
||||
appview_url: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_api_urls(state: tauri::State<'_, AppState>) -> ApiUrls {
|
||||
// Sync command — the URLs are immutable for the lifetime of the
|
||||
// Tauri shell (read from MAARCADETWEET_*_URL at startup), so no
|
||||
// async machinery is needed. Returns the AppView URL the
|
||||
// frontend needs; PDS URL is exposed too so future fetch-based
|
||||
// XRPC calls don't have to add their own command.
|
||||
ApiUrls {
|
||||
pds_url: state.pds.base_url.clone(),
|
||||
appview_url: state.appview_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
@@ -43,6 +43,26 @@ pub struct CreateRecordReq {
|
||||
pub record: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Strong reference as defined by
|
||||
/// `com.atproto.repo.strongRef` — `{uri, cid}`. Used inside
|
||||
/// `app.bsky.feed.post#reply` (root + parent) and inside
|
||||
/// `app.bsky.embed.record` (the quoted post).
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct StrongRef {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
}
|
||||
|
||||
/// `app.bsky.feed.post#reply` — the `reply` field on a post
|
||||
/// record. `root` is the topmost ancestor of the thread,
|
||||
/// `parent` is the post being directly replied to. For a
|
||||
/// top-level reply they point at the same `strongRef`.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ReplyRef {
|
||||
pub root: StrongRef,
|
||||
pub parent: StrongRef,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateRecordResp {
|
||||
pub uri: String,
|
||||
@@ -385,3 +405,67 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
pub struct AppState {
|
||||
pub pds: crate::pds_client::PdsHttpClient,
|
||||
pub appview: crate::appview_client::AppViewClient,
|
||||
/// Base URL of the AppView service (`http://host:port`, no
|
||||
/// trailing slash). Stored verbatim so the frontend can build
|
||||
/// absolute URLs for fetch calls — a relative `/api/profile/…`
|
||||
/// would resolve against the Vite dev origin, not the AppView.
|
||||
pub appview_url: String,
|
||||
pub store: crate::store::SessionStore,
|
||||
}
|
||||
|
||||
@@ -34,12 +34,13 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": true,
|
||||
"dialog": true,
|
||||
"active": false,
|
||||
"dialog": false,
|
||||
"endpoints": [
|
||||
"https://releases.maarcadetweet.local/{{target}}/{{arch}}/{{current_version}}"
|
||||
],
|
||||
"pubkey": ""
|
||||
"pubkey": "",
|
||||
"_comment": "Auto-update is disabled for dev. To enable for releases: (1) stand up a release-artifacts server that serves update.json, (2) run `tauri signer generate` and paste the pubkey here, (3) flip active+dialog to true. Capabilities already include `updater:default` so the frontend can request update checks via the plugin once enabled."
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+371
-263
@@ -4,26 +4,31 @@
|
||||
session,
|
||||
pdsStatus,
|
||||
fetchTimeline,
|
||||
fetchProfile,
|
||||
fetchSearch,
|
||||
fetchPost,
|
||||
openExternalUrl,
|
||||
showError,
|
||||
type Session,
|
||||
type Post,
|
||||
type ProfileResponse,
|
||||
} from "./lib/api/client";
|
||||
import NavRail from "./lib/components/NavRail.svelte";
|
||||
import StatusBar from "./lib/components/StatusBar.svelte";
|
||||
import PostCard from "./lib/components/PostCard.svelte";
|
||||
import ComposeBox from "./lib/components/ComposeBox.svelte";
|
||||
import ProfileView from "./lib/components/ProfileView.svelte";
|
||||
import LoginScreen from "./lib/components/LoginScreen.svelte";
|
||||
import Terminal from "./lib/components/Terminal.svelte";
|
||||
import Skeleton from "./lib/components/Skeleton.svelte";
|
||||
import Sidebar from "./lib/components/Sidebar.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 });
|
||||
|
||||
@@ -35,10 +40,16 @@
|
||||
let seenUris: Set<string> = new Set();
|
||||
let _statusTimer: number | undefined;
|
||||
|
||||
// Profile state.
|
||||
let profile: ProfileResponse | null = $state(null);
|
||||
let profileLoading: boolean = $state(false);
|
||||
let profileError: string | null = $state(null);
|
||||
// Home tab strip — "for you" is a placeholder (no real algo yet),
|
||||
// "following" is the live behavior. Mirrors the X-style "For you /
|
||||
// Following" tabs.
|
||||
type HomeTab = "for-you" | "following";
|
||||
let homeTab: HomeTab = $state("following");
|
||||
|
||||
// Search tab strip — only "top" is wired (matches the current
|
||||
// search endpoint). The rest are visually present but disabled.
|
||||
type SearchTab = "top" | "latest" | "people" | "photos";
|
||||
let searchTab: SearchTab = $state("top");
|
||||
|
||||
// Search state.
|
||||
let searchQuery: string = $state("");
|
||||
@@ -53,6 +64,28 @@
|
||||
let threadLoading: boolean = $state(false);
|
||||
let threadError: string | null = $state(null);
|
||||
|
||||
// Reply state — when the user clicks the reply button on a
|
||||
// PostCard, the parent fires `on_reply` with strongRefs. We
|
||||
// stash them here and switch to the compose view; the ComposeBox
|
||||
// reads `replyTo` to render the "Replying to @handle" bar and
|
||||
// attach the reply block on submit.
|
||||
type ReplyTarget = {
|
||||
handle: string;
|
||||
root: { uri: string; cid: string };
|
||||
parent: { uri: string; cid: string };
|
||||
};
|
||||
let replyTo: ReplyTarget | null = $state(null);
|
||||
|
||||
/// Called by PostCard's reply button. Stores the strongRefs and
|
||||
/// routes the user to the compose view.
|
||||
function onReply(target: ReplyTarget) {
|
||||
replyTo = target;
|
||||
view = "compose";
|
||||
}
|
||||
function clearReply() {
|
||||
replyTo = null;
|
||||
}
|
||||
|
||||
// Toasts surfaced by child components via the `maarcadetweet:toast`
|
||||
// window event. We keep the last few so a slow render doesn't
|
||||
// wipe the message before the user reads it.
|
||||
@@ -81,6 +114,17 @@
|
||||
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
|
||||
/// `<ProfileView>` on mount.
|
||||
function openUserProfile(handle: string) {
|
||||
selectedHandle = handle;
|
||||
view = "user";
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
}
|
||||
function closeThread() {
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
@@ -247,8 +291,8 @@
|
||||
void refreshTimeline(true);
|
||||
}
|
||||
if (next === "profile") {
|
||||
const handle = currentUser.handle;
|
||||
void refreshProfile(handle);
|
||||
// ProfileView fetches its own data on mount; nothing to
|
||||
// preload here.
|
||||
}
|
||||
if (next === "search" && searchQuery.trim().length > 0) {
|
||||
scheduleSearch();
|
||||
@@ -317,19 +361,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProfile(handle: string) {
|
||||
profileLoading = true;
|
||||
profileError = null;
|
||||
try {
|
||||
profile = await fetchProfile(handle);
|
||||
} catch (e) {
|
||||
profileError = String(e);
|
||||
profile = null;
|
||||
} finally {
|
||||
profileLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSearch() {
|
||||
if (_searchDebounce) clearTimeout(_searchDebounce);
|
||||
_searchDebounce = window.setTimeout(() => {
|
||||
@@ -372,15 +403,30 @@
|
||||
}
|
||||
|
||||
async function handlePosted() {
|
||||
// After the user posts, reset to page 1 so they see their own post.
|
||||
// After the user posts, reset to page 1 so they see their own
|
||||
// post, and clear any active reply target so the next compose
|
||||
// doesn't re-attach the reply block.
|
||||
replyTo = null;
|
||||
await refreshTimeline(true);
|
||||
}
|
||||
|
||||
/// Wired into the right-rail Sidebar. Fills the search query and
|
||||
/// switches to the search view. If the query is empty we just
|
||||
/// switch to the search view (the input there will keep focus).
|
||||
function onSidebarSearch(query: string) {
|
||||
searchQuery = query;
|
||||
view = "search";
|
||||
if (query.trim().length > 0) {
|
||||
// Run the search immediately so the Sidebar click feels
|
||||
// responsive (no debounce delay).
|
||||
scheduleSearch();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await session.logout();
|
||||
setView("home");
|
||||
profile = null;
|
||||
searchResults = [];
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
@@ -392,13 +438,6 @@
|
||||
// Derive a display handle. The session already gives us the user's
|
||||
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
|
||||
// posts that have empty handles it falls back to a synthetic
|
||||
// "@did:plc:abcd…" form, so the fallback here matches that.
|
||||
function displayHandle(h: string | null | undefined): string {
|
||||
if (!h) return "@unknown";
|
||||
if (h.startsWith("@")) return h;
|
||||
return `@${h}`;
|
||||
}
|
||||
|
||||
// Mirror the URLs the Rust shell reads from MAARCADETWEET_PDS_URL /
|
||||
// MAARCADETWEET_APPVIEW_URL (see `crates/tauri-app/src-tauri/src/lib.rs`).
|
||||
// Used in the Settings view to show which backends the client is
|
||||
@@ -434,6 +473,7 @@
|
||||
on_select={(v) => setView(v)}
|
||||
/>
|
||||
<div class="main">
|
||||
<div class="main-inner">
|
||||
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
|
||||
{#if view === "home"}
|
||||
<div class="head">
|
||||
@@ -442,6 +482,20 @@
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
<span class="meta">→ {userPosts.length} posts · polling every 5s</span>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="Timeline">
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
disabled
|
||||
title="for you — algo coming soon"
|
||||
>for you</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:tab--active={homeTab === "following"}
|
||||
type="button"
|
||||
onclick={() => (homeTab = "following")}
|
||||
>following</button>
|
||||
</nav>
|
||||
{#if timelineError}
|
||||
<div class="toast toast--err">err: {timelineError}</div>
|
||||
{/if}
|
||||
@@ -462,14 +516,14 @@
|
||||
<div class="toast toast--err">err: {threadError}</div>
|
||||
{:else if threadRoot}
|
||||
{#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} on_reply={onReply} /></div>
|
||||
{/if}
|
||||
<PostCard post={threadRoot} />
|
||||
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#each userPosts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
|
||||
{/each}
|
||||
{#if timelineCursor}
|
||||
<div class="loadmore">
|
||||
@@ -486,165 +540,165 @@
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
<span class="meta">⌘↵ to post</span>
|
||||
</div>
|
||||
<ComposeBox onPosted={handlePosted} />
|
||||
{:else if view === "profile"}
|
||||
<ComposeBox
|
||||
onPosted={handlePosted}
|
||||
replyTo={replyTo}
|
||||
onClearReply={clearReply}
|
||||
/>
|
||||
{:else if view === "user"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// profile —</span>
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
<span class="as">@{selectedHandle}</span>
|
||||
</div>
|
||||
{#if profileLoading && !profile}
|
||||
<Skeleton rows={4} />
|
||||
{:else if profileError}
|
||||
<div class="toast toast--err">err: {profileError}</div>
|
||||
{:else if profile}
|
||||
<section class="profile">
|
||||
<header class="profile__head">
|
||||
<div class="profile__handle">{displayHandle(profile.handle)}</div>
|
||||
<div class="profile__did" title={profile.did}>{profile.did}</div>
|
||||
</header>
|
||||
|
||||
<div class="profile__actions">
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
title="Copy DID to clipboard"
|
||||
onclick={() => copyToClipboard(profile!.did)}
|
||||
>copy did</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
title="Copy AT URI to clipboard"
|
||||
onclick={() =>
|
||||
copyToClipboard(`at://${profile!.did}/app.twi.post`)}
|
||||
>copy at-uri</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
title="Open profile in your default browser"
|
||||
onclick={() =>
|
||||
openExternalUrl(
|
||||
`https://bsky.app/profile/${profile!.handle}`,
|
||||
)}
|
||||
>open in browser</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
title="Sign out of this app"
|
||||
onclick={handleLogout}
|
||||
>sign out</button>
|
||||
</div>
|
||||
|
||||
<dl class="counts">
|
||||
<div>
|
||||
<dt>followers</dt>
|
||||
<dd>{profile.followers}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>following</dt>
|
||||
<dd>{profile.following}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>posts</dt>
|
||||
<dd>{profile.posts.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if profile.posts.length === 0}
|
||||
<div class="empty">// no posts yet — compose your first one</div>
|
||||
{:else}
|
||||
<h3 class="profile__h3">// recent posts</h3>
|
||||
{#each profile.posts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
<ProfileView
|
||||
handle={selectedHandle}
|
||||
on_thread_click={openThread}
|
||||
current_user_did={currentUser?.did ?? null}
|
||||
/>
|
||||
{:else if view === "profile"}
|
||||
{#if currentUser}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// profile —</span>
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
</div>
|
||||
<ProfileView
|
||||
handle={currentUser.handle}
|
||||
on_thread_click={openThread}
|
||||
current_user_did={currentUser.did}
|
||||
/>
|
||||
{/if}
|
||||
{:else if view === "settings"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// settings</span>
|
||||
<span class="meta">@{currentUser?.handle ?? "?"}</span>
|
||||
</div>
|
||||
<section class="settings">
|
||||
<h3 class="settings__h3">// account</h3>
|
||||
<dl class="settings__rows">
|
||||
<div>
|
||||
<dt>handle</dt>
|
||||
<dd>@{currentUser?.handle ?? "?"}</dd>
|
||||
<!-- Account — X-style rows: label left, value right, full-width clickable -->
|
||||
<div class="settings__group">
|
||||
<h3 class="settings__h3">// account</h3>
|
||||
<div class="settings__list">
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">handle</span>
|
||||
<span class="settings__value">@{currentUser?.handle ?? "?"}</span>
|
||||
</div>
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">did</span>
|
||||
<code class="settings__value settings__value--mono">{currentUser?.did ?? "?"}</code>
|
||||
</div>
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">posts cached</span>
|
||||
<span class="settings__value">{userPosts.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<dt>did</dt>
|
||||
<dd class="did-cell">{currentUser?.did ?? "?"}</dd>
|
||||
<div class="settings__actions">
|
||||
<button
|
||||
class="settings__action"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
currentUser && copyToClipboard(currentUser.did)}
|
||||
>
|
||||
<span>copy did</span>
|
||||
<span class="settings__action-hint">atproto</span>
|
||||
</button>
|
||||
<button
|
||||
class="settings__action"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
openExternalUrl(
|
||||
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
|
||||
)}
|
||||
>
|
||||
<span>open profile in browser</span>
|
||||
<span class="settings__action-hint">↗ bsky.app</span>
|
||||
</button>
|
||||
<button
|
||||
class="settings__action"
|
||||
type="button"
|
||||
onclick={() => setView("home")}
|
||||
>
|
||||
<span>← back to timeline</span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<dt>posts in cache</dt>
|
||||
<dd>{userPosts.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<h3 class="settings__h3">// actions</h3>
|
||||
<div class="settings__actions">
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
currentUser && copyToClipboard(currentUser.did)}
|
||||
>copy my did</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
openExternalUrl(
|
||||
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
|
||||
)}
|
||||
>open profile in browser</button>
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() => setView("home")}
|
||||
>← back to timeline</button>
|
||||
</div>
|
||||
|
||||
<h3 class="settings__h3">// about</h3>
|
||||
<dl class="settings__rows">
|
||||
<div>
|
||||
<dt>app</dt>
|
||||
<dd>maarcadetweet</dd>
|
||||
<!-- Backend / connection info — same row pattern -->
|
||||
<div class="settings__group">
|
||||
<h3 class="settings__h3">// backend</h3>
|
||||
<div class="settings__list">
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">app</span>
|
||||
<span class="settings__value">maarcadetweet</span>
|
||||
</div>
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">version</span>
|
||||
<span class="settings__value">0.1.0</span>
|
||||
</div>
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">pds</span>
|
||||
<code class="settings__value settings__value--mono">{pdsBase()}</code>
|
||||
</div>
|
||||
<div class="settings__row">
|
||||
<span class="settings__label">appview</span>
|
||||
<code class="settings__value settings__value--mono">{appviewBase()}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<dt>version</dt>
|
||||
<dd>0.1.0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>backend</dt>
|
||||
<dd>{pdsBase()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>appview</dt>
|
||||
<dd>{appviewBase()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="settings__signout">
|
||||
<button
|
||||
class="btn btn--ghost btn--danger"
|
||||
type="button"
|
||||
onclick={handleLogout}
|
||||
>sign out</button>
|
||||
<!-- Sign-out — separate danger zone at the bottom, like X's "Log out" row -->
|
||||
<div class="settings__group settings__group--danger">
|
||||
<div class="settings__list">
|
||||
<button
|
||||
class="settings__action settings__action--danger"
|
||||
type="button"
|
||||
onclick={handleLogout}
|
||||
>
|
||||
<span>sign out</span>
|
||||
<span class="settings__action-hint">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if view === "search"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// search</span>
|
||||
<span class="title">// search —</span>
|
||||
<input
|
||||
class="search"
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
placeholder="grep posts…"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
class="search"
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
placeholder="grep posts…"
|
||||
/>
|
||||
<nav class="tabs" aria-label="Search sections">
|
||||
<button
|
||||
class="tab"
|
||||
class:tab--active={searchTab === "top"}
|
||||
type="button"
|
||||
onclick={() => (searchTab = "top")}
|
||||
>top</button>
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
disabled
|
||||
title="latest — coming soon"
|
||||
>latest</button>
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
disabled
|
||||
title="people — coming soon"
|
||||
>people</button>
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
disabled
|
||||
title="photos — coming soon"
|
||||
>photos</button>
|
||||
</nav>
|
||||
{#if searchError}
|
||||
<div class="toast toast--err">err: {searchError}</div>
|
||||
{/if}
|
||||
@@ -657,11 +711,15 @@
|
||||
{:else}
|
||||
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
|
||||
{#each searchResults as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</Terminal>
|
||||
{#if view === "home"}
|
||||
<Sidebar posts={userPosts} onSearch={onSidebarSearch} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar did={status.did ?? ""} authenticated={status.authenticated} />
|
||||
</div>
|
||||
@@ -751,12 +809,51 @@
|
||||
overflow: auto;
|
||||
padding: var(--s-3);
|
||||
}
|
||||
|
||||
.profile__actions {
|
||||
.main-inner {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
margin: var(--s-3) 0;
|
||||
gap: var(--s-3);
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
.main-inner > :global(.terminal) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Tab strip — mirrors the ProfileView's `.tab` pattern so the
|
||||
home + search tabs read as siblings of the profile tabs. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin: 0 0 var(--s-3);
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: var(--s-3);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition:
|
||||
color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease);
|
||||
}
|
||||
.tab:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
.tab:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.tab--active {
|
||||
color: var(--orange);
|
||||
border-bottom-color: var(--orange);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.head {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
@@ -829,51 +926,6 @@
|
||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.profile {
|
||||
padding: 0 var(--s-3);
|
||||
}
|
||||
.profile__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-3) 0 var(--s-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
.profile__handle {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-200);
|
||||
color: var(--orange);
|
||||
}
|
||||
.profile__did {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
word-break: break-all;
|
||||
}
|
||||
.counts {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
margin: 0 0 var(--s-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.counts > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.counts dt { color: var(--text-dim); letter-spacing: 0.04em; }
|
||||
.counts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-200);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: var(--s-4);
|
||||
@@ -903,68 +955,124 @@
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
/* (the legacy .btn--danger class used to be applied to the
|
||||
"sign out" button — that's now styled via
|
||||
`.settings__group--danger .settings__action` which is its own
|
||||
selector tree in the settings section below) */
|
||||
|
||||
.profile__h3,
|
||||
/* X-style settings page: sectioned cards with label-left /
|
||||
value-right rows, then a list of clickable action rows, then
|
||||
a danger zone at the bottom. Stays monospace + terminal-
|
||||
commented, but the structure is the same as X's. */
|
||||
.settings {
|
||||
padding: 0 var(--s-3) var(--s-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
.settings__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
.settings__h3 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
margin: var(--s-4) 0 var(--s-2);
|
||||
font-weight: 400;
|
||||
color: var(--orange);
|
||||
letter-spacing: var(--tracking-label);
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.did-cell {
|
||||
word-break: break-all;
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
|
||||
.settings {
|
||||
padding: 0 var(--s-3);
|
||||
.settings__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings__rows {
|
||||
/* Each row is a label-left / value-right flex line, separated
|
||||
by a hairline (X uses a single border on each row except the
|
||||
last). */
|
||||
.settings__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
margin: 0 0 var(--s-4);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.settings__rows > div {
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
.settings__list .settings__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.settings__rows dt {
|
||||
.settings__label {
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
min-width: 9rem;
|
||||
letter-spacing: var(--tracking-label);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.settings__rows dd {
|
||||
margin: 0;
|
||||
.settings__value {
|
||||
color: var(--text);
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
min-width: 0;
|
||||
}
|
||||
.settings__value--mono {
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
/* Actions live in their own list — same border-radius but each
|
||||
item is a full-width clickable button. The hint on the right
|
||||
(e.g. "atproto", "↗ bsky.app") is a dim secondary label, the
|
||||
same way X shows the destination on follow / open-in-app
|
||||
rows. */
|
||||
.settings__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
margin: 0 0 var(--s-4);
|
||||
flex-direction: column;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings__signout {
|
||||
margin-top: var(--s-4);
|
||||
padding-top: var(--s-4);
|
||||
border-top: 1px dashed var(--line);
|
||||
.settings__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--dur) var(--ease),
|
||||
color var(--dur) var(--ease);
|
||||
}
|
||||
.settings__actions .settings__action:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.settings__action:hover {
|
||||
background: var(--orange-8);
|
||||
color: var(--orange);
|
||||
}
|
||||
.settings__action-hint {
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.settings__action:hover .settings__action-hint {
|
||||
color: var(--orange);
|
||||
}
|
||||
.settings__group--danger .settings__action {
|
||||
color: var(--red);
|
||||
}
|
||||
.settings__group--danger .settings__action:hover {
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
color: var(--red);
|
||||
}
|
||||
.settings__group--danger {
|
||||
margin-top: var(--s-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,33 @@ async function tauriCall<T>(cmd: string, fallback: T, args?: Record<string, unkn
|
||||
return invoke<T>(cmd, args);
|
||||
}
|
||||
|
||||
/// Base URLs the Tauri shell was started with. Exposed via the
|
||||
/// `get_api_urls` command so the Svelte components can build
|
||||
/// absolute fetch URLs — a relative `/api/...` resolves against
|
||||
/// the Vite dev origin (port 1430), not the AppView (port 2584),
|
||||
/// and `response.json()` then throws `SyntaxError` on the 404
|
||||
/// HTML page. Cached after the first successful call.
|
||||
let _apiUrlsCache: { pdsUrl: string; appviewUrl: string } | null = null;
|
||||
|
||||
export type ApiUrls = { pdsUrl: string; appviewUrl: string };
|
||||
|
||||
/// Fetch the AppView + PDS base URLs from the Rust shell. Returns
|
||||
/// the cached value on subsequent calls.
|
||||
export async function getApiUrls(): Promise<ApiUrls> {
|
||||
if (_apiUrlsCache) return _apiUrlsCache;
|
||||
const urls = await safeInvoke<ApiUrls>("get_api_urls");
|
||||
_apiUrlsCache = urls;
|
||||
return urls;
|
||||
}
|
||||
|
||||
/// Convenience: just the AppView base URL (the only one the UI
|
||||
/// currently needs for direct fetch calls). Same caching as
|
||||
/// `getApiUrls`.
|
||||
export async function getAppviewUrl(): Promise<string> {
|
||||
const { appviewUrl } = await getApiUrls();
|
||||
return appviewUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict variant of `tauriCall` for actions that MUST hit the
|
||||
* Tauri runtime (login, register, logout, post, like, etc.). In
|
||||
@@ -191,6 +218,11 @@ export type Post = {
|
||||
embed?: Embed | null;
|
||||
langs: string[];
|
||||
created_at: string;
|
||||
like_count?: number;
|
||||
repost_count?: number;
|
||||
/// 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 = {
|
||||
@@ -204,6 +236,11 @@ export type ProfileResponse = {
|
||||
posts: Post[];
|
||||
followers: number;
|
||||
following: number;
|
||||
display_name?: string | null;
|
||||
description?: string | null;
|
||||
avatar_cid?: string | null;
|
||||
banner_cid?: string | null;
|
||||
post_count: number;
|
||||
};
|
||||
|
||||
export type SearchResponse = {
|
||||
@@ -228,9 +265,20 @@ export type ThreadResponse = {
|
||||
repost_count?: number;
|
||||
};
|
||||
|
||||
/// Reply block for `app.bsky.feed.post#reply`. Both `root` and
|
||||
/// `parent` are `com.atproto.repo.strongRef`s (uri + cid). For a
|
||||
/// top-level reply to a single post, `root` and `parent` point at
|
||||
/// the same strongRef. The Rust `post_create` command wires this
|
||||
/// onto the record's `reply` field.
|
||||
export type ReplyRef = {
|
||||
root: { uri: string; cid: string };
|
||||
parent: { uri: string; cid: string };
|
||||
};
|
||||
|
||||
export async function createPost(
|
||||
text: string,
|
||||
embed?: unknown | null,
|
||||
reply?: ReplyRef | null,
|
||||
): Promise<Post> {
|
||||
// The Rust post_create command returns a different shape (uri+cid
|
||||
// only), but we keep the call simple: it gives us the cid we need
|
||||
@@ -238,9 +286,12 @@ export async function createPost(
|
||||
// `embed` is forwarded verbatim; the caller is responsible for
|
||||
// shaping it as an `app.bsky.embed.images` / `.external` / etc.
|
||||
// record. Pass `null` or `undefined` to omit.
|
||||
// `reply` is the reply block (root + parent strongRefs); `null` or
|
||||
// `undefined` means "top-level post" (no reply block on the record).
|
||||
return await safeInvoke<any>("post_create", {
|
||||
text,
|
||||
embed: embed ?? null,
|
||||
reply: reply ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -370,10 +421,28 @@ export async function unrepostPost(
|
||||
return await safeInvoke<DeleteRecordResult>("unrepost_post", { repostUri });
|
||||
}
|
||||
|
||||
/// Fire-and-forget user-visible error toast. Implemented as a
|
||||
/// `window` `CustomEvent` so any component can show errors without
|
||||
/// pulling in a global store. `App.svelte` listens for the event
|
||||
/// and renders the toast UI.
|
||||
/// `followUser(targetDid)` — create an `app.bsky.graph.follow` record
|
||||
/// on the user's PDS. Returns `{ uri, cid }` — the client caches
|
||||
/// `uri` in localStorage so `unfollowUser(uri)` can delete the
|
||||
/// record without needing a "list my follows" round-trip.
|
||||
export async function followUser(
|
||||
targetDid: string,
|
||||
): Promise<RepoWriteResult> {
|
||||
return await safeInvoke<RepoWriteResult>("follow_user", { targetDid });
|
||||
}
|
||||
|
||||
export async function unfollowUser(
|
||||
followUri: string,
|
||||
): Promise<DeleteRecordResult> {
|
||||
return await safeInvoke<DeleteRecordResult>("unfollow_user", {
|
||||
followUri,
|
||||
});
|
||||
}
|
||||
|
||||
/// Fire-and-forget user-visible toast. Implemented as a `window`
|
||||
/// `CustomEvent` so any component can show toasts without pulling
|
||||
/// in a global store. `App.svelte` listens for the event and
|
||||
/// renders the toast UI.
|
||||
export function showError(text: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
@@ -381,6 +450,13 @@ export function showError(text: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
export function showInfo(text: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("maarcadetweet:toast", { detail: { kind: "info", text } }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a native OS notification. Thin wrapper around the
|
||||
/// `show_notification` Tauri command. The Rust side also emits an
|
||||
/// `app://notification` event with the same payload, so the click
|
||||
@@ -429,6 +505,33 @@ export async function listenTrayEvents(
|
||||
/// browser preview where no Tauri runtime is present, fall back
|
||||
/// to `window.open` and treat a popup-blocker denial as
|
||||
/// "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> {
|
||||
try {
|
||||
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>
|
||||
@@ -8,27 +8,37 @@
|
||||
releaseBlob,
|
||||
session,
|
||||
type Post,
|
||||
type Session,
|
||||
type ReplyRef,
|
||||
} from "../api/client";
|
||||
import Avatar from "./Avatar.svelte";
|
||||
|
||||
/// Reply target. The parent/root strongRefs are required so the
|
||||
/// resulting post can carry the `reply` block on its record.
|
||||
type ReplyTarget = {
|
||||
handle: string;
|
||||
root: { uri: string; cid: string };
|
||||
parent: { uri: string; cid: string };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onPosted?: () => void;
|
||||
replyTo?: ReplyTarget | null;
|
||||
onClearReply?: () => void;
|
||||
};
|
||||
|
||||
const MAX = 160;
|
||||
let { onPosted }: { onPosted?: () => void } = $props();
|
||||
let text: string = $state("");
|
||||
let isPosting: boolean = $state(false);
|
||||
let isAttaching: boolean = $state(false);
|
||||
let { onPosted, replyTo = null, onClearReply }: Props = $props();
|
||||
let text = $state("");
|
||||
let isPosting = $state(false);
|
||||
let isAttaching = $state(false);
|
||||
let status: { kind: "ok" | "err" | "info"; msg: string } | null = $state(null);
|
||||
let currentUser: Session | null = $state(null);
|
||||
|
||||
// Currently logged-in user. We need the DID for `fetchBlob` (the
|
||||
// PDS endpoint keys blobs by `(did, cid)`), so the compose box
|
||||
// subscribes to the session store rather than taking a prop.
|
||||
let did: string = $state("");
|
||||
$effect(() => {
|
||||
const u = $session;
|
||||
did = u?.did ?? "";
|
||||
currentUser = $session;
|
||||
});
|
||||
|
||||
// The currently-attached image. `null` = no attachment. We hold
|
||||
// the blob reference + a local object URL for the preview so the
|
||||
// user sees the image before they post.
|
||||
let attachment: {
|
||||
cid: string;
|
||||
mimeType: string;
|
||||
@@ -36,267 +46,417 @@
|
||||
previewUrl: string;
|
||||
} | null = $state(null);
|
||||
|
||||
let remaining = $derived(MAX - text.length);
|
||||
let counterClass = $derived(
|
||||
remaining < 0 ? "counter counter--err" :
|
||||
remaining < 40 ? "counter counter--warn" : "counter"
|
||||
// Count graphemes, not UTF-16 code units. atproto enforces
|
||||
// `maxLength: 160` as graphemes, so a single 🚀 (surrogate pair)
|
||||
// must count as 1, not 2. `Intl.Segmenter` is built into the
|
||||
// runtime — no dependency needed.
|
||||
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
|
||||
const count = $derived(text.trim().length === 0 ? 0 : [...seg.segment(text)].length);
|
||||
const isTooLong = $derived(count > MAX);
|
||||
const isAtMax = $derived(count === MAX);
|
||||
// atproto's `maxLength: 160` is inclusive of the 160th grapheme
|
||||
// — the spec rejects any record whose text length exceeds 160.
|
||||
// So we treat `count === MAX` as "exactly at the cap, still
|
||||
// shippable" and only flag as an error on strict overflow.
|
||||
const counterClass = $derived(
|
||||
isTooLong
|
||||
? "counter counter--err"
|
||||
: isAtMax
|
||||
? "counter counter--warn"
|
||||
: count >= MAX - 30
|
||||
? "counter counter--warn"
|
||||
: "counter",
|
||||
);
|
||||
const canPost = $derived(!!text.trim() && !isTooLong && !isPosting);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
post();
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void post();
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MiB`;
|
||||
function fmtBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
|
||||
}
|
||||
|
||||
async function attach() {
|
||||
if (isAttaching || attachment) return;
|
||||
if (!did) {
|
||||
status = { kind: "err", msg: "> log in first" };
|
||||
if (!currentUser?.did) {
|
||||
status = { kind: "err", msg: "log in to add an image" };
|
||||
return;
|
||||
}
|
||||
isAttaching = true;
|
||||
status = { kind: "info", msg: "> picking…" };
|
||||
status = null;
|
||||
try {
|
||||
const blob = await pickAndUploadImage();
|
||||
if (!blob) {
|
||||
// User cancelled — restore the previous status rather than
|
||||
// leaving the "picking…" message on screen.
|
||||
status = null;
|
||||
return;
|
||||
}
|
||||
// Fetch the bytes back from the PDS so we can render the
|
||||
// preview. `fetchBlob` caches by CID, so re-rendering the
|
||||
// preview after a re-attach is cheap.
|
||||
const previewUrl = await fetchBlob(did, blob.cid);
|
||||
if (!blob) return;
|
||||
const previewUrl = await fetchBlob(currentUser.did, blob.cid);
|
||||
attachment = { ...blob, previewUrl };
|
||||
status = { kind: "info", msg: `> attached (${fmtBytes(blob.size)})` };
|
||||
} catch (e) {
|
||||
status = { kind: "err", msg: `> ${String(e)}` };
|
||||
} catch (error) {
|
||||
status = { kind: "err", msg: String(error) };
|
||||
} finally {
|
||||
isAttaching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment() {
|
||||
if (attachment) {
|
||||
// Revoke the object URL. `fetchBlob` may have evicted the
|
||||
// cache entry for a different reason, so tolerate a no-op.
|
||||
// The user can re-attach — the next fetch will allocate a
|
||||
// fresh URL.
|
||||
releaseBlob(did, attachment.cid);
|
||||
attachment = null;
|
||||
}
|
||||
if (!attachment || !currentUser?.did) return;
|
||||
releaseBlob(currentUser.did, attachment.cid);
|
||||
attachment = null;
|
||||
}
|
||||
|
||||
async function post() {
|
||||
if (!text.trim() || remaining < 0 || isPosting) return;
|
||||
if (!canPost) return;
|
||||
isPosting = true;
|
||||
status = { kind: "info", msg: "> posting…" };
|
||||
status = { kind: "info", msg: "posting…" };
|
||||
try {
|
||||
const embed = attachment ? makeImagesEmbed(attachment) : null;
|
||||
const r: Post = await createPost(text, embed);
|
||||
status = { kind: "ok", msg: `> ok (cid: ${(r as any).cid?.slice?.(0, 8) ?? "?"}…)` };
|
||||
const reply: ReplyRef | null = replyTo
|
||||
? { root: replyTo.root, parent: replyTo.parent }
|
||||
: null;
|
||||
const response: Post = await createPost(text.trim(), embed, reply);
|
||||
status = {
|
||||
kind: "ok",
|
||||
msg: `posted · cid ${(response as any).cid?.slice?.(0, 8) ?? "?"}…`,
|
||||
};
|
||||
text = "";
|
||||
removeAttachment();
|
||||
onPosted?.();
|
||||
} catch (e) {
|
||||
status = { kind: "err", msg: `> ${String(e)}` };
|
||||
showError(`post failed: ${e}`);
|
||||
} catch (error) {
|
||||
status = { kind: "err", msg: String(error) };
|
||||
showError(`post failed: ${error}`);
|
||||
} finally {
|
||||
isPosting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="compose">
|
||||
<div class="compose__head">
|
||||
<span class="title">// compose</span>
|
||||
<span class="handle">@you</span>
|
||||
<span class={counterClass}>{remaining}</span>
|
||||
<section class="compose" aria-label={replyTo ? `Reply to @${replyTo.handle}` : "Compose a post"}>
|
||||
<div class="compose__avatar">
|
||||
<Avatar
|
||||
did={currentUser?.did ?? ""}
|
||||
name={currentUser?.handle ?? "you"}
|
||||
size={40}
|
||||
/>
|
||||
</div>
|
||||
<div class="compose__body">
|
||||
<span class="prompt">$</span>
|
||||
|
||||
<div class="compose__content">
|
||||
{#if replyTo}
|
||||
<div class="replying">
|
||||
<span>Replying to <b>@{replyTo.handle}</b></span>
|
||||
<button type="button" onclick={onClearReply} title="cancel reply" aria-label="Cancel reply">×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<textarea
|
||||
bind:value={text}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="// what's happening in 160 chars?"
|
||||
placeholder={replyTo ? `Reply to @${replyTo.handle}…` : "What's happening?"}
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
maxlength={MAX}
|
||||
aria-label="Post text"
|
||||
></textarea>
|
||||
</div>
|
||||
{#if attachment}
|
||||
<div class="compose__attach">
|
||||
<img
|
||||
class="compose__preview"
|
||||
src={attachment.previewUrl}
|
||||
alt="attachment preview"
|
||||
/>
|
||||
<div class="compose__attach-meta">
|
||||
<span class="compose__attach-cid" title={attachment.cid}>cid: {attachment.cid.slice(0, 10)}…</span>
|
||||
<span class="compose__attach-mime">{attachment.mimeType}</span>
|
||||
<span class="compose__attach-size">{fmtBytes(attachment.size)}</span>
|
||||
|
||||
{#if attachment}
|
||||
<div class="attachment">
|
||||
<img src={attachment.previewUrl} alt="Attachment preview" />
|
||||
<div class="attachment__meta">
|
||||
<span>{attachment.mimeType}</span>
|
||||
<span>{fmtBytes(attachment.size)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment__remove"
|
||||
onclick={removeAttachment}
|
||||
disabled={isPosting}
|
||||
title="remove image"
|
||||
aria-label="Remove image"
|
||||
>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="compose__footer">
|
||||
<button
|
||||
type="button"
|
||||
class="compose__attach-remove"
|
||||
onclick={removeAttachment}
|
||||
disabled={isPosting}
|
||||
title="remove attachment"
|
||||
>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="compose__foot">
|
||||
<span class="hint">⌘↵ to post</span>
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--ghost"
|
||||
class="media-button"
|
||||
onclick={attach}
|
||||
disabled={isAttaching || !!attachment || isPosting}
|
||||
title={attachment ? "image already attached" : "attach image"}
|
||||
title={attachment ? "one image already attached" : "add image"}
|
||||
>
|
||||
{isAttaching ? "picking…" : "📎"}
|
||||
</button>
|
||||
<button class="btn btn--ghost" onclick={() => (text = "")} disabled={!text || isPosting}>draft</button>
|
||||
<button class="btn btn--primary" onclick={post} disabled={!text.trim() || remaining < 0 || isPosting}>
|
||||
{isPosting ? "posting…" : "post"}
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<circle cx="8.5" cy="9" r="1.5" />
|
||||
<path d="m4 17 5-5 4 4 3-3 4 4" />
|
||||
</svg>
|
||||
<span>{isAttaching ? "adding…" : "image"}</span>
|
||||
</button>
|
||||
|
||||
<div class="compose__submit">
|
||||
<span class={counterClass}>{count}/{MAX}</span>
|
||||
<span class="divider" aria-hidden="true"></span>
|
||||
<button
|
||||
class="post-button"
|
||||
type="button"
|
||||
onclick={post}
|
||||
disabled={!canPost}
|
||||
>
|
||||
{isPosting ? "Posting…" : replyTo ? "Reply" : "Post"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if status}
|
||||
<div class="status status--{status.kind}" role="status">{status.msg}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if status}
|
||||
<div class="status status--{status.kind}">{status.msg}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.compose {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
margin: var(--s-4) var(--s-5);
|
||||
}
|
||||
.compose__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
background: var(--bg-deep);
|
||||
padding: var(--s-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.title { color: var(--orange); }
|
||||
.handle { color: var(--text-dim); flex: 1; }
|
||||
.counter { color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
.counter--warn { color: var(--orange); }
|
||||
.counter--err { color: var(--red); letter-spacing: 0.05em; }
|
||||
.compose__body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
}
|
||||
.prompt {
|
||||
color: var(--orange);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
line-height: 1.7;
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-100);
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
textarea::placeholder { color: var(--text-dim); }
|
||||
.compose__attach {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
background: var(--bg-deep);
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
.compose__preview {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg);
|
||||
|
||||
.compose__avatar {
|
||||
padding-top: 2px;
|
||||
}
|
||||
.compose__attach-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
|
||||
.compose__content {
|
||||
min-width: 0;
|
||||
}
|
||||
.compose__attach-cid { color: var(--cid-fg); }
|
||||
.compose__attach-mime,
|
||||
.compose__attach-size { font-variant-numeric: tabular-nums; }
|
||||
.compose__attach-remove {
|
||||
background: transparent;
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.compose__attach-remove:hover:not(:disabled) {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.compose__attach-remove:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.compose__foot {
|
||||
|
||||
.replying {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-2) var(--s-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.hint { font-family: var(--font-mono); font-size: var(--fs-50); color: var(--text-dim); }
|
||||
.actions { display: flex; gap: var(--s-2); }
|
||||
.btn {
|
||||
margin-bottom: var(--s-2);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.replying b {
|
||||
color: var(--orange);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.replying button {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||
}
|
||||
.btn--ghost { color: var(--text-dim); border-color: var(--line-2); background: transparent; }
|
||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
||||
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
|
||||
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.status {
|
||||
|
||||
.replying button:hover {
|
||||
background: var(--orange-8);
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
padding: var(--s-1) 0 var(--s-3);
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-200);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.attachment {
|
||||
position: relative;
|
||||
margin-bottom: var(--s-3);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.attachment img {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
}
|
||||
|
||||
.attachment__remove {
|
||||
position: absolute;
|
||||
top: var(--s-2);
|
||||
right: var(--s-2);
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-elev);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment__remove:hover:not(:disabled) {
|
||||
border-color: var(--orange);
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.compose__footer,
|
||||
.compose__submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compose__footer {
|
||||
min-height: 40px;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-3);
|
||||
padding-top: var(--s-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.status--ok { color: var(--green); }
|
||||
.status--err { color: var(--red); }
|
||||
.status--info { color: var(--orange); }
|
||||
|
||||
.compose__submit {
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.media-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-2);
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: transparent;
|
||||
color: var(--orange);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.media-button:hover:not(:disabled) {
|
||||
background: var(--orange-8);
|
||||
}
|
||||
|
||||
.media-button svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.media-button:disabled,
|
||||
.post-button:disabled,
|
||||
.attachment__remove:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.counter {
|
||||
min-width: 5.5rem;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.counter--warn {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.counter--err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--line-2);
|
||||
}
|
||||
|
||||
.post-button {
|
||||
min-width: 76px;
|
||||
padding: 0.55rem 1rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--orange);
|
||||
color: var(--bg-deep);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.post-button:hover:not(:disabled) {
|
||||
background: var(--orange-bright);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: var(--s-2);
|
||||
padding-top: var(--s-2);
|
||||
border-top: 1px dashed var(--line);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
|
||||
.status--ok {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.status--err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.status--info {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.compose {
|
||||
padding-inline: var(--s-3);
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-size: var(--fs-100);
|
||||
}
|
||||
|
||||
.media-button span,
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,10 @@
|
||||
|
||||
let { onLogin }: { onLogin: (s: Session) => void } = $props();
|
||||
|
||||
let mode: "login" | "register" = $state("register");
|
||||
// Default to "login" — most users opening the app already have an
|
||||
// account, and the empty-autocomplete form now matches the
|
||||
// action-label pair they expect. "register" is one click away.
|
||||
let mode: "login" | "register" = $state("login");
|
||||
let handle: string = $state("");
|
||||
let password: string = $state("");
|
||||
let busy = $state(false);
|
||||
@@ -42,47 +45,46 @@
|
||||
<div class="t">maarcadetweet — {mode}</div>
|
||||
</div>
|
||||
<div class="terminal-body">
|
||||
<div class="line">
|
||||
<span class="prompt">$</span> maarcadetweet {mode}
|
||||
</div>
|
||||
<div class="line muted">// the timeline that fits in 160 chars.</div>
|
||||
<div class="line"> </div>
|
||||
<h1 class="brand">maarcadetweet</h1>
|
||||
<p class="tagline">// the timeline that fits in 160 chars.</p>
|
||||
{#if serverInfo}
|
||||
<div class="line muted">// pds: {serverInfo.did ?? "?"}</div>
|
||||
<div class="line muted">// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</div>
|
||||
<div class="meta">
|
||||
<span>// pds: {serverInfo.did ?? "?"}</span>
|
||||
<span>// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="line"> </div>
|
||||
<div class="form">
|
||||
<label>
|
||||
<span class="key">handle:</span>
|
||||
<form class="form" onsubmit={(e) => { e.preventDefault(); submit(); }}>
|
||||
<label class="field">
|
||||
<span class="key">handle</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={handle}
|
||||
placeholder="alice.maarcadetweet.local"
|
||||
disabled={busy}
|
||||
autocomplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="key">password:</span>
|
||||
<label class="field">
|
||||
<span class="key">password</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="≥ 8 chars"
|
||||
disabled={busy}
|
||||
onkeydown={(e) => e.key === "Enter" && submit()}
|
||||
autocomplete={mode === "register" ? "new-password" : "current-password"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
{#if error}
|
||||
<div class="line err">error: {error}</div>
|
||||
<div class="err">err: {error}</div>
|
||||
{/if}
|
||||
<div class="line"> </div>
|
||||
<div class="line">
|
||||
<div class="actions">
|
||||
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
|
||||
{busy ? "..." : mode === "register" ? "create account" : "login"}
|
||||
{busy ? "..." : mode === "register" ? "create account" : "log in"}
|
||||
</button>
|
||||
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
|
||||
{mode === "register" ? "have an account? login" : "no account? register"}
|
||||
{mode === "register" ? "have an account? log in" : "no account? register"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +96,8 @@
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
width: min(560px, 92vw);
|
||||
width: min(480px, 92vw);
|
||||
box-shadow: 0 24px 60px -28px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.terminal-head {
|
||||
display: flex;
|
||||
@@ -121,49 +124,109 @@
|
||||
}
|
||||
.terminal-body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.85;
|
||||
padding: var(--s-5);
|
||||
padding: var(--s-6) var(--s-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
.brand {
|
||||
margin: 0;
|
||||
color: var(--orange);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-400);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--lh-tight);
|
||||
text-align: center;
|
||||
}
|
||||
.tagline {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
text-align: center;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
text-align: center;
|
||||
}
|
||||
.line { white-space: pre-wrap; }
|
||||
.muted { color: var(--text-dim); }
|
||||
.prompt { color: var(--orange); }
|
||||
.err { color: var(--red); }
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
margin: var(--s-4) 0;
|
||||
margin-top: var(--s-3);
|
||||
}
|
||||
.form label {
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
.key {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
letter-spacing: var(--tracking-label);
|
||||
}
|
||||
.key { color: var(--orange); width: 90px; flex-shrink: 0; }
|
||||
.form input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--r-sm);
|
||||
outline: none;
|
||||
transition: border-color var(--dur) var(--ease);
|
||||
}
|
||||
.form input:focus { border-color: var(--orange); }
|
||||
.btn {
|
||||
.form input::placeholder { color: var(--text-dim); }
|
||||
.err {
|
||||
color: var(--red);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.5rem 0.8rem;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-left: 3px solid var(--red);
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
margin-top: var(--s-3);
|
||||
}
|
||||
.btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
margin-right: var(--s-2);
|
||||
text-align: center;
|
||||
transition:
|
||||
background var(--dur) var(--ease),
|
||||
color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease);
|
||||
}
|
||||
.btn--primary {
|
||||
background: var(--orange);
|
||||
color: #1a0d00;
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
|
||||
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
|
||||
.btn--ghost { background: transparent; color: var(--text-dim); border-color: var(--line-2); }
|
||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
}
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// `$bindable`, use a callback prop to bubble state changes up to
|
||||
// the parent.
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
|
||||
|
||||
let {
|
||||
view = "home",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
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");
|
||||
</script>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,811 @@
|
||||
<script lang="ts">
|
||||
import Avatar from "./Avatar.svelte";
|
||||
import PostCard from "./PostCard.svelte";
|
||||
import {
|
||||
setMyProfile,
|
||||
pickAndUploadImage,
|
||||
fetchBlob,
|
||||
releaseBlob,
|
||||
getAppviewUrl,
|
||||
followUser,
|
||||
unfollowUser,
|
||||
showInfo,
|
||||
showError,
|
||||
} from "../api/client";
|
||||
import { localStorageKey } from "../utils/localstorage";
|
||||
import { onDestroy, onMount, untrack } 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; the
|
||||
/// /user-profile/<handle> route is then the user's own
|
||||
/// profile (and the avatar / bio are editable).
|
||||
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);
|
||||
// Type-annotated so TypeScript keeps the discriminated-union narrowing
|
||||
// when we do `viewModel.kind === "ready"` — otherwise $state infers
|
||||
// the literal `"loading"` from the initial value and the `===`
|
||||
// checks become "no overlap" errors.
|
||||
let viewModel: State = $state({ kind: "loading" } as State);
|
||||
let editName: string = $state("");
|
||||
let editDesc: string = $state("");
|
||||
let editAvatarCid: string | null = $state(null);
|
||||
let saving: boolean = $state(false);
|
||||
|
||||
// Follow state — the AppView has no `viewer_followed` field yet, so
|
||||
// we persist per-viewer follow state in localStorage (keyed by
|
||||
// viewer-did + target-did). `followUri` is the URI of the
|
||||
// `app.bsky.graph.follow` record on the viewer's PDS — the unfollow
|
||||
// command needs it because atproto requires the rkey to delete a
|
||||
// record, and we don't have a "list my follows" endpoint to look
|
||||
// it up server-side.
|
||||
let isFollowing: boolean = $state(false);
|
||||
let followUri: string | null = $state(null);
|
||||
let followBusy: boolean = $state(false);
|
||||
|
||||
// Banner blob URL — fetched via the same path as Avatar (Tauri
|
||||
// getBlob via fetchBlob). Released on unmount or when banner
|
||||
// changes.
|
||||
let bannerUrl: string | null = $state(null);
|
||||
let bannerCidLoaded: string | null = null;
|
||||
|
||||
async function load() {
|
||||
viewModel = { kind: "loading" };
|
||||
try {
|
||||
// Absolute URL because the Tauri webview's origin is the Vite
|
||||
// dev server (port 1430), not the AppView (port 2584) — a
|
||||
// relative `/api/profile/…` would resolve against Vite, hit a
|
||||
// 404 HTML page, and `r.json()` would throw `SyntaxError`.
|
||||
const base = await getAppviewUrl();
|
||||
const r = await fetch(`${base}/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) };
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const bannerCid =
|
||||
viewModel.kind === "ready" ? viewModel.data.banner_cid ?? null : null;
|
||||
// Release the previous URL whenever the banner CID changes
|
||||
// (including to/from null). We read the previous-loaded value
|
||||
// through `untrack` because reading + writing `bannerCidLoaded`
|
||||
// inside the same effect would trip Svelte 5's depth guard
|
||||
// (`effect_update_depth_exceeded`).
|
||||
const previous = untrack(() => bannerCidLoaded);
|
||||
if (previous === bannerCid) return;
|
||||
if (bannerUrl) {
|
||||
if (viewModel.kind === "ready" && viewModel.data.did) {
|
||||
releaseBlob(viewModel.data.did, previous ?? "");
|
||||
}
|
||||
URL.revokeObjectURL(bannerUrl);
|
||||
bannerUrl = null;
|
||||
}
|
||||
bannerCidLoaded = bannerCid;
|
||||
if (!bannerCid || viewModel.kind !== "ready") return;
|
||||
const did = viewModel.data.did;
|
||||
let cancelled = false;
|
||||
fetchBlob(did, bannerCid)
|
||||
.then((u) => {
|
||||
if (!cancelled) bannerUrl = u;
|
||||
else URL.revokeObjectURL(u);
|
||||
})
|
||||
.catch(() => {
|
||||
/* fall back to CSS gradient placeholder */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (bannerUrl) URL.revokeObjectURL(bannerUrl);
|
||||
});
|
||||
|
||||
const isOwn = $derived(
|
||||
!!current_user_did &&
|
||||
viewModel.kind === "ready" &&
|
||||
viewModel.data.did === current_user_did,
|
||||
);
|
||||
const isEmptyProfile = $derived(
|
||||
viewModel.kind === "ready" &&
|
||||
!viewModel.data.display_name &&
|
||||
!viewModel.data.description &&
|
||||
!viewModel.data.avatar_cid &&
|
||||
!viewModel.data.banner_cid,
|
||||
);
|
||||
|
||||
// Restore follow state from localStorage whenever the profile
|
||||
// (DID) changes. Writes are inside `untrack` so the effect's
|
||||
// reactive dep set is just `[viewModel.kind, viewModel.data.did]`
|
||||
// — without untrack, every write to `isFollowing` / `followUri`
|
||||
// would re-enter the effect and trip Svelte's depth guard.
|
||||
$effect(() => {
|
||||
if (viewModel.kind !== "ready" || !current_user_did) return;
|
||||
const did = viewModel.data.did;
|
||||
const key = localStorageKey(`follow:${current_user_did}:${did}`);
|
||||
untrack(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
following: boolean;
|
||||
uri: string | null;
|
||||
};
|
||||
isFollowing = !!parsed.following;
|
||||
followUri = parsed.uri ?? null;
|
||||
} else {
|
||||
isFollowing = false;
|
||||
followUri = null;
|
||||
}
|
||||
} catch {
|
||||
isFollowing = false;
|
||||
followUri = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function persistFollow(following: boolean, uri: string | null) {
|
||||
if (viewModel.kind !== "ready" || !current_user_did) return;
|
||||
const did = viewModel.data.did;
|
||||
const key = localStorageKey(`follow:${current_user_did}:${did}`);
|
||||
try {
|
||||
if (following) {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ following: true, uri }),
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
/* quota / private mode — fall through */
|
||||
}
|
||||
}
|
||||
|
||||
async function onFollowClick() {
|
||||
if (viewModel.kind !== "ready" || !current_user_did) return;
|
||||
if (followBusy) return;
|
||||
const targetDid = viewModel.data.did;
|
||||
if (targetDid === current_user_did) return;
|
||||
followBusy = true;
|
||||
const wasFollowing = isFollowing;
|
||||
const previousUri = followUri;
|
||||
isFollowing = true;
|
||||
try {
|
||||
const resp = await followUser(targetDid);
|
||||
followUri = resp.uri;
|
||||
persistFollow(true, resp.uri);
|
||||
showInfo("followed");
|
||||
} catch (e) {
|
||||
isFollowing = wasFollowing;
|
||||
followUri = previousUri;
|
||||
persistFollow(wasFollowing, previousUri);
|
||||
showError(`follow failed: ${e}`);
|
||||
} finally {
|
||||
followBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnfollowClick() {
|
||||
if (viewModel.kind !== "ready") return;
|
||||
if (followBusy) return;
|
||||
if (!followUri) {
|
||||
// Nothing to unfollow — clear the flag and bail.
|
||||
isFollowing = false;
|
||||
return;
|
||||
}
|
||||
followBusy = true;
|
||||
const wasFollowing = isFollowing;
|
||||
const previousUri = followUri;
|
||||
isFollowing = false;
|
||||
followUri = null;
|
||||
persistFollow(false, null);
|
||||
try {
|
||||
await unfollowUser(previousUri!);
|
||||
showInfo("unfollowed");
|
||||
} catch (e) {
|
||||
isFollowing = wasFollowing;
|
||||
followUri = previousUri;
|
||||
persistFollow(wasFollowing, previousUri);
|
||||
showError(`unfollow failed: ${e}`);
|
||||
} finally {
|
||||
followBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
type Tab = "posts" | "replies" | "likes";
|
||||
let activeTab: Tab = $state("posts");
|
||||
</script>
|
||||
|
||||
<section class="profile">
|
||||
<!-- ─── banner ────────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="profile__banner"
|
||||
style:background-image={bannerUrl ? `url(${bannerUrl})` : "none"}
|
||||
>
|
||||
{#if !bannerUrl}
|
||||
<!--
|
||||
Placeholder shown when the profile has no banner blob.
|
||||
Subtle orange-tinted terminal grid — keeps the page from
|
||||
looking bare without competing with the avatar.
|
||||
-->
|
||||
<div class="profile__banner-grid" aria-hidden="true"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ─── avatar + actions ──────────────────────────────────────── -->
|
||||
<div class="profile__topbar">
|
||||
<div class="profile__avatar-overlap">
|
||||
{#if viewModel.kind === "ready"}
|
||||
<Avatar
|
||||
did={viewModel.data.did}
|
||||
cid={viewModel.data.avatar_cid ?? null}
|
||||
name={viewModel.data.display_name ?? viewModel.data.handle}
|
||||
size={96}
|
||||
/>
|
||||
{:else}
|
||||
<span class="profile__avatar-skeleton"></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="profile__actions">
|
||||
{#if viewModel.kind === "ready"}
|
||||
{#if isOwn}
|
||||
{#if editing}
|
||||
<button
|
||||
class="btn btn--ghost"
|
||||
type="button"
|
||||
onclick={() => (editing = false)}
|
||||
>cancel</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
type="button"
|
||||
onclick={openEdit}
|
||||
>edit profile</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<!--
|
||||
Follow toggle. Text + class flip with `isFollowing`:
|
||||
"follow" / `.btn--primary` (outlined-emphasis) when not
|
||||
following, "following" / `.btn--ghost` (subdued) when
|
||||
already following. The "following" click becomes an
|
||||
unfollow via the same handler — X shows the relationship
|
||||
state in the label, not a separate "unfollow" button.
|
||||
-->
|
||||
{#if isFollowing}
|
||||
<button
|
||||
class="btn btn--ghost profile__follow-btn profile__follow-btn--active"
|
||||
type="button"
|
||||
disabled={followBusy}
|
||||
onclick={onUnfollowClick}
|
||||
>following</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn--primary profile__follow-btn"
|
||||
type="button"
|
||||
disabled={followBusy}
|
||||
onclick={onFollowClick}
|
||||
>follow</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── identity ──────────────────────────────────────────────── -->
|
||||
{#if viewModel.kind === "ready"}
|
||||
<div class="profile__identity">
|
||||
<h2 class="profile__name">
|
||||
{viewModel.data.display_name ?? viewModel.data.handle}
|
||||
</h2>
|
||||
<div class="profile__handle">@{viewModel.data.handle}</div>
|
||||
</div>
|
||||
{:else if viewModel.kind === "loading"}
|
||||
<div class="profile__identity">
|
||||
<h2 class="profile__name profile__name--skeleton">…</h2>
|
||||
<div class="profile__handle">@{handle}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="profile__identity profile__identity--err">
|
||||
err: {viewModel.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ─── bio ───────────────────────────────────────────────────── -->
|
||||
{#if viewModel.kind === "ready"}
|
||||
{#if viewModel.data.description}
|
||||
<p class="profile__bio">{viewModel.data.description}</p>
|
||||
{:else if isEmptyProfile}
|
||||
<p class="profile__bio profile__bio--empty">
|
||||
{#if isOwn}
|
||||
// no profile yet — click "edit profile" to set one up.
|
||||
{:else}
|
||||
// no profile yet.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ─── meta (did) ───────────────────────────────────────────── -->
|
||||
{#if viewModel.kind === "ready"}
|
||||
<div class="profile__meta">
|
||||
<span class="profile__meta-item" title={viewModel.data.did}>
|
||||
did: <code>{shortDid(viewModel.data.did)}</code>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ─── counts ────────────────────────────────────────────────── -->
|
||||
{#if viewModel.kind === "ready"}
|
||||
<dl class="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>
|
||||
{/if}
|
||||
|
||||
<!-- ─── tabs ──────────────────────────────────────────────────── -->
|
||||
<nav class="profile__tabs" aria-label="Profile sections">
|
||||
<button
|
||||
class="tab"
|
||||
class:tab--active={activeTab === "posts"}
|
||||
type="button"
|
||||
onclick={() => (activeTab = "posts")}
|
||||
>posts</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:tab--active={activeTab === "replies"}
|
||||
type="button"
|
||||
disabled
|
||||
title="replies — coming soon"
|
||||
>replies</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:tab--active={activeTab === "likes"}
|
||||
type="button"
|
||||
disabled
|
||||
title="likes — coming soon"
|
||||
>likes</button>
|
||||
</nav>
|
||||
|
||||
<!-- ─── feed ──────────────────────────────────────────────────── -->
|
||||
<div class="profile__feed">
|
||||
{#if viewModel.kind === "ready"}
|
||||
{#each viewModel.data.posts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={on_thread_click} />
|
||||
{/each}
|
||||
{#if viewModel.data.posts.length === 0}
|
||||
<div class="profile__empty">// no posts yet.</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ─── edit form (own profile only) ─────────────────────────── -->
|
||||
{#if editing && viewModel.kind === "ready"}
|
||||
<div class="profile__edit">
|
||||
<h3 class="profile__edit-title">// edit profile</h3>
|
||||
<label class="profile__edit-field">
|
||||
<span class="key">display name</span>
|
||||
<input type="text" bind:value={editName} maxlength="64" />
|
||||
</label>
|
||||
<label class="profile__edit-field">
|
||||
<span class="key">description</span>
|
||||
<textarea
|
||||
bind:value={editDesc}
|
||||
rows="3"
|
||||
maxlength="300"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="profile__edit-field">
|
||||
<span class="key">avatar</span>
|
||||
<div class="profile__edit-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="profile__edit-actions">
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onclick={saveProfile}
|
||||
>
|
||||
{saving ? "saving…" : "save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<script lang="ts" module>
|
||||
/// Compact DID renderer for the profile meta line — keeps the
|
||||
/// `did:plc:bafyreiczj…` from spilling past the column.
|
||||
export function shortDid(did: string): string {
|
||||
if (did.length <= 24) return did;
|
||||
const head = did.slice(0, 18);
|
||||
const tail = did.slice(-6);
|
||||
return `${head}…${tail}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* No outer padding — banner + avatar overhang make the section
|
||||
fill the column edge to edge on mobile. */
|
||||
|
||||
/* ─── banner ─────────────────────────────────────────────── */
|
||||
.profile__banner {
|
||||
position: relative;
|
||||
height: 140px;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-elev);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
.profile__banner-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--bg-elev) 0%,
|
||||
rgba(255, 102, 0, 0.12) 60%,
|
||||
rgba(255, 102, 0, 0.04) 100%
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent 0,
|
||||
transparent 27px,
|
||||
rgba(255, 102, 0, 0.06) 27px,
|
||||
rgba(255, 102, 0, 0.06) 28px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent 0,
|
||||
transparent 27px,
|
||||
rgba(255, 102, 0, 0.06) 27px,
|
||||
rgba(255, 102, 0, 0.06) 28px
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── avatar + actions ──────────────────────────────────── */
|
||||
.profile__topbar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--s-4);
|
||||
margin-top: -44px;
|
||||
min-height: 52px;
|
||||
}
|
||||
.profile__avatar-overlap {
|
||||
border: 4px solid var(--bg);
|
||||
border-radius: 50%;
|
||||
background: var(--bg);
|
||||
line-height: 0;
|
||||
}
|
||||
.profile__avatar-skeleton {
|
||||
display: inline-block;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.profile__actions {
|
||||
padding-bottom: var(--s-3);
|
||||
}
|
||||
/* Follow button — X-style with two states. "follow" is the
|
||||
full orange emphasis (btn--primary); "following" flips to a
|
||||
ghost button that turns red on hover (mirroring X's
|
||||
"unfollow on hover" affordance). */
|
||||
.profile__follow-btn {
|
||||
min-width: 6.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.profile__follow-btn--active {
|
||||
color: var(--text);
|
||||
border-color: var(--line-2);
|
||||
background: transparent;
|
||||
}
|
||||
.profile__follow-btn--active:hover:not(:disabled) {
|
||||
/* X's "unfollow on hover" — replace label + colour with the
|
||||
destructive cue, but only while actually hovering. */
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
}
|
||||
|
||||
/* ─── identity ──────────────────────────────────────────── */
|
||||
.profile__identity {
|
||||
padding: var(--s-3) var(--s-4) 0;
|
||||
}
|
||||
.profile__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-300);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: var(--lh-tight);
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.profile__name--skeleton {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.profile__handle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.profile__identity--err {
|
||||
color: var(--red);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
}
|
||||
|
||||
/* ─── bio ───────────────────────────────────────────────── */
|
||||
.profile__bio {
|
||||
padding: var(--s-3) var(--s-4) 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
color: var(--text);
|
||||
line-height: var(--lh-body);
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.profile__bio--empty {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ─── meta ──────────────────────────────────────────────── */
|
||||
.profile__meta {
|
||||
padding: var(--s-3) var(--s-4) 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.profile__meta code {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ─── counts ────────────────────────────────────────────── */
|
||||
.profile__counts {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.profile__counts > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.profile__counts dt {
|
||||
color: var(--text-dim);
|
||||
letter-spacing: var(--tracking-label);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.profile__counts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-200);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ─── tabs ──────────────────────────────────────────────── */
|
||||
.profile__tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-top: var(--s-2);
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: var(--s-3);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease);
|
||||
}
|
||||
.tab:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
.tab:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.tab--active {
|
||||
color: var(--orange);
|
||||
border-bottom-color: var(--orange);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ─── feed ──────────────────────────────────────────────── */
|
||||
.profile__feed {
|
||||
padding: var(--s-2) 0 var(--s-6);
|
||||
}
|
||||
.profile__empty {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
padding: var(--s-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── edit form ─────────────────────────────────────────── */
|
||||
.profile__edit {
|
||||
border-top: 1px solid var(--line);
|
||||
margin: var(--s-4) var(--s-4) 0;
|
||||
padding: var(--s-4) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
.profile__edit-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
color: var(--orange);
|
||||
margin: 0 0 var(--s-2);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-label);
|
||||
}
|
||||
.profile__edit-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
.profile__edit-field .key {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
letter-spacing: var(--tracking-label);
|
||||
}
|
||||
.profile__edit-field input,
|
||||
.profile__edit-field textarea {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--r-sm);
|
||||
resize: vertical;
|
||||
}
|
||||
.profile__edit-field input:focus,
|
||||
.profile__edit-field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--orange);
|
||||
}
|
||||
.profile__edit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.profile__edit-row .meta {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.profile__edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script lang="ts">
|
||||
import type { Post } from "../api/client";
|
||||
|
||||
type Props = {
|
||||
/// Posts currently in the home timeline. The Sidebar derives
|
||||
/// the trends list client-side from these (top 3 DIDs by post
|
||||
/// count), so no new backend endpoint is required.
|
||||
posts: Post[];
|
||||
/// Switches the App's view to "search" and populates the
|
||||
/// search query. Wired by the parent App.
|
||||
onSearch: (query: string) => void;
|
||||
};
|
||||
|
||||
let { posts, onSearch }: Props = $props();
|
||||
|
||||
let query: string = $state("");
|
||||
|
||||
function submit() {
|
||||
onSearch(query.trim());
|
||||
}
|
||||
|
||||
/// Aggregate by DID; cheapest possible counter (no fetchProfile).
|
||||
/// We surface the handles for display, but the actual handle
|
||||
/// resolution still comes from the `handle` field baked into
|
||||
/// each post by the AppView. Skips empty DIDs defensively.
|
||||
const trends = $derived.by(() => {
|
||||
const counts = new Map<string, { did: string; handle: string; count: number }>();
|
||||
for (const p of posts) {
|
||||
if (!p.did) continue;
|
||||
const existing = counts.get(p.did);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
counts.set(p.did, { did: p.did, handle: p.handle, count: 1 });
|
||||
}
|
||||
}
|
||||
const sorted = Array.from(counts.values()).sort((a, b) => b.count - a.count);
|
||||
return sorted.slice(0, 3);
|
||||
});
|
||||
|
||||
const placeholders = [
|
||||
{ handle: "alice.bsky.social", why: "shared network" },
|
||||
{ handle: "bob.bsky.social", why: "popular in feed" },
|
||||
{ handle: "carol.bsky.social", why: "trending" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<aside class="sidebar" aria-label="Discover">
|
||||
<section class="panel">
|
||||
<label class="panel__search">
|
||||
<span class="prompt">$</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
onfocus={() => onSearch("")}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder="grep posts…"
|
||||
aria-label="Search posts"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3 class="panel__title">// trends</h3>
|
||||
{#if trends.length === 0}
|
||||
<p class="panel__empty">// no posts yet — start the timeline.</p>
|
||||
{:else}
|
||||
<ul class="trends">
|
||||
{#each trends as t (t.did)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="trend"
|
||||
title={`${t.count} post${t.count === 1 ? "" : "s"} in current timeline`}
|
||||
onclick={() => onSearch(t.handle)}
|
||||
>
|
||||
<span class="trend__handle">@{t.handle}</span>
|
||||
<span class="trend__count">{t.count} post{t.count === 1 ? "" : "s"}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3 class="panel__title">// who to follow</h3>
|
||||
<ul class="who">
|
||||
{#each placeholders as p (p.handle)}
|
||||
<li class="who__row">
|
||||
<span class="who__handle">@{p.handle}</span>
|
||||
<span class="who__why">{p.why}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="panel__hint">// coming soon — follow graph not wired yet</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
flex: 0 0 280px;
|
||||
align-self: flex-start;
|
||||
position: sticky;
|
||||
top: var(--s-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-2) var(--s-3) var(--s-5);
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-deep);
|
||||
padding: var(--s-3);
|
||||
}
|
||||
|
||||
.panel__title {
|
||||
margin: 0 0 var(--s-3);
|
||||
color: var(--orange);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
letter-spacing: var(--tracking-label);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.panel__search .prompt {
|
||||
color: var(--orange);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
}
|
||||
|
||||
.panel__search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--r-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.panel__search input:focus {
|
||||
border-color: var(--orange);
|
||||
}
|
||||
|
||||
.panel__search input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.panel__empty {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.panel__hint {
|
||||
margin: var(--s-3) 0 0;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.trends,
|
||||
.who {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.trend {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.trend:hover {
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
}
|
||||
|
||||
.trend__handle {
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trend:hover .trend__handle {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.trend__count {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.who__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--s-2) var(--s-1);
|
||||
border-bottom: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.who__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.who__handle {
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.who__why {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "app.bsky.actor.profile",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"key": "tid",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"displayName": { "type": "string", "maxLength": 64, "maxGraphemes": 64 },
|
||||
"description": { "type": "string", "maxLength": 300, "maxGraphemes": 300 },
|
||||
"avatar": {
|
||||
"type": "blob",
|
||||
"accept": ["image/png", "image/jpeg", "image/webp", "image/gif"]
|
||||
},
|
||||
"banner": {
|
||||
"type": "blob",
|
||||
"accept": ["image/png", "image/jpeg", "image/webp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
-- AppView database schema 0005: profile metadata + per-post avatar refs.
|
||||
--
|
||||
-- Why
|
||||
-- The Profile-View-Page and PostCard both render a user avatar.
|
||||
-- Fetching the live `app.bsky.actor.profile/self` record from every
|
||||
-- user's PDS on every render doesn't scale, and isn't always reachable
|
||||
-- (e.g. a did:web: user whose PDS is offline). We cache the
|
||||
-- denormalised profile fields the UI shows keyed by did, indexed by
|
||||
-- handle so the `/api/profile/<handle>` lookup is index-driven.
|
||||
--
|
||||
-- Source of truth: the user's own PDS. The PDS pushes profile records
|
||||
-- via the existing `/internal/ingest-commit` path; this migration
|
||||
-- adds the `(collection, action, rkey) == ('app.bsky.actor.profile',
|
||||
-- 'create', 'self')` arm to the AppView's indexer to populate this
|
||||
-- table.
|
||||
--
|
||||
-- All fields nullable: a profile record can omit displayName,
|
||||
-- description, avatar, banner independently.
|
||||
--
|
||||
-- post_count / follower_count / following_count are denormalised
|
||||
-- counts populated only when the row is created/replaced; the
|
||||
-- Profile-View-Page reads them here so it doesn't have to issue a
|
||||
-- separate COUNT(*) over posts/follows.
|
||||
--
|
||||
-- The avatar_cid on posts is the resolved profile-avatar blob ref
|
||||
-- (or NULL) for the post's author. The AppView fills it in at
|
||||
-- upsert_post-time from the profiles table; the PostCard reads it
|
||||
-- to inline an <Avatar cid={post.avatar_cid}/> without a per-row
|
||||
-- PDS round-trip.
|
||||
|
||||
CREATE TABLE profiles (
|
||||
did TEXT PRIMARY KEY,
|
||||
handle TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
description TEXT,
|
||||
avatar_cid TEXT,
|
||||
banner_cid TEXT,
|
||||
post_count BIGINT NOT NULL DEFAULT 0,
|
||||
follower_count BIGINT NOT NULL DEFAULT 0,
|
||||
following_count BIGINT NOT NULL DEFAULT 0,
|
||||
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX profiles_handle_idx ON profiles (LOWER(handle));
|
||||
CREATE INDEX profiles_indexed_at_idx ON profiles (indexed_at DESC);
|
||||
|
||||
-- Backfill: seed a profile row for every handle we've already
|
||||
-- resolved through the posts.did → posts.handle mapping. The display
|
||||
-- fields stay NULL — they need the live PDS profile record.
|
||||
INSERT INTO profiles (did, handle)
|
||||
SELECT DISTINCT ON (did) did, handle
|
||||
FROM posts
|
||||
WHERE handle <> ''
|
||||
ORDER BY did, indexed_at DESC
|
||||
ON CONFLICT (did) DO NOTHING;
|
||||
|
||||
ALTER TABLE posts ADD COLUMN IF NOT EXISTS avatar_cid TEXT;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- AppView database schema 0006: handle-sync attempt tracking.
|
||||
--
|
||||
-- Why
|
||||
-- The `handle_sync` worker SELECTs DIDs whose `posts.handle` is empty
|
||||
-- and tries to resolve them via the local PDS → PLC directory →
|
||||
-- `did:web:` resolver. Some DIDs are *unresolvable* (e.g. a `did:key:`
|
||||
-- user not hosted on the local PDS, or any `did:foo:` method that
|
||||
-- neither PLC nor Web understands). Without tracking these, every
|
||||
-- pass re-selects them and they dominate the 100-row batch — and
|
||||
-- since `did:key:` sorts lexicographically before `did:plc:` /
|
||||
-- `did:web:`, the worker would process the same 100 unresolvable
|
||||
-- `did:key:` rows forever and never reach any resolvable DID.
|
||||
--
|
||||
-- With this column the worker marks each empty-handle row with the
|
||||
-- time of its last attempt. The SELECT filter excludes rows
|
||||
-- attempted within the last hour, so an unresolvable DID gets at
|
||||
-- most one attempt per hour and stops blocking forward progress.
|
||||
-- Rows whose `handle` later gets filled (by another code path) are
|
||||
-- naturally no longer in the candidate set.
|
||||
--
|
||||
-- The column is per-post (not per-DID) because the candidate set is
|
||||
-- already per-post and the update is cheap (the empty-handle slice
|
||||
-- is small in steady state).
|
||||
|
||||
ALTER TABLE posts ADD COLUMN IF NOT EXISTS handle_sync_attempted_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- AppView database schema 0007: drop unused `profiles_handle_idx`.
|
||||
--
|
||||
-- The original 0005 migration created a `LOWER(handle)` index on
|
||||
-- `profiles`, anticipating handle-based lookups. In practice every
|
||||
-- caller derives a DID first (via `posts.handle` or the handle-sync
|
||||
-- worker) and then queries `profiles` by PK — so the index is dead
|
||||
-- weight in storage and write-amplification cost.
|
||||
--
|
||||
-- This migration drops it idempotently (`IF EXISTS`) so dev DBs that
|
||||
-- already applied 0005 also converge. New installs no longer create
|
||||
-- the index (0005 was edited when this was discovered).
|
||||
DROP INDEX IF EXISTS profiles_handle_idx;
|
||||
Reference in New Issue
Block a user