Compare commits
10
Commits
550b89673d
...
a5b1c889dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b1c889dc | ||
|
|
a226a92c12 | ||
|
|
73e56fd788 | ||
|
|
78b752c993 | ||
|
|
184e0dfe03 | ||
|
|
abea4d2a8a | ||
|
|
d1fff87e34 | ||
|
|
647c3059b4 | ||
|
|
6a82c906de | ||
|
|
43fc889d47 |
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use at_firehose::JetstreamEvent;
|
use at_firehose::JetstreamEvent;
|
||||||
|
use serde_json::Value;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -133,13 +134,17 @@ impl IndexHandler {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"identity" => {
|
"identity" => {
|
||||||
trace!(did = %ev.did, "identity event (logged only)");
|
if let Err(e) = handle_identity(&self.db, &ev).await {
|
||||||
let _ = handle_identity(&ev);
|
warn!(error = %e, did = %ev.did, "handle_identity failed");
|
||||||
|
return Ok(()); // don't advance cursor; let next replay retry
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
"account" => {
|
"account" => {
|
||||||
trace!(did = %ev.did, "account event (logged only)");
|
if let Err(e) = handle_account(&self.db, &ev).await {
|
||||||
let _ = handle_account(&ev);
|
warn!(error = %e, did = %ev.did, "handle_account failed");
|
||||||
|
return Ok(()); // don't advance cursor; let next replay retry
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
@@ -164,16 +169,67 @@ impl IndexHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_identity(_ev: &JetstreamEvent) -> Result<()> {
|
/// `identity` event — Jetstream tells us a DID's handle changed.
|
||||||
info!("identity change (DID doc rotation)");
|
///
|
||||||
|
/// The Jetstream payload includes `identity.handle` (the *current*
|
||||||
|
/// handle, since the event fires after every handle change) and
|
||||||
|
/// optionally `identity.did` (the DID — redundant with the outer
|
||||||
|
/// `ev.did` but we accept both). We pull the handle out and run it
|
||||||
|
/// through `indexer::backfill_handle` so every existing post row for
|
||||||
|
/// that DID gets the new value. The COALESCE guard inside
|
||||||
|
/// `indexer::PostRow::from_record` keeps empty strings from
|
||||||
|
/// clobbering this backfilled value when a later `commit` event
|
||||||
|
/// arrives.
|
||||||
|
async fn handle_identity(db: &PgPool, ev: &JetstreamEvent) -> Result<()> {
|
||||||
|
let handle = extract_handle(&ev.identity).or_else(|| extract_handle(&ev.account));
|
||||||
|
let Some(handle) = handle else {
|
||||||
|
// Some identity events carry only a DID-doc rotation signal
|
||||||
|
// with no handle payload — those are uninteresting for our
|
||||||
|
// purpose. Advance the cursor anyway.
|
||||||
|
debug!(did = %ev.did, "identity event without a usable handle payload");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let rows = indexer::backfill_handle(db, &ev.did, &handle).await?;
|
||||||
|
info!(
|
||||||
|
did = %ev.did,
|
||||||
|
handle = %handle,
|
||||||
|
rows_updated = rows,
|
||||||
|
"backfilled handle on posts"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_account(_ev: &JetstreamEvent) -> Result<()> {
|
/// `account` event — Jetstream tells us an account's active/deactive
|
||||||
info!("account change (active/-status)");
|
/// status changed. We mirror the handle-backfill behaviour in case
|
||||||
|
/// the `account` payload carries the verified handle alongside
|
||||||
|
/// `active`; many real-world identities show the handle there even
|
||||||
|
/// when no `identity` event was emitted.
|
||||||
|
async fn handle_account(db: &PgPool, ev: &JetstreamEvent) -> Result<()> {
|
||||||
|
let Some(handle) = extract_handle(&ev.account) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let rows = indexer::backfill_handle(db, &ev.did, &handle).await?;
|
||||||
|
info!(
|
||||||
|
did = %ev.did,
|
||||||
|
handle = %handle,
|
||||||
|
rows_updated = rows,
|
||||||
|
"backfilled handle on posts (account event)"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pull a handle string out of a Jetstream event fragment. Returns
|
||||||
|
/// `None` if the fragment is absent or doesn't carry a usable
|
||||||
|
/// `handle` string field.
|
||||||
|
fn extract_handle(fragment: &Option<Value>) -> Option<String> {
|
||||||
|
fragment
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.get("handle"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn the background task that drains the cursor-flush channel and
|
/// Spawn the background task that drains the cursor-flush channel and
|
||||||
/// writes the running maximum to the DB. Returns when the receiver is
|
/// writes the running maximum to the DB. Returns when the receiver is
|
||||||
/// dropped (i.e. the main process is shutting down).
|
/// dropped (i.e. the main process is shutting down).
|
||||||
|
|||||||
@@ -112,11 +112,22 @@ impl HandleSyncWorker {
|
|||||||
/// One bounded scan: find up to [`BATCH_SIZE`] distinct DIDs whose
|
/// One bounded scan: find up to [`BATCH_SIZE`] distinct DIDs whose
|
||||||
/// posts have an empty handle, resolve them, and update the rows
|
/// posts have an empty handle, resolve them, and update the rows
|
||||||
/// where the handle is still empty (race-safe).
|
/// 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.
|
||||||
pub async fn run_once(&self) -> Result<SyncReport> {
|
pub async fn run_once(&self) -> Result<SyncReport> {
|
||||||
let dids: Vec<(String,)> = sqlx::query_as(
|
let dids: Vec<(String,)> = sqlx::query_as(
|
||||||
r#"SELECT DISTINCT did
|
r#"SELECT DISTINCT did
|
||||||
FROM posts
|
FROM posts
|
||||||
WHERE handle = ''
|
WHERE handle = ''
|
||||||
|
AND (did LIKE 'did:plc:%' OR did LIKE 'did:web:%')
|
||||||
ORDER BY did
|
ORDER BY did
|
||||||
LIMIT $1"#,
|
LIMIT $1"#,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -214,12 +214,21 @@ impl PostRow {
|
|||||||
/// by sniffing for `$type` (`app.bsky.embed.images` / `.external` /
|
/// by sniffing for `$type` (`app.bsky.embed.images` / `.external` /
|
||||||
/// `.record`). Keeping it as raw JSON means we don't have to mirror
|
/// `.record`). Keeping it as raw JSON means we don't have to mirror
|
||||||
/// every embed variant in Rust.
|
/// every embed variant in Rust.
|
||||||
|
///
|
||||||
|
/// `pds_handle` is the optional handle forwarded by the PDS through
|
||||||
|
/// the `/internal/ingest-commit` payload. Local-PDS users have
|
||||||
|
/// `did:key:` DIDs that no PLC directory can resolve, so the PDS is
|
||||||
|
/// the only authoritative source for their handle. Pass `Some(handle)`
|
||||||
|
/// when you have it; pass `None` (e.g. Jetstream path) and the
|
||||||
|
/// `upsert_post` COALESCE guard ensures the empty value won't
|
||||||
|
/// clobber a backfilled handle from `firehose::handle_identity`.
|
||||||
pub fn from_record(
|
pub fn from_record(
|
||||||
did: &str,
|
did: &str,
|
||||||
rkey: &str,
|
rkey: &str,
|
||||||
collection: &str,
|
collection: &str,
|
||||||
cid: &str,
|
cid: &str,
|
||||||
record: &Value,
|
record: &Value,
|
||||||
|
pds_handle: Option<&str>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let text = record
|
let text = record
|
||||||
.get("text")
|
.get("text")
|
||||||
@@ -250,10 +259,14 @@ impl PostRow {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
});
|
});
|
||||||
let uri = format!("at://{did}/{collection}/{rkey}");
|
let uri = format!("at://{did}/{collection}/{rkey}");
|
||||||
|
let handle = pds_handle
|
||||||
|
.map(|h| h.trim().to_string())
|
||||||
|
.filter(|h| !h.is_empty())
|
||||||
|
.unwrap_or_default();
|
||||||
Self {
|
Self {
|
||||||
uri,
|
uri,
|
||||||
did: did.to_string(),
|
did: did.to_string(),
|
||||||
handle: String::new(),
|
handle,
|
||||||
rkey: rkey.to_string(),
|
rkey: rkey.to_string(),
|
||||||
collection: collection.to_string(),
|
collection: collection.to_string(),
|
||||||
text,
|
text,
|
||||||
@@ -575,12 +588,17 @@ pub async fn apply_commit(
|
|||||||
})?;
|
})?;
|
||||||
let cid = op.cid.clone().unwrap_or_default();
|
let cid = op.cid.clone().unwrap_or_default();
|
||||||
let record = op.record.clone().unwrap_or(Value::Null);
|
let record = op.record.clone().unwrap_or(Value::Null);
|
||||||
|
// Jetstream `commit` events don't carry the
|
||||||
|
// 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 row = PostRow::from_record(
|
||||||
&ev.did,
|
&ev.did,
|
||||||
&rkey,
|
&rkey,
|
||||||
&collection,
|
&collection,
|
||||||
&cid,
|
&cid,
|
||||||
&record,
|
&record,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
upsert_post(db, &row).await?;
|
upsert_post(db, &row).await?;
|
||||||
applied = true;
|
applied = true;
|
||||||
@@ -825,7 +843,7 @@ mod tests {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
|
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
|
||||||
let embed = row.embed.expect("embed must be captured");
|
let embed = row.embed.expect("embed must be captured");
|
||||||
assert_eq!(embed["$type"], "app.bsky.embed.images");
|
assert_eq!(embed["$type"], "app.bsky.embed.images");
|
||||||
assert_eq!(embed["images"][0]["alt"], "a cat");
|
assert_eq!(embed["images"][0]["alt"], "a cat");
|
||||||
@@ -845,7 +863,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
|
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
|
||||||
let embed = row.embed.expect("embed must be captured");
|
let embed = row.embed.expect("embed must be captured");
|
||||||
assert_eq!(embed["$type"], "app.bsky.embed.external");
|
assert_eq!(embed["$type"], "app.bsky.embed.external");
|
||||||
assert_eq!(embed["external"]["uri"], "https://example.com");
|
assert_eq!(embed["external"]["uri"], "https://example.com");
|
||||||
@@ -857,7 +875,7 @@ mod tests {
|
|||||||
"text": "no embed here",
|
"text": "no embed here",
|
||||||
"createdAt": "2026-07-01T12:00:00Z"
|
"createdAt": "2026-07-01T12:00:00Z"
|
||||||
});
|
});
|
||||||
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
|
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
|
||||||
assert!(row.embed.is_none());
|
assert!(row.embed.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,7 +889,7 @@ mod tests {
|
|||||||
"root": {"uri": "at://did:plc:b/app.twi.post/r", "cid": "cr"}
|
"root": {"uri": "at://did:plc:b/app.twi.post/r", "cid": "cr"}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
|
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
|
||||||
assert_eq!(row.parent_uri.as_deref(), Some("at://did:plc:b/app.twi.post/p"));
|
assert_eq!(row.parent_uri.as_deref(), Some("at://did:plc:b/app.twi.post/p"));
|
||||||
assert_eq!(row.root_uri.as_deref(), Some("at://did:plc:b/app.twi.post/r"));
|
assert_eq!(row.root_uri.as_deref(), Some("at://did:plc:b/app.twi.post/r"));
|
||||||
}
|
}
|
||||||
@@ -1000,6 +1018,7 @@ mod tests {
|
|||||||
"app.twi.post",
|
"app.twi.post",
|
||||||
"cid-embed",
|
"cid-embed",
|
||||||
&record,
|
&record,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
upsert_post(&db, &row).await.unwrap();
|
upsert_post(&db, &row).await.unwrap();
|
||||||
|
|
||||||
@@ -1092,3 +1111,31 @@ mod tests {
|
|||||||
assert_eq!(count, 0);
|
assert_eq!(count, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Backfill the `posts.handle` column for every row belonging to
|
||||||
|
/// `did`. Used by the Jetstream `identity` handler when Jetstream
|
||||||
|
/// tells us a DID's handle has changed — every existing post row
|
||||||
|
/// needs the new value.
|
||||||
|
///
|
||||||
|
/// **Race-safety**: this only writes if the row's current handle is
|
||||||
|
/// empty OR doesn't match, so concurrent PDS pushes (which carry
|
||||||
|
/// the same handle) and concurrent identity replays don't fight.
|
||||||
|
/// The `WHERE handle IS DISTINCT FROM $1` makes the update a
|
||||||
|
/// no-op when the value is already correct, which Postgres treats
|
||||||
|
/// cheaply.
|
||||||
|
///
|
||||||
|
/// Returns the number of rows updated.
|
||||||
|
pub async fn backfill_handle(
|
||||||
|
db: &PgPool,
|
||||||
|
did: &str,
|
||||||
|
new_handle: &str,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let res = sqlx::query(
|
||||||
|
"UPDATE posts SET handle = $1 WHERE did = $2 AND handle IS DISTINCT FROM $1",
|
||||||
|
)
|
||||||
|
.bind(new_handle)
|
||||||
|
.bind(did)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ use tracing::{info, warn};
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct IngestCommitReq {
|
pub struct IngestCommitReq {
|
||||||
pub did: String,
|
pub did: String,
|
||||||
|
/// The poster's current handle, as known by the PDS `users` table.
|
||||||
|
/// Optional in the wire payload — the AppView falls back to an
|
||||||
|
/// empty string, and the upsert COALESCE guard prevents the empty
|
||||||
|
/// value from clobbering a backfilled handle from the Jetstream
|
||||||
|
/// `identity` event path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub handle: Option<String>,
|
||||||
pub collection: String,
|
pub collection: String,
|
||||||
pub action: String,
|
pub action: String,
|
||||||
pub rkey: String,
|
pub rkey: String,
|
||||||
@@ -127,6 +134,7 @@ async fn apply(
|
|||||||
&req.collection,
|
&req.collection,
|
||||||
&cid,
|
&cid,
|
||||||
&record,
|
&record,
|
||||||
|
req.handle.as_deref(),
|
||||||
);
|
);
|
||||||
indexer::upsert_post(&state.db, &row).await.map_err(db_err)?;
|
indexer::upsert_post(&state.db, &row).await.map_err(db_err)?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
|||||||
@@ -355,13 +355,24 @@ async fn resolve_profile(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let Some(target_did) = target_did else {
|
let Some(target_did) = target_did else {
|
||||||
return Err((
|
// No posts indexed for this handle yet — typical for
|
||||||
StatusCode::NOT_FOUND,
|
// local-PDS users whose posts haven't been ingested into
|
||||||
Json(json!({
|
// the Jetstream yet (and the AppView indexes only ingested
|
||||||
"error": "NotFound",
|
// posts, never queries upstream PDSs for handle→DID
|
||||||
"message": "no DID or handle provided, or no posts for that handle",
|
// resolution). Instead of bubbling a 404 to the UI which
|
||||||
})),
|
// then shows an error toast instead of an empty profile,
|
||||||
));
|
// synthesise a profile row with the requested handle and
|
||||||
|
// zero counts. The `did` is left empty; the UI's
|
||||||
|
// `displayHandle()` falls back to the handle and the
|
||||||
|
// "copy did" button just copies an empty string.
|
||||||
|
let display = handle_clean.unwrap_or_default();
|
||||||
|
return Ok(Json(ProfileResponse {
|
||||||
|
did: String::new(),
|
||||||
|
handle: display,
|
||||||
|
posts: Vec::new(),
|
||||||
|
followers: 0,
|
||||||
|
following: 0,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch the user's most recent posts (newest first). We return up to
|
// Fetch the user's most recent posts (newest first). We return up to
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ use std::time::Duration;
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct IngestCommitBody<'a> {
|
struct IngestCommitBody<'a> {
|
||||||
did: &'a str,
|
did: &'a str,
|
||||||
|
/// The poster's current handle. Optional in the wire payload —
|
||||||
|
/// the AppView's indexer treats an empty/missing handle as the
|
||||||
|
/// existing empty-string placeholder, which the Jetstream
|
||||||
|
/// `identity` event path will eventually backfill.
|
||||||
|
handle: Option<&'a str>,
|
||||||
collection: &'a str,
|
collection: &'a str,
|
||||||
action: &'a str,
|
action: &'a str,
|
||||||
rkey: &'a str,
|
rkey: &'a str,
|
||||||
@@ -63,6 +68,14 @@ impl AppViewPushClient {
|
|||||||
/// AT-Protocol record value as JSON — the AppView's indexer reads
|
/// AT-Protocol record value as JSON — the AppView's indexer reads
|
||||||
/// `embed` / `reply` off it, which is why we can't just send the CID.
|
/// `embed` / `reply` off it, which is why we can't just send the CID.
|
||||||
///
|
///
|
||||||
|
/// `handle` is the poster's current handle. Pass `Some(handle)` for
|
||||||
|
/// local-PDS users so the AppView's `posts.handle` column is
|
||||||
|
/// populated immediately (otherwise the timeline renders handles as
|
||||||
|
/// `@did:plc:…` snippets and the profile endpoint can't resolve
|
||||||
|
/// `handle → did`). For the `app.bsky.feed.like` / `app.bsky.feed.repost`
|
||||||
|
/// collections the AppView also needs it so the liker's handle
|
||||||
|
/// lands on the `likes.liker_handle` column.
|
||||||
|
///
|
||||||
/// Returns `Ok(true)` if the AppView applied the commit, `Ok(false)`
|
/// Returns `Ok(true)` if the AppView applied the commit, `Ok(false)`
|
||||||
/// if it returned a non-2xx status (logged as warn), and `Err(_)` if
|
/// if it returned a non-2xx status (logged as warn), and `Err(_)` if
|
||||||
/// the request itself failed. The caller should treat any non-Ok as
|
/// the request itself failed. The caller should treat any non-Ok as
|
||||||
@@ -70,6 +83,7 @@ impl AppViewPushClient {
|
|||||||
pub async fn push_create(
|
pub async fn push_create(
|
||||||
&self,
|
&self,
|
||||||
did: &str,
|
did: &str,
|
||||||
|
handle: Option<&str>,
|
||||||
collection: &str,
|
collection: &str,
|
||||||
rkey: &str,
|
rkey: &str,
|
||||||
cid: &str,
|
cid: &str,
|
||||||
@@ -77,6 +91,7 @@ impl AppViewPushClient {
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
self.push(
|
self.push(
|
||||||
did,
|
did,
|
||||||
|
handle,
|
||||||
collection,
|
collection,
|
||||||
"create",
|
"create",
|
||||||
rkey,
|
rkey,
|
||||||
@@ -93,19 +108,21 @@ impl AppViewPushClient {
|
|||||||
collection: &str,
|
collection: &str,
|
||||||
rkey: &str,
|
rkey: &str,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
self.push(did, collection, "delete", rkey, None, None, None)
|
self.push(did, None, collection, "delete", rkey, None, None, None)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_follow_create(
|
pub async fn push_follow_create(
|
||||||
&self,
|
&self,
|
||||||
did: &str,
|
did: &str,
|
||||||
|
handle: Option<&str>,
|
||||||
rkey: &str,
|
rkey: &str,
|
||||||
subject_did: &str,
|
subject_did: &str,
|
||||||
record: &Value,
|
record: &Value,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
self.push(
|
self.push(
|
||||||
did,
|
did,
|
||||||
|
handle,
|
||||||
"app.bsky.graph.follow",
|
"app.bsky.graph.follow",
|
||||||
"create",
|
"create",
|
||||||
rkey,
|
rkey,
|
||||||
@@ -124,6 +141,7 @@ impl AppViewPushClient {
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
self.push(
|
self.push(
|
||||||
did,
|
did,
|
||||||
|
None,
|
||||||
"app.bsky.graph.follow",
|
"app.bsky.graph.follow",
|
||||||
"delete",
|
"delete",
|
||||||
rkey,
|
rkey,
|
||||||
@@ -137,6 +155,7 @@ impl AppViewPushClient {
|
|||||||
async fn push(
|
async fn push(
|
||||||
&self,
|
&self,
|
||||||
did: &str,
|
did: &str,
|
||||||
|
handle: Option<&str>,
|
||||||
collection: &str,
|
collection: &str,
|
||||||
action: &str,
|
action: &str,
|
||||||
rkey: &str,
|
rkey: &str,
|
||||||
@@ -147,6 +166,7 @@ impl AppViewPushClient {
|
|||||||
let url = format!("{}/internal/ingest-commit", self.base_url);
|
let url = format!("{}/internal/ingest-commit", self.base_url);
|
||||||
let body = IngestCommitBody {
|
let body = IngestCommitBody {
|
||||||
did,
|
did,
|
||||||
|
handle,
|
||||||
collection,
|
collection,
|
||||||
action,
|
action,
|
||||||
rkey,
|
rkey,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
//! removed from the MST, a new commit is signed, the AppView is
|
//! removed from the MST, a new commit is signed, the AppView is
|
||||||
//! told to drop the row, and we return the new commit CID + rev.
|
//! told to drop the row, and we return the new commit CID + rev.
|
||||||
|
|
||||||
use crate::routes::helpers::{apply_repo_write, err, to_sqlx_error, RepoWriteOutcome};
|
use crate::routes::helpers::{apply_repo_write, err, lookup_handle, to_sqlx_error, RepoWriteOutcome};
|
||||||
use at_repo::blockstore::Blockstore;
|
use at_repo::blockstore::Blockstore;
|
||||||
use crate::routes::types::ErrorBody;
|
use crate::routes::types::ErrorBody;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -273,6 +273,7 @@ pub async fn create_like(
|
|||||||
let value_cid_str = value_cid.to_string();
|
let value_cid_str = value_cid.to_string();
|
||||||
let push_record = record.clone();
|
let push_record = record.clone();
|
||||||
let push_rkey = rkey.clone();
|
let push_rkey = rkey.clone();
|
||||||
|
let push_handle_str: Option<String> = lookup_handle(&state, &did).await;
|
||||||
|
|
||||||
let commit = apply_and_commit(&state, &did, move |repo| {
|
let commit = apply_and_commit(&state, &did, move |repo| {
|
||||||
let value_cid = value_cid;
|
let value_cid = value_cid;
|
||||||
@@ -317,6 +318,7 @@ pub async fn create_like(
|
|||||||
if let Err(e) = push_handle
|
if let Err(e) = push_handle
|
||||||
.push_create(
|
.push_create(
|
||||||
&push_did,
|
&push_did,
|
||||||
|
push_handle_str.as_deref(),
|
||||||
LIKE_COLLECTION,
|
LIKE_COLLECTION,
|
||||||
&push_rkey_owned,
|
&push_rkey_owned,
|
||||||
&push_cid_owned,
|
&push_cid_owned,
|
||||||
|
|||||||
@@ -453,4 +453,25 @@ async fn persist_user_blocks_in_tx(
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
/// Look up the current handle for `did` from the `users` table.
|
||||||
|
///
|
||||||
|
/// Returned as `Option<String>` (rather than an empty default) so the
|
||||||
|
/// caller can decide what to do when the row hasn't been found yet —
|
||||||
|
/// in practice the row is always present for an authenticated route,
|
||||||
|
/// but we'd rather log a warning than silently emit a bogus empty
|
||||||
|
/// handle into the AppView's `posts.handle` column.
|
||||||
|
///
|
||||||
|
/// Called once per `createRecord` / `feed.like.create` write, just
|
||||||
|
/// before the AppView push, so the liker's/poster's display handle
|
||||||
|
/// lands on the AppView row at write time — otherwise the AppView has
|
||||||
|
/// no source for `did:key:` handles and the timeline renders them as
|
||||||
|
/// `@did:key:z16D…` snippets.
|
||||||
|
pub async fn lookup_handle(state: &AppState, did: &str) -> Option<String> {
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
|
||||||
|
.bind(did)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::routes::helpers::{
|
use crate::routes::helpers::{
|
||||||
apply_repo_write, err, to_sqlx_error, RepoWriteOutcome,
|
apply_repo_write, err, lookup_handle, to_sqlx_error, RepoWriteOutcome,
|
||||||
};
|
};
|
||||||
use crate::routes::types::{CreateRecordReq, CreateRecordResp};
|
use crate::routes::types::{CreateRecordReq, CreateRecordResp};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -92,6 +92,16 @@ pub async fn create_record(
|
|||||||
let push_rkey = rkey.clone();
|
let push_rkey = rkey.clone();
|
||||||
let push_cid = value_cid.to_string();
|
let push_cid = value_cid.to_string();
|
||||||
let push_record = req.record.clone();
|
let push_record = req.record.clone();
|
||||||
|
// Resolve the poster's current handle from the local users
|
||||||
|
// table *before* the spawn — the closure can't easily borrow
|
||||||
|
// `&state` after we hand ownership to the spawned task.
|
||||||
|
let push_handle_str: Option<String> = match lookup_handle(&state, &did).await {
|
||||||
|
h @ Some(_) => h,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(did = %did, "appview push: no handle in users table; timeline will show @did-prefix");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
let collection = req.collection.clone();
|
let collection = req.collection.clone();
|
||||||
|
|
||||||
let outcome = apply_repo_write(&state, &did, move |repo| {
|
let outcome = apply_repo_write(&state, &did, move |repo| {
|
||||||
@@ -136,7 +146,14 @@ pub async fn create_record(
|
|||||||
// the AppView.
|
// the AppView.
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = push_handle
|
if let Err(e) = push_handle
|
||||||
.push_create(&push_did, &push_coll, &push_rkey, &push_cid, &push_record)
|
.push_create(
|
||||||
|
&push_did,
|
||||||
|
push_handle_str.as_deref(),
|
||||||
|
&push_coll,
|
||||||
|
&push_rkey,
|
||||||
|
&push_cid,
|
||||||
|
&push_record,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(error = %e, did = %push_did, "appview push_create failed; jetstream will replay");
|
tracing::warn!(error = %e, did = %push_did, "appview push_create failed; jetstream will replay");
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capabilities for the maarcadetweet main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:event:default",
|
||||||
|
"core:webview:allow-internal-toggle-devtools",
|
||||||
|
"core:window:default",
|
||||||
|
"notification:default",
|
||||||
|
"shell:default",
|
||||||
|
"dialog:default",
|
||||||
|
"updater:default",
|
||||||
|
"window-state:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -559,6 +559,15 @@ pub fn run() {
|
|||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
tracing::info!("maarcadetweet starting up");
|
tracing::info!("maarcadetweet starting up");
|
||||||
|
|
||||||
|
// Open the webview devtools on startup in debug builds so
|
||||||
|
// we can see the console + DOM inspector without reaching
|
||||||
|
// for the macOS menu. The production build has no debug
|
||||||
|
// assertions so this branch is a no-op there.
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.open_devtools();
|
||||||
|
}
|
||||||
|
|
||||||
// Embed the tray icon at compile time. `include_image!`
|
// Embed the tray icon at compile time. `include_image!`
|
||||||
// resolves paths relative to `CARGO_MANIFEST_DIR` and
|
// resolves paths relative to `CARGO_MANIFEST_DIR` and
|
||||||
// bakes the raw RGBA pixels into the binary, so the
|
// bakes the raw RGBA pixels into the binary, so the
|
||||||
@@ -606,6 +615,14 @@ pub fn run() {
|
|||||||
None::<&str>,
|
None::<&str>,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("failed to build search menu item: {e}"))?;
|
.map_err(|e| format!("failed to build search menu item: {e}"))?;
|
||||||
|
let settings_item = tauri::menu::MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
"tray_settings",
|
||||||
|
"Settings",
|
||||||
|
true,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("failed to build settings menu item: {e}"))?;
|
||||||
let quit_item = tauri::menu::MenuItem::with_id(
|
let quit_item = tauri::menu::MenuItem::with_id(
|
||||||
app,
|
app,
|
||||||
"tray_quit",
|
"tray_quit",
|
||||||
@@ -625,6 +642,7 @@ pub fn run() {
|
|||||||
&compose_item,
|
&compose_item,
|
||||||
&profile_item,
|
&profile_item,
|
||||||
&search_item,
|
&search_item,
|
||||||
|
&settings_item,
|
||||||
&separator,
|
&separator,
|
||||||
&quit_item,
|
&quit_item,
|
||||||
],
|
],
|
||||||
@@ -653,6 +671,9 @@ pub fn run() {
|
|||||||
"tray_search" => {
|
"tray_search" => {
|
||||||
let _ = tauri::Emitter::emit(app, "app://navigate", "search");
|
let _ = tauri::Emitter::emit(app, "app://navigate", "search");
|
||||||
}
|
}
|
||||||
|
"tray_settings" => {
|
||||||
|
let _ = tauri::Emitter::emit(app, "app://navigate", "settings");
|
||||||
|
}
|
||||||
"tray_quit" => {
|
"tray_quit" => {
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ipc: http://ipc.localhost"
|
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ipc: http://ipc.localhost",
|
||||||
|
"capabilities": ["default"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
|||||||
+246
-32
@@ -7,6 +7,8 @@
|
|||||||
fetchProfile,
|
fetchProfile,
|
||||||
fetchSearch,
|
fetchSearch,
|
||||||
fetchPost,
|
fetchPost,
|
||||||
|
openExternalUrl,
|
||||||
|
showError,
|
||||||
type Session,
|
type Session,
|
||||||
type Post,
|
type Post,
|
||||||
type ProfileResponse,
|
type ProfileResponse,
|
||||||
@@ -19,7 +21,7 @@
|
|||||||
import Terminal from "./lib/components/Terminal.svelte";
|
import Terminal from "./lib/components/Terminal.svelte";
|
||||||
import Skeleton from "./lib/components/Skeleton.svelte";
|
import Skeleton from "./lib/components/Skeleton.svelte";
|
||||||
|
|
||||||
type View = "home" | "compose" | "profile" | "search";
|
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||||
|
|
||||||
let view: View = $state("home");
|
let view: View = $state("home");
|
||||||
let currentUser: Session | null = $state(null);
|
let currentUser: Session | null = $state(null);
|
||||||
@@ -32,7 +34,6 @@
|
|||||||
let timelineError: string | null = $state(null);
|
let timelineError: string | null = $state(null);
|
||||||
let seenUris: Set<string> = new Set();
|
let seenUris: Set<string> = new Set();
|
||||||
let _statusTimer: number | undefined;
|
let _statusTimer: number | undefined;
|
||||||
let _timelinePollTimer: number | undefined;
|
|
||||||
|
|
||||||
// Profile state.
|
// Profile state.
|
||||||
let profile: ProfileResponse | null = $state(null);
|
let profile: ProfileResponse | null = $state(null);
|
||||||
@@ -91,7 +92,7 @@
|
|||||||
// — `main.ts` is the only place that wires to the Tauri event bus).
|
// — `main.ts` is the only place that wires to the Tauri event bus).
|
||||||
function onNavigateToView(e: Event) {
|
function onNavigateToView(e: Event) {
|
||||||
const detail = (e as CustomEvent<{ view: View }>).detail;
|
const detail = (e as CustomEvent<{ view: View }>).detail;
|
||||||
if (detail?.view) view = detail.view;
|
if (detail?.view) setView(detail.view);
|
||||||
}
|
}
|
||||||
function onNotification(e: Event) {
|
function onNotification(e: Event) {
|
||||||
const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail;
|
const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail;
|
||||||
@@ -131,7 +132,7 @@
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// unknown scheme — just open home
|
// unknown scheme — just open home
|
||||||
view = "home";
|
setView("home");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +185,7 @@
|
|||||||
registerCleanup(() => {
|
registerCleanup(() => {
|
||||||
if (_sessionUnsub) _sessionUnsub();
|
if (_sessionUnsub) _sessionUnsub();
|
||||||
if (_statusTimer) clearInterval(_statusTimer);
|
if (_statusTimer) clearInterval(_statusTimer);
|
||||||
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
|
if (_pollTimer != null) clearInterval(_pollTimer);
|
||||||
if (_searchDebounce) clearTimeout(_searchDebounce);
|
if (_searchDebounce) clearTimeout(_searchDebounce);
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.removeEventListener("maarcadetweet:toast", _toastHandler);
|
window.removeEventListener("maarcadetweet:toast", _toastHandler);
|
||||||
@@ -199,6 +200,11 @@
|
|||||||
_sessionUnsub = session.subscribe((s) => {
|
_sessionUnsub = session.subscribe((s) => {
|
||||||
currentUser = s;
|
currentUser = s;
|
||||||
status = { ...status, did: s?.did, handle: s?.handle, authenticated: !!s };
|
status = { ...status, did: s?.did, handle: s?.handle, authenticated: !!s };
|
||||||
|
// Drive the 5s poll off the session lifecycle instead of a
|
||||||
|
// reactive effect — the effect form kept tripping Svelte 5's
|
||||||
|
// depth guard.
|
||||||
|
if (s) startPoll();
|
||||||
|
else stopPoll();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -219,29 +225,48 @@
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-fetch the home timeline whenever we navigate to "home" or
|
// Imperative view-switching. We dispatch from a single function
|
||||||
// when the logged-in user changes. We also poll every 5s while
|
// (called by NavRail on_select, the LoginScreen onLogin path, and
|
||||||
// the home view is active so new posts trickle in.
|
// the tray-event bridge) so every view transition runs the same
|
||||||
$effect(() => {
|
// side effects in one place. Previously this was four separate
|
||||||
if (view === "home" && currentUser) {
|
// `$effect` blocks that read `view` / `currentUser` and called
|
||||||
|
// `refreshTimeline` / `refreshProfile` / `scheduleSearch`. Svelte
|
||||||
|
// 5's depth tracker kept aborting with `effect_update_depth_exceeded`
|
||||||
|
// because the sync portions of those refresh functions (`timelineLoading
|
||||||
|
// = true`, `profileLoading = true`) wrote $state that the effect's
|
||||||
|
// proxy-tracking had flagged as a self-write. Driving everything
|
||||||
|
// imperatively from a setter sidesteps the reactive cycle.
|
||||||
|
function setView(next: View) {
|
||||||
|
const prev = view;
|
||||||
|
view = next;
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
// Entering home from elsewhere — pull a fresh timeline and
|
||||||
|
// (re)start the poll timer. Leaving home clears it.
|
||||||
|
if (next === "home" && prev !== "home") {
|
||||||
void refreshTimeline(true);
|
void refreshTimeline(true);
|
||||||
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
|
|
||||||
_timelinePollTimer = window.setInterval(() => {
|
|
||||||
void refreshTimeline(false); // poll = prepend new posts, don't wipe
|
|
||||||
}, 5000);
|
|
||||||
} else if (_timelinePollTimer) {
|
|
||||||
clearInterval(_timelinePollTimer);
|
|
||||||
_timelinePollTimer = undefined;
|
|
||||||
}
|
}
|
||||||
|
if (next === "profile") {
|
||||||
if (view === "profile" && currentUser) {
|
const handle = currentUser.handle;
|
||||||
void refreshProfile(currentUser.handle);
|
void refreshProfile(handle);
|
||||||
}
|
}
|
||||||
|
if (next === "search" && searchQuery.trim().length > 0) {
|
||||||
if (view === "search" && currentUser && searchQuery.trim().length > 0) {
|
|
||||||
scheduleSearch();
|
scheduleSearch();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
let _pollTimer: number | undefined;
|
||||||
|
function startPoll() {
|
||||||
|
if (_pollTimer != null) return;
|
||||||
|
_pollTimer = window.setInterval(() => {
|
||||||
|
if (view === "home") void refreshTimeline(false);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
function stopPoll() {
|
||||||
|
if (_pollTimer == null) return;
|
||||||
|
window.clearInterval(_pollTimer);
|
||||||
|
_pollTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshTimeline(reset: boolean) {
|
async function refreshTimeline(reset: boolean) {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
@@ -351,6 +376,19 @@
|
|||||||
await refreshTimeline(true);
|
await refreshTimeline(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await session.logout();
|
||||||
|
setView("home");
|
||||||
|
profile = null;
|
||||||
|
searchResults = [];
|
||||||
|
threadRoot = null;
|
||||||
|
threadParent = null;
|
||||||
|
userPosts = [];
|
||||||
|
} catch (e) {
|
||||||
|
showError(`logout failed: ${String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Derive a display handle. The session already gives us the user's
|
// Derive a display handle. The session already gives us the user's
|
||||||
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
|
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
|
||||||
// posts that have empty handles it falls back to a synthetic
|
// posts that have empty handles it falls back to a synthetic
|
||||||
@@ -360,6 +398,24 @@
|
|||||||
if (h.startsWith("@")) return h;
|
if (h.startsWith("@")) return h;
|
||||||
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
|
||||||
|
// talking to. Kept as plain helpers so they can be swapped for a
|
||||||
|
// `pds_describe`/`appview_describe` Tauri command later.
|
||||||
|
function pdsBase(): string {
|
||||||
|
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) {
|
||||||
|
return (import.meta as any).env.VITE_PDS_URL as string;
|
||||||
|
}
|
||||||
|
return "http://127.0.0.1:2583";
|
||||||
|
}
|
||||||
|
function appviewBase(): string {
|
||||||
|
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) {
|
||||||
|
return (import.meta as any).env.VITE_APPVIEW_URL as string;
|
||||||
|
}
|
||||||
|
return "http://127.0.0.1:2584";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !currentUser}
|
{#if !currentUser}
|
||||||
@@ -367,7 +423,7 @@
|
|||||||
<LoginScreen
|
<LoginScreen
|
||||||
onLogin={(s) => {
|
onLogin={(s) => {
|
||||||
currentUser = s;
|
currentUser = s;
|
||||||
view = "home";
|
setView("home");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -375,9 +431,7 @@
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
<NavRail
|
<NavRail
|
||||||
{view}
|
{view}
|
||||||
on_select={(v) => {
|
on_select={(v) => setView(v)}
|
||||||
view = v;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
|
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
|
||||||
@@ -446,9 +500,10 @@
|
|||||||
{:else if profile}
|
{:else if profile}
|
||||||
<section class="profile">
|
<section class="profile">
|
||||||
<header class="profile__head">
|
<header class="profile__head">
|
||||||
<span class="profile__handle">{displayHandle(profile.handle)}</span>
|
<div class="profile__handle">{displayHandle(profile.handle)}</div>
|
||||||
<span class="profile__did" title={profile.did}>{profile.did}</span>
|
<div class="profile__did" title={profile.did}>{profile.did}</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="profile__actions">
|
<div class="profile__actions">
|
||||||
<button
|
<button
|
||||||
class="btn btn--ghost"
|
class="btn btn--ghost"
|
||||||
@@ -463,7 +518,23 @@
|
|||||||
onclick={() =>
|
onclick={() =>
|
||||||
copyToClipboard(`at://${profile!.did}/app.twi.post`)}
|
copyToClipboard(`at://${profile!.did}/app.twi.post`)}
|
||||||
>copy at-uri</button>
|
>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>
|
</div>
|
||||||
|
|
||||||
<dl class="counts">
|
<dl class="counts">
|
||||||
<div>
|
<div>
|
||||||
<dt>followers</dt>
|
<dt>followers</dt>
|
||||||
@@ -478,15 +549,90 @@
|
|||||||
<dd>{profile.posts.length}</dd>
|
<dd>{profile.posts.length}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{#if profile.posts.length === 0}
|
{#if profile.posts.length === 0}
|
||||||
<div class="empty">// no posts yet</div>
|
<div class="empty">// no posts yet — compose your first one</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<h3 class="profile__h3">// recent posts</h3>
|
||||||
{#each profile.posts as p (p.uri)}
|
{#each profile.posts as p (p.uri)}
|
||||||
<PostCard post={p} on_thread_click={openThread} />
|
<PostCard post={p} on_thread_click={openThread} />
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if view === "settings"}
|
||||||
|
<div class="head">
|
||||||
|
<span class="prompt">$</span>
|
||||||
|
<span class="title">// settings</span>
|
||||||
|
</div>
|
||||||
|
<section class="settings">
|
||||||
|
<h3 class="settings__h3">// account</h3>
|
||||||
|
<dl class="settings__rows">
|
||||||
|
<div>
|
||||||
|
<dt>handle</dt>
|
||||||
|
<dd>@{currentUser?.handle ?? "?"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>did</dt>
|
||||||
|
<dd class="did-cell">{currentUser?.did ?? "?"}</dd>
|
||||||
|
</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>
|
||||||
|
</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 class="settings__signout">
|
||||||
|
<button
|
||||||
|
class="btn btn--ghost btn--danger"
|
||||||
|
type="button"
|
||||||
|
onclick={handleLogout}
|
||||||
|
>sign out</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{:else if view === "search"}
|
{:else if view === "search"}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="prompt">$</span>
|
<span class="prompt">$</span>
|
||||||
@@ -595,8 +741,11 @@
|
|||||||
"rail status";
|
"rail status";
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.shell > :global(nav.rail) { grid-area: rail; }
|
/* NavRail and StatusBar self-assign their own grid-area
|
||||||
.shell > :global(.statusbar) { grid-area: status; }
|
(`grid-area: rail` / `grid-area: status`) in their component
|
||||||
|
styles, so the parent doesn't need any :global() child
|
||||||
|
selectors. The `.main` slot is just the next sibling; we set
|
||||||
|
its grid-area explicitly below. */
|
||||||
.main {
|
.main {
|
||||||
grid-area: main;
|
grid-area: main;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -753,4 +902,69 @@
|
|||||||
border-color: var(--red);
|
border-color: var(--red);
|
||||||
background: rgba(255, 59, 48, 0.08);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile__h3,
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.did-cell {
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: var(--fs-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
padding: 0 var(--s-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-2);
|
||||||
|
}
|
||||||
|
.settings__rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-1);
|
||||||
|
padding: var(--s-2) var(--s-4);
|
||||||
|
margin: 0 0 var(--s-4);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-50);
|
||||||
|
}
|
||||||
|
.settings__rows > div {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s-3);
|
||||||
|
}
|
||||||
|
.settings__rows dt {
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
min-width: 9rem;
|
||||||
|
}
|
||||||
|
.settings__rows dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.settings__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s-2);
|
||||||
|
margin: 0 0 var(--s-4);
|
||||||
|
}
|
||||||
|
.settings__signout {
|
||||||
|
margin-top: var(--s-4);
|
||||||
|
padding-top: var(--s-4);
|
||||||
|
border-top: 1px dashed var(--line);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -30,12 +30,40 @@ async function tauriCall<T>(cmd: string, fallback: T, args?: Record<string, unkn
|
|||||||
* the browser preview this throws a friendly Error so the UI can
|
* the browser preview this throws a friendly Error so the UI can
|
||||||
* show a "running in browser preview" notice. In the Tauri
|
* show a "running in browser preview" notice. In the Tauri
|
||||||
* webview it falls through to a normal `invoke` call.
|
* webview it falls through to a normal `invoke` call.
|
||||||
|
*
|
||||||
|
* **Auto-refresh on 401**: the access JWT expires after 1 hour. When
|
||||||
|
* the PDS rejects our token with `TokenInvalid` (the rusty
|
||||||
|
* `routes::auth` handlers return `{"error":"TokenInvalid",...}`),
|
||||||
|
* we ask the Rust shell for a fresh access JWT via the
|
||||||
|
* `auth_refresh` Tauri command. The Rust side reads the stored
|
||||||
|
* refresh JWT (valid for 90 days) and rotates both. We retry
|
||||||
|
* exactly once on the same `cmd` + `args`. The `auth_*` commands
|
||||||
|
* themselves are skipped so a failing login doesn't trigger an
|
||||||
|
* infinite refresh loop.
|
||||||
*/
|
*/
|
||||||
async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
throw new Error(`Tauri command ${cmd} requires the desktop runtime`);
|
throw new Error(`Tauri command ${cmd} requires the desktop runtime`);
|
||||||
}
|
}
|
||||||
return invoke<T>(cmd, args);
|
try {
|
||||||
|
return await invoke<T>(cmd, args);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!isTokenInvalid(e) || cmd.startsWith("auth_")) throw e;
|
||||||
|
const fresh = await session.refresh();
|
||||||
|
if (!fresh) throw e;
|
||||||
|
return await invoke<T>(cmd, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sniff out a `TokenInvalid` response from the Rust error string.
|
||||||
|
/// Returns true when the error message looks like an expired/
|
||||||
|
/// invalid JWT (the PDS uses a stable `"TokenInvalid"` code in its
|
||||||
|
/// JSON error body, which `@tauri-apps/api/core` surfaces verbatim).
|
||||||
|
function isTokenInvalid(e: unknown): boolean {
|
||||||
|
if (typeof e !== "object" || e === null) return false;
|
||||||
|
const msg = (e as { message?: string }).message ?? String(e);
|
||||||
|
if (!msg) return false;
|
||||||
|
return msg.includes("TokenInvalid") || msg.includes("ExpiredSignature");
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Session = {
|
export type Session = {
|
||||||
@@ -47,9 +75,35 @@ export type Session = {
|
|||||||
|
|
||||||
function createSessionStore() {
|
function createSessionStore() {
|
||||||
const { subscribe, set } = writable<Session | null>(null);
|
const { subscribe, set } = writable<Session | null>(null);
|
||||||
|
// Coalesce concurrent refresh requests into one — every safeInvoke
|
||||||
|
// call that hits a 401 would otherwise race to call auth_refresh in
|
||||||
|
// parallel. The pending promise is reset to `null` exactly once in
|
||||||
|
// the finally block; subsequent callers await the same one.
|
||||||
|
let pendingRefresh: Promise<Session | null> | null = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
|
/// Mint a fresh access JWT from the stored refresh JWT. Called
|
||||||
|
/// automatically by [`safeInvoke`] on `TokenInvalid` responses.
|
||||||
|
/// Returns the new session, or `null` if the refresh itself failed
|
||||||
|
/// (e.g. refresh JWT expired; at that point the user has to log
|
||||||
|
/// in again).
|
||||||
|
async refresh(): Promise<Session | null> {
|
||||||
|
if (pendingRefresh) return pendingRefresh;
|
||||||
|
pendingRefresh = (async () => {
|
||||||
|
try {
|
||||||
|
const s = await invoke<Session>("auth_refresh");
|
||||||
|
set(s);
|
||||||
|
return s;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("session refresh failed", e);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
pendingRefresh = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return pendingRefresh;
|
||||||
|
},
|
||||||
async load() {
|
async load() {
|
||||||
const s = await tauriCall<Session | null>("current_session", null);
|
const s = await tauriCall<Session | null>("current_session", null);
|
||||||
set(s);
|
set(s);
|
||||||
@@ -355,13 +409,13 @@ export async function showNotification(
|
|||||||
/// The handler receives the event payload (empty object for these
|
/// The handler receives the event payload (empty object for these
|
||||||
/// cases). Returns an unsubscribe function.
|
/// cases). Returns an unsubscribe function.
|
||||||
export async function listenTrayEvents(
|
export async function listenTrayEvents(
|
||||||
handler: (event: "show" | "home" | "compose" | "profile" | "search") => void,
|
handler: (event: "show" | "home" | "compose" | "profile" | "search" | "settings") => void,
|
||||||
): Promise<() => void> {
|
): Promise<() => void> {
|
||||||
const { listen } = await import("@tauri-apps/api/event");
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
const unlisteners: Array<() => void> = [];
|
const unlisteners: Array<() => void> = [];
|
||||||
const u1 = await listen("app://show", () => handler("show"));
|
const u1 = await listen("app://show", () => handler("show"));
|
||||||
const u2 = await listen("app://navigate", (e) => {
|
const u2 = await listen("app://navigate", (e) => {
|
||||||
handler(e.payload as "home" | "profile" | "search");
|
handler(e.payload as "home" | "profile" | "search" | "settings");
|
||||||
});
|
});
|
||||||
const u3 = await listen("app://compose", () => handler("compose"));
|
const u3 = await listen("app://compose", () => handler("compose"));
|
||||||
unlisteners.push(u1, u2, u3);
|
unlisteners.push(u1, u2, u3);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// `$bindable`, use a callback prop to bubble state changes up to
|
// `$bindable`, use a callback prop to bubble state changes up to
|
||||||
// the parent.
|
// the parent.
|
||||||
|
|
||||||
type View = "home" | "compose" | "profile" | "search";
|
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
view = "home",
|
view = "home",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
{ id: "compose", label: "compose", key: "c", icon: "compose" },
|
{ id: "compose", label: "compose", key: "c", icon: "compose" },
|
||||||
{ id: "profile", label: "profile", key: "p", icon: "profile" },
|
{ id: "profile", label: "profile", key: "p", icon: "profile" },
|
||||||
{ id: "search", label: "search", key: "/", icon: "search" },
|
{ id: "search", label: "search", key: "/", icon: "search" },
|
||||||
|
{ id: "settings", label: "settings", key: ",", icon: "settings" },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -44,6 +45,11 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||||
<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-7 8-7s8 3 8 7"/>
|
<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-7 8-7s8 3 8 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
{:else if item.icon === "settings"}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>
|
||||||
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||||
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
|
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
|
||||||
@@ -56,7 +62,15 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Self-assign the grid area so the parent App.svelte doesn't
|
||||||
|
need a `:global(nav.rail)` selector. Doing it via a parent
|
||||||
|
child-selector is fragile under Svelte 5's scoping — the
|
||||||
|
parent's `.shell.s-XXX > nav.rail` doesn't reliably match
|
||||||
|
`<nav class="rail s-YYY">` in the Tauri webview, which leaves
|
||||||
|
the buttons invisible to clicks. Owning the grid placement
|
||||||
|
here avoids the cross-component selector entirely. */
|
||||||
.rail {
|
.rail {
|
||||||
|
grid-area: rail;
|
||||||
width: 88px;
|
width: 88px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ async function mountHarness() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("NavRail (callback-prop pattern)", () => {
|
describe("NavRail (callback-prop pattern)", () => {
|
||||||
it("renders 4 buttons with home active", async () => {
|
it("renders 5 buttons with home active", async () => {
|
||||||
await mountHarness();
|
await mountHarness();
|
||||||
const btns = target.querySelectorAll("button.rail__btn");
|
const btns = target.querySelectorAll("button.rail__btn");
|
||||||
expect(btns.length).toBe(4);
|
expect(btns.length).toBe(5);
|
||||||
expect(btns[0].classList.contains("active")).toBe(true);
|
expect(btns[0].classList.contains("active")).toBe(true);
|
||||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
|
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
|
||||||
});
|
});
|
||||||
@@ -56,5 +56,10 @@ describe("NavRail (callback-prop pattern)", () => {
|
|||||||
await tick();
|
await tick();
|
||||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
|
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
|
||||||
expect(btns[2].classList.contains("active")).toBe(true);
|
expect(btns[2].classList.contains("active")).toBe(true);
|
||||||
|
|
||||||
|
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await tick();
|
||||||
|
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("settings");
|
||||||
|
expect(btns[4].classList.contains("active")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import NavRail from "./NavRail.svelte";
|
import NavRail from "./NavRail.svelte";
|
||||||
|
|
||||||
type View = "home" | "compose" | "profile" | "search";
|
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||||
let view: View = $state("home");
|
let view: View = $state("home");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
import {
|
import {
|
||||||
fetchPost,
|
fetchPost,
|
||||||
likePost,
|
likePost,
|
||||||
@@ -23,13 +24,24 @@
|
|||||||
let quotedErr: string | null = $state(null);
|
let quotedErr: string | null = $state(null);
|
||||||
let quotedLoading: boolean = $state(false);
|
let quotedLoading: boolean = $state(false);
|
||||||
|
|
||||||
|
// Resolve the embedded `app.bsky.embed.record` (a quoted post) by
|
||||||
|
// fetching the full record once per URI. We `untrack()` the
|
||||||
|
// in-flight check (`quoted` / `quotedLoading`) so a sync read+write
|
||||||
|
// of the same $state isn't reported as
|
||||||
|
// `effect_update_depth_exceeded` — without it, every time the
|
||||||
|
// effect re-fires (e.g. on parent re-render) Svelte 5's depth
|
||||||
|
// tracker saw `quotedLoading` read **and** flipped to `true`
|
||||||
|
// within the same tick.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const rec = (post.embed?.$type === "app.bsky.embed.record" || post.embed?.$type === "app.bsky.embed.recordWithMedia")
|
const rec = (post.embed?.$type === "app.bsky.embed.record" || post.embed?.$type === "app.bsky.embed.recordWithMedia")
|
||||||
? post.embed?.record
|
? post.embed?.record
|
||||||
: null;
|
: null;
|
||||||
if (rec?.uri && !quoted && !quotedLoading) {
|
const targetUri = rec?.uri;
|
||||||
|
if (!targetUri) return;
|
||||||
|
untrack(() => {
|
||||||
|
if (quoted || quotedLoading) return;
|
||||||
quotedLoading = true;
|
quotedLoading = true;
|
||||||
fetchPost(rec.uri)
|
fetchPost(targetUri)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
quoted = r.post;
|
quoted = r.post;
|
||||||
})
|
})
|
||||||
@@ -39,7 +51,7 @@
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
quotedLoading = false;
|
quotedLoading = false;
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resolve the embed shape once at render time. We sniff $type to
|
// Resolve the embed shape once at render time. We sniff $type to
|
||||||
@@ -99,13 +111,26 @@
|
|||||||
$state(null);
|
$state(null);
|
||||||
$effect.pre(() => {
|
$effect.pre(() => {
|
||||||
const k = localStorageKey(`liked:${post.did}:${post.rkey}`);
|
const k = localStorageKey(`liked:${post.did}:${post.rkey}`);
|
||||||
likedBox = useLocalStorage<{ liked: boolean; uri: string | null }>(k, {
|
// Re-create the box whenever the post changes; the hydration
|
||||||
liked: false,
|
// reads (`likedBox.get()`) and the writes that seed `liked` /
|
||||||
uri: null,
|
// `likedUri` from localStorage all happen inside `untrack` so
|
||||||
|
// `likedBox` (which is $state) is **read and written in the same
|
||||||
|
// effect run**. Without untrack, Svelte 5's effect tracker would
|
||||||
|
// schedule `possible_effect_self_invalidation` on `likedBox`
|
||||||
|
// and the effect would loop until `effect_update_depth_exceeded`
|
||||||
|
// fires. See the explorer agent's read-out: this is THE loop
|
||||||
|
// that took down login with `process_fn x 95`. The outer
|
||||||
|
// `post.did` / `post.rkey` reads remain tracked so the effect
|
||||||
|
// still re-runs when navigating from one card to the next.
|
||||||
|
untrack(() => {
|
||||||
|
likedBox = useLocalStorage<{ liked: boolean; uri: string | null }>(k, {
|
||||||
|
liked: false,
|
||||||
|
uri: null,
|
||||||
|
});
|
||||||
|
const stored = likedBox.get();
|
||||||
|
liked = stored.liked;
|
||||||
|
likedUri = stored.uri;
|
||||||
});
|
});
|
||||||
const stored = likedBox.get();
|
|
||||||
liked = stored.liked;
|
|
||||||
likedUri = stored.uri;
|
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!likedBox) return;
|
if (!likedBox) return;
|
||||||
|
|||||||
@@ -53,7 +53,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Self-assign the grid area — see NavRail.svelte for why we don't
|
||||||
|
rely on the parent's `:global(.statusbar)` child selector
|
||||||
|
(Svelte 5 scoping makes it unreliable in the Tauri webview). */
|
||||||
.statusbar {
|
.statusbar {
|
||||||
|
grid-area: status;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
|
|||||||
Reference in New Issue
Block a user