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 at_firehose::JetstreamEvent;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -133,13 +134,17 @@ impl IndexHandler {
|
||||
}
|
||||
},
|
||||
"identity" => {
|
||||
trace!(did = %ev.did, "identity event (logged only)");
|
||||
let _ = handle_identity(&ev);
|
||||
if let Err(e) = handle_identity(&self.db, &ev).await {
|
||||
warn!(error = %e, did = %ev.did, "handle_identity failed");
|
||||
return Ok(()); // don't advance cursor; let next replay retry
|
||||
}
|
||||
true
|
||||
}
|
||||
"account" => {
|
||||
trace!(did = %ev.did, "account event (logged only)");
|
||||
let _ = handle_account(&ev);
|
||||
if let Err(e) = handle_account(&self.db, &ev).await {
|
||||
warn!(error = %e, did = %ev.did, "handle_account failed");
|
||||
return Ok(()); // don't advance cursor; let next replay retry
|
||||
}
|
||||
true
|
||||
}
|
||||
other => {
|
||||
@@ -164,16 +169,67 @@ impl IndexHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_identity(_ev: &JetstreamEvent) -> Result<()> {
|
||||
info!("identity change (DID doc rotation)");
|
||||
/// `identity` event — Jetstream tells us a DID's handle changed.
|
||||
///
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
fn handle_account(_ev: &JetstreamEvent) -> Result<()> {
|
||||
info!("account change (active/-status)");
|
||||
/// `account` event — Jetstream tells us an account's active/deactive
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// writes the running maximum to the DB. Returns when the receiver is
|
||||
/// 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
|
||||
/// 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.
|
||||
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:%')
|
||||
ORDER BY did
|
||||
LIMIT $1"#,
|
||||
)
|
||||
|
||||
@@ -214,12 +214,21 @@ impl PostRow {
|
||||
/// by sniffing for `$type` (`app.bsky.embed.images` / `.external` /
|
||||
/// `.record`). Keeping it as raw JSON means we don't have to mirror
|
||||
/// 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(
|
||||
did: &str,
|
||||
rkey: &str,
|
||||
collection: &str,
|
||||
cid: &str,
|
||||
record: &Value,
|
||||
pds_handle: Option<&str>,
|
||||
) -> Self {
|
||||
let text = record
|
||||
.get("text")
|
||||
@@ -250,10 +259,14 @@ impl PostRow {
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
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 {
|
||||
uri,
|
||||
did: did.to_string(),
|
||||
handle: String::new(),
|
||||
handle,
|
||||
rkey: rkey.to_string(),
|
||||
collection: collection.to_string(),
|
||||
text,
|
||||
@@ -575,12 +588,17 @@ pub async fn apply_commit(
|
||||
})?;
|
||||
let cid = op.cid.clone().unwrap_or_default();
|
||||
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(
|
||||
&ev.did,
|
||||
&rkey,
|
||||
&collection,
|
||||
&cid,
|
||||
&record,
|
||||
None,
|
||||
);
|
||||
upsert_post(db, &row).await?;
|
||||
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");
|
||||
assert_eq!(embed["$type"], "app.bsky.embed.images");
|
||||
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");
|
||||
assert_eq!(embed["$type"], "app.bsky.embed.external");
|
||||
assert_eq!(embed["external"]["uri"], "https://example.com");
|
||||
@@ -857,7 +875,7 @@ mod tests {
|
||||
"text": "no embed here",
|
||||
"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());
|
||||
}
|
||||
|
||||
@@ -871,7 +889,7 @@ mod tests {
|
||||
"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.root_uri.as_deref(), Some("at://did:plc:b/app.twi.post/r"));
|
||||
}
|
||||
@@ -1000,6 +1018,7 @@ mod tests {
|
||||
"app.twi.post",
|
||||
"cid-embed",
|
||||
&record,
|
||||
None,
|
||||
);
|
||||
upsert_post(&db, &row).await.unwrap();
|
||||
|
||||
@@ -1092,3 +1111,31 @@ mod tests {
|
||||
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)]
|
||||
pub struct IngestCommitReq {
|
||||
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 action: String,
|
||||
pub rkey: String,
|
||||
@@ -127,6 +134,7 @@ async fn apply(
|
||||
&req.collection,
|
||||
&cid,
|
||||
&record,
|
||||
req.handle.as_deref(),
|
||||
);
|
||||
indexer::upsert_post(&state.db, &row).await.map_err(db_err)?;
|
||||
Ok(true)
|
||||
|
||||
@@ -355,13 +355,24 @@ async fn resolve_profile(
|
||||
};
|
||||
|
||||
let Some(target_did) = target_did else {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
"error": "NotFound",
|
||||
"message": "no DID or handle provided, or no posts for that handle",
|
||||
})),
|
||||
));
|
||||
// No posts indexed for this handle yet — typical for
|
||||
// local-PDS users whose posts haven't been ingested into
|
||||
// the Jetstream yet (and the AppView indexes only ingested
|
||||
// posts, never queries upstream PDSs for handle→DID
|
||||
// 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
|
||||
|
||||
@@ -32,6 +32,11 @@ use std::time::Duration;
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IngestCommitBody<'a> {
|
||||
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,
|
||||
action: &'a str,
|
||||
rkey: &'a str,
|
||||
@@ -63,6 +68,14 @@ impl AppViewPushClient {
|
||||
/// 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.
|
||||
///
|
||||
/// `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)`
|
||||
/// 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
|
||||
@@ -70,6 +83,7 @@ impl AppViewPushClient {
|
||||
pub async fn push_create(
|
||||
&self,
|
||||
did: &str,
|
||||
handle: Option<&str>,
|
||||
collection: &str,
|
||||
rkey: &str,
|
||||
cid: &str,
|
||||
@@ -77,6 +91,7 @@ impl AppViewPushClient {
|
||||
) -> Result<bool> {
|
||||
self.push(
|
||||
did,
|
||||
handle,
|
||||
collection,
|
||||
"create",
|
||||
rkey,
|
||||
@@ -93,19 +108,21 @@ impl AppViewPushClient {
|
||||
collection: &str,
|
||||
rkey: &str,
|
||||
) -> Result<bool> {
|
||||
self.push(did, collection, "delete", rkey, None, None, None)
|
||||
self.push(did, None, collection, "delete", rkey, None, None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn push_follow_create(
|
||||
&self,
|
||||
did: &str,
|
||||
handle: Option<&str>,
|
||||
rkey: &str,
|
||||
subject_did: &str,
|
||||
record: &Value,
|
||||
) -> Result<bool> {
|
||||
self.push(
|
||||
did,
|
||||
handle,
|
||||
"app.bsky.graph.follow",
|
||||
"create",
|
||||
rkey,
|
||||
@@ -124,6 +141,7 @@ impl AppViewPushClient {
|
||||
) -> Result<bool> {
|
||||
self.push(
|
||||
did,
|
||||
None,
|
||||
"app.bsky.graph.follow",
|
||||
"delete",
|
||||
rkey,
|
||||
@@ -137,6 +155,7 @@ impl AppViewPushClient {
|
||||
async fn push(
|
||||
&self,
|
||||
did: &str,
|
||||
handle: Option<&str>,
|
||||
collection: &str,
|
||||
action: &str,
|
||||
rkey: &str,
|
||||
@@ -147,6 +166,7 @@ impl AppViewPushClient {
|
||||
let url = format!("{}/internal/ingest-commit", self.base_url);
|
||||
let body = IngestCommitBody {
|
||||
did,
|
||||
handle,
|
||||
collection,
|
||||
action,
|
||||
rkey,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//! 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.
|
||||
|
||||
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 crate::routes::types::ErrorBody;
|
||||
use crate::state::AppState;
|
||||
@@ -273,6 +273,7 @@ pub async fn create_like(
|
||||
let value_cid_str = value_cid.to_string();
|
||||
let push_record = record.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 value_cid = value_cid;
|
||||
@@ -317,6 +318,7 @@ pub async fn create_like(
|
||||
if let Err(e) = push_handle
|
||||
.push_create(
|
||||
&push_did,
|
||||
push_handle_str.as_deref(),
|
||||
LIKE_COLLECTION,
|
||||
&push_rkey_owned,
|
||||
&push_cid_owned,
|
||||
|
||||
@@ -454,3 +454,24 @@ async fn persist_user_blocks_in_tx(
|
||||
}
|
||||
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::{
|
||||
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::state::AppState;
|
||||
@@ -92,6 +92,16 @@ pub async fn create_record(
|
||||
let push_rkey = rkey.clone();
|
||||
let push_cid = value_cid.to_string();
|
||||
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 outcome = apply_repo_write(&state, &did, move |repo| {
|
||||
@@ -136,7 +146,14 @@ pub async fn create_record(
|
||||
// the AppView.
|
||||
tokio::spawn(async move {
|
||||
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
|
||||
{
|
||||
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;
|
||||
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!`
|
||||
// resolves paths relative to `CARGO_MANIFEST_DIR` and
|
||||
// bakes the raw RGBA pixels into the binary, so the
|
||||
@@ -606,6 +615,14 @@ pub fn run() {
|
||||
None::<&str>,
|
||||
)
|
||||
.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(
|
||||
app,
|
||||
"tray_quit",
|
||||
@@ -625,6 +642,7 @@ pub fn run() {
|
||||
&compose_item,
|
||||
&profile_item,
|
||||
&search_item,
|
||||
&settings_item,
|
||||
&separator,
|
||||
&quit_item,
|
||||
],
|
||||
@@ -653,6 +671,9 @@ pub fn run() {
|
||||
"tray_search" => {
|
||||
let _ = tauri::Emitter::emit(app, "app://navigate", "search");
|
||||
}
|
||||
"tray_settings" => {
|
||||
let _ = tauri::Emitter::emit(app, "app://navigate", "settings");
|
||||
}
|
||||
"tray_quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
|
||||
+246
-32
@@ -7,6 +7,8 @@
|
||||
fetchProfile,
|
||||
fetchSearch,
|
||||
fetchPost,
|
||||
openExternalUrl,
|
||||
showError,
|
||||
type Session,
|
||||
type Post,
|
||||
type ProfileResponse,
|
||||
@@ -19,7 +21,7 @@
|
||||
import Terminal from "./lib/components/Terminal.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 currentUser: Session | null = $state(null);
|
||||
@@ -32,7 +34,6 @@
|
||||
let timelineError: string | null = $state(null);
|
||||
let seenUris: Set<string> = new Set();
|
||||
let _statusTimer: number | undefined;
|
||||
let _timelinePollTimer: number | undefined;
|
||||
|
||||
// Profile state.
|
||||
let profile: ProfileResponse | null = $state(null);
|
||||
@@ -91,7 +92,7 @@
|
||||
// — `main.ts` is the only place that wires to the Tauri event bus).
|
||||
function onNavigateToView(e: Event) {
|
||||
const detail = (e as CustomEvent<{ view: View }>).detail;
|
||||
if (detail?.view) view = detail.view;
|
||||
if (detail?.view) setView(detail.view);
|
||||
}
|
||||
function onNotification(e: Event) {
|
||||
const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail;
|
||||
@@ -131,7 +132,7 @@
|
||||
}
|
||||
} else {
|
||||
// unknown scheme — just open home
|
||||
view = "home";
|
||||
setView("home");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +185,7 @@
|
||||
registerCleanup(() => {
|
||||
if (_sessionUnsub) _sessionUnsub();
|
||||
if (_statusTimer) clearInterval(_statusTimer);
|
||||
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
|
||||
if (_pollTimer != null) clearInterval(_pollTimer);
|
||||
if (_searchDebounce) clearTimeout(_searchDebounce);
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("maarcadetweet:toast", _toastHandler);
|
||||
@@ -199,6 +200,11 @@
|
||||
_sessionUnsub = session.subscribe((s) => {
|
||||
currentUser = 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") {
|
||||
@@ -219,29 +225,48 @@
|
||||
})();
|
||||
});
|
||||
|
||||
// Re-fetch the home timeline whenever we navigate to "home" or
|
||||
// when the logged-in user changes. We also poll every 5s while
|
||||
// the home view is active so new posts trickle in.
|
||||
$effect(() => {
|
||||
if (view === "home" && currentUser) {
|
||||
// Imperative view-switching. We dispatch from a single function
|
||||
// (called by NavRail on_select, the LoginScreen onLogin path, and
|
||||
// the tray-event bridge) so every view transition runs the same
|
||||
// side effects in one place. Previously this was four separate
|
||||
// `$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);
|
||||
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 (view === "profile" && currentUser) {
|
||||
void refreshProfile(currentUser.handle);
|
||||
if (next === "profile") {
|
||||
const handle = currentUser.handle;
|
||||
void refreshProfile(handle);
|
||||
}
|
||||
|
||||
if (view === "search" && currentUser && searchQuery.trim().length > 0) {
|
||||
if (next === "search" && searchQuery.trim().length > 0) {
|
||||
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) {
|
||||
if (!currentUser) return;
|
||||
@@ -351,6 +376,19 @@
|
||||
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
|
||||
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
|
||||
// posts that have empty handles it falls back to a synthetic
|
||||
@@ -360,6 +398,24 @@
|
||||
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
|
||||
// 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>
|
||||
|
||||
{#if !currentUser}
|
||||
@@ -367,7 +423,7 @@
|
||||
<LoginScreen
|
||||
onLogin={(s) => {
|
||||
currentUser = s;
|
||||
view = "home";
|
||||
setView("home");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -375,9 +431,7 @@
|
||||
<div class="shell">
|
||||
<NavRail
|
||||
{view}
|
||||
on_select={(v) => {
|
||||
view = v;
|
||||
}}
|
||||
on_select={(v) => setView(v)}
|
||||
/>
|
||||
<div class="main">
|
||||
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
|
||||
@@ -446,9 +500,10 @@
|
||||
{:else if profile}
|
||||
<section class="profile">
|
||||
<header class="profile__head">
|
||||
<span class="profile__handle">{displayHandle(profile.handle)}</span>
|
||||
<span class="profile__did" title={profile.did}>{profile.did}</span>
|
||||
<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"
|
||||
@@ -463,7 +518,23 @@
|
||||
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>
|
||||
@@ -478,15 +549,90 @@
|
||||
<dd>{profile.posts.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if profile.posts.length === 0}
|
||||
<div class="empty">// no posts yet</div>
|
||||
<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>
|
||||
{/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"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
@@ -595,8 +741,11 @@
|
||||
"rail status";
|
||||
min-height: 0;
|
||||
}
|
||||
.shell > :global(nav.rail) { grid-area: rail; }
|
||||
.shell > :global(.statusbar) { grid-area: status; }
|
||||
/* NavRail and StatusBar self-assign their own grid-area
|
||||
(`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 {
|
||||
grid-area: main;
|
||||
overflow: auto;
|
||||
@@ -753,4 +902,69 @@
|
||||
border-color: var(--red);
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
* show a "running in browser preview" notice. In the Tauri
|
||||
* 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> {
|
||||
if (!isTauri()) {
|
||||
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 = {
|
||||
@@ -47,9 +75,35 @@ export type Session = {
|
||||
|
||||
function createSessionStore() {
|
||||
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 {
|
||||
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() {
|
||||
const s = await tauriCall<Session | null>("current_session", null);
|
||||
set(s);
|
||||
@@ -355,13 +409,13 @@ export async function showNotification(
|
||||
/// The handler receives the event payload (empty object for these
|
||||
/// cases). Returns an unsubscribe function.
|
||||
export async function listenTrayEvents(
|
||||
handler: (event: "show" | "home" | "compose" | "profile" | "search") => void,
|
||||
handler: (event: "show" | "home" | "compose" | "profile" | "search" | "settings") => void,
|
||||
): Promise<() => void> {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
const unlisteners: Array<() => void> = [];
|
||||
const u1 = await listen("app://show", () => handler("show"));
|
||||
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"));
|
||||
unlisteners.push(u1, u2, u3);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// `$bindable`, use a callback prop to bubble state changes up to
|
||||
// the parent.
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search";
|
||||
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||
|
||||
let {
|
||||
view = "home",
|
||||
@@ -19,6 +19,7 @@
|
||||
{ id: "compose", label: "compose", key: "c", icon: "compose" },
|
||||
{ id: "profile", label: "profile", key: "p", icon: "profile" },
|
||||
{ id: "search", label: "search", key: "/", icon: "search" },
|
||||
{ id: "settings", label: "settings", key: ",", icon: "settings" },
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -44,6 +45,11 @@
|
||||
<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"/>
|
||||
</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}
|
||||
<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"/>
|
||||
@@ -56,7 +62,15 @@
|
||||
</nav>
|
||||
|
||||
<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 {
|
||||
grid-area: rail;
|
||||
width: 88px;
|
||||
background: var(--bg);
|
||||
border-right: 1px solid var(--line);
|
||||
|
||||
@@ -29,10 +29,10 @@ async function mountHarness() {
|
||||
}
|
||||
|
||||
describe("NavRail (callback-prop pattern)", () => {
|
||||
it("renders 4 buttons with home active", async () => {
|
||||
it("renders 5 buttons with home active", async () => {
|
||||
await mountHarness();
|
||||
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(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
|
||||
});
|
||||
@@ -56,5 +56,10 @@ describe("NavRail (callback-prop pattern)", () => {
|
||||
await tick();
|
||||
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
|
||||
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";
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search";
|
||||
type View = "home" | "compose" | "profile" | "search" | "settings";
|
||||
let view: View = $state("home");
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
fetchPost,
|
||||
likePost,
|
||||
@@ -23,13 +24,24 @@
|
||||
let quotedErr: string | null = $state(null);
|
||||
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(() => {
|
||||
const rec = (post.embed?.$type === "app.bsky.embed.record" || post.embed?.$type === "app.bsky.embed.recordWithMedia")
|
||||
? post.embed?.record
|
||||
: null;
|
||||
if (rec?.uri && !quoted && !quotedLoading) {
|
||||
const targetUri = rec?.uri;
|
||||
if (!targetUri) return;
|
||||
untrack(() => {
|
||||
if (quoted || quotedLoading) return;
|
||||
quotedLoading = true;
|
||||
fetchPost(rec.uri)
|
||||
fetchPost(targetUri)
|
||||
.then((r) => {
|
||||
quoted = r.post;
|
||||
})
|
||||
@@ -39,7 +51,7 @@
|
||||
.finally(() => {
|
||||
quotedLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Resolve the embed shape once at render time. We sniff $type to
|
||||
@@ -99,6 +111,18 @@
|
||||
$state(null);
|
||||
$effect.pre(() => {
|
||||
const k = localStorageKey(`liked:${post.did}:${post.rkey}`);
|
||||
// Re-create the box whenever the post changes; the hydration
|
||||
// reads (`likedBox.get()`) and the writes that seed `liked` /
|
||||
// `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,
|
||||
@@ -107,6 +131,7 @@
|
||||
liked = stored.liked;
|
||||
likedUri = stored.uri;
|
||||
});
|
||||
});
|
||||
$effect(() => {
|
||||
if (!likedBox) return;
|
||||
likedBox.set({ liked, uri: likedUri });
|
||||
|
||||
@@ -53,7 +53,11 @@
|
||||
</div>
|
||||
|
||||
<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 {
|
||||
grid-area: status;
|
||||
height: 24px;
|
||||
background: var(--bg-elev);
|
||||
border-top: 1px solid var(--line);
|
||||
|
||||
Reference in New Issue
Block a user