diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6957dc4f..5a95334f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -159,7 +159,7 @@ jobs:
- name: Test the static copy
run: |
bash scripts/build-pages.sh
- E2E_PAGES=1 npx playwright test e2e/static-body-limit.fixture.spec.ts e2e/static-metadata.fixture.spec.ts e2e/static-navigation.fixture.spec.ts e2e/static-follows.fixture.spec.ts e2e/preloaded-replies.fixture.spec.ts e2e/poll.fixture.spec.ts --output=test-results/static
+ E2E_PAGES=1 npx playwright test e2e/static-body-limit.fixture.spec.ts e2e/static-metadata.fixture.spec.ts e2e/static-navigation.fixture.spec.ts e2e/static-follows.fixture.spec.ts e2e/preloaded-replies.fixture.spec.ts e2e/poll.fixture.spec.ts e2e/library.fixture.spec.ts --output=test-results/static
env:
PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report/static
- name: Upload browser failure reports
diff --git a/AGENTS.md b/AGENTS.md
index 054e9f14..ef19748b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,7 +16,7 @@ Allthing is a Farcaster client for reading, written in Rust: a Leptos WebAssembl
- Payloads go through `cache::serve`.
- Never await another request's I/O; see the coalescing notes in `cache.rs`.
- Update `docs/BUDGET.md` when costs change.
-- **Links.** The app's routes are `/`, `/c/`, `/p/`, `/u/`, `/u//followers`, `/u//following`, `/channels`, `/search?q=`, `/dau`, `/scheduled`, `/messages` (`?c=`, `?g=`, `?new=1`) and `/notifications` (`?tab=mentions` or `replies`). Build them with the helpers in `crates/allthing-web/src/util.rs` (`href`, `channel_href`, …), never as literal strings, and go to one from code with `util::use_go`, not the router's `use_navigate`, which puts the base before it again: the GitHub Pages build lives under `/Allthing`.
+- **Links.** The app's routes are `/`, `/c/`, `/p/`, `/u/`, `/u//followers`, `/u//following`, `/channels`, `/search?q=`, `/dau`, `/scheduled`, `/messages` (`?c=`, `?g=`, `?new=1`), `/notifications` (`?tab=mentions` or `replies`), and `/library` (`?tab=bookmarks|likes|recasts|seen`, optional `q` filters loaded posts). Build them with the helpers in `crates/allthing-web/src/util.rs` (`href`, `channel_href`, …), never as literal strings, and go to one from code with `util::use_go`, not the router's `use_navigate`, which puts the base before it again: the GitHub Pages build lives under `/Allthing`.
- **Licensing.** Licensing is AGPL-3.0-or-later. Start new files with an SPDX header. New dependencies must pass `cargo deny check licenses`.
- **Secrets.** Never commit secrets, tokens, `.dev.vars` or `.env` files, or session URLs. The Worker's secrets (`SESSION_SECRET`, `SOLANA_RPC_URL`) are set in Cloudflare; see `docs/DEPLOY.md#secrets`.
- **Checks.**
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1bbb6e2d..437ace94 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,16 +7,25 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch
### Added
+- A private Library for bookmarks and seen history synced across signed-in devices through SpacetimeDB, plus paginated Farcaster likes and recasts. Seen history no longer expires after 30 days or 2,000 posts; fresh Home candidates wait for synced history, while already drawn cards keep their place. Library includes loaded-post filtering, activity times, retry, Back restoration, Mark unread and Clear history controls.
- Sync notification read progress across signed-in sessions with a private, attested SpacetimeDB watermark. The existing local cache works offline; a delayed background connection uses separate credentials without creating chat presence or DM keys.
- Videos up to 250 MB upload in 8 MB chunks. Retry continues from the last acknowledged chunk; Remove cancels the pending upload. Preparation and finishing have visible states, and the cast stays disabled until its attachments are ready.
- Compatible MP4 and MOV videos are prepared for HLS from bounded metadata and file slices, preserving their frames and sound without copying the entire video into WebAssembly. Existing image limits and storage safeguards remain.
+### Changed
+
+- Following opens with New selected; For you still opens with Hot. Explicit sort choices remain selected when changing the time range.
+
### Fixed
+- Keep Cast available on Notifications and while Settings is open, including the phone button; one press dismisses Settings and opens the composer.
+- Leave enough room between phone post actions for the Save button across platform fonts, preserving their tap targets.
- After Escape closes a hover card, layout changes cannot reopen it under a stationary pointer. Moving away and back still opens it normally.
### Development
+- Library Back checks compare with the card position at the actual click, after browser actionability scrolling, while retaining the two-pixel restoration limit and exact cached-page checks.
+
- Move build orchestration, Brotli compression, repository validation, asset-credit checks, deployment waiting, isolated database-module checks, audit handling and browser fixture orchestration into native Rust `xtask` commands. Retain official Playwright tests and independent cryptography, SQL and SDK codec checks.
- Live layout and interaction checks wait for loaded app content instead of loading placeholders or inert early cards; first-paint and navigation timing limits remain unchanged.
diff --git a/README.md b/README.md
index e7312fdf..c3432555 100644
--- a/README.md
+++ b/README.md
@@ -31,8 +31,8 @@ Allthing is an independent Farcaster client, written in Rust, that shows trendin
### Reading, no account needed
-- **Feeds.** A front page sorted **hot**, **new** or **top**, with a time filter in every sort. Signed in, **For you** lifts casts from recently active accounts you follow, while **Following** shows their casts only. New stays chronological. Channel feeds, a channel directory and one search box cover casts, people and channels; the client bar filters casts by the app they came from.
-- **Seen posts.** Front-page feeds leave out posts you already read on a later visit, with Show seen posts and Reset seen posts to bring them back. History stays in this browser for 30 days, separate for each reader; cards stay put while you read.
+- **Feeds.** A front page sorted **hot**, **new** or **top**, with a time filter in every sort. Signed in, **For you** defaults to **hot** and lifts casts from recently active accounts you follow, while **Following** defaults to **new** and shows their casts only. New stays chronological. Channel feeds, a channel directory and one search box cover casts, people and channels; the client bar filters casts by the app they came from.
+- **Seen posts.** Home leaves out posts you already read on future visits. Signed-in history stays private and syncs across devices through SpacetimeDB without a 30-day expiry or 2,000-post limit. Cards stay put while you read; anonymous history stays in this browser for 30 days.
- **Who is here.** An online count beside Home, including on phones. Signed-in readers can choose whether their name appears.
- **Posts and threads.** A post with its parent, quoted casts (inline up to three levels) and threaded replies that collapse and expand.
- **Honest engagement.** Likes, recasts and replies, the only counts Farcaster has. Hovering or tapping a count lists who liked, recasted or replied.
@@ -50,6 +50,7 @@ Allthing is an independent Farcaster client, written in Rust, that shows trendin
- **Sign in by one QR code.** Approve Allthing once in the Farcaster app (a QR code on a computer, a button on a phone). That one approval signs you in and gives your browser a key to write with; the key never leaves it.
- **Write from Allthing.** Cast, reply, quote, like, recast, follow, delete your own casts and [schedule casts](docs/ARCHITECTURE.md#scheduled-casts) (listed at /scheduled), shown "via Allthing". Each is a Farcaster message signed in your browser and sent to Hypersnap; Allthing asks for keys as its own account, @allthingapp.
- **A composer like the Farcaster app's.** Images and videos (PNG, JPEG, GIF, WebP; MP4, MOV, WebM) by the attach button, paste or drop, with photo location data stripped in the browser; @mention autocomplete; a Pepe emoji picker, animated Pepe stickers and GIFs (from [KLIPY](https://klipy.com)); drafts that last; a sheet on the keyboard on a phone.
+- **Library.** Bookmarks, Likes, Recasts and Seen, with paginated history, activity times, a filter for loaded posts and Back restoration. Save posts privately without a Farcaster signing key; mark posts unread or clear reading history across devices while keeping bookmarks.
- **Notifications.** A bell with what's new, and /notifications with likes, recasts, replies, mentions, quotes and follows, grouped as the Farcaster app groups them.
- **Messages.** One inbox on SpacetimeDB at /messages: Allthing's public `#general` room and end-to-end encrypted conversations ([docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#messages-on-spacetimedb)).
- **Signed out**, a write opens the cast, the profile or a ready draft in the Farcaster app instead.
@@ -57,7 +58,7 @@ Allthing is an independent Farcaster client, written in Rust, that shows trendin
### Fast and frugal
- **One API call per page**, cached at the edge, so the whole site runs on the Cloudflare Workers Free plan.
-- **Early paint.** A small JavaScript renderer draws the front page's first cards from its existing response while the Rust app downloads. It stays in JavaScript because it beat the Rust prototype in the [paired cold-load comparison](docs/PERFORMANCE.md#measured-exception-the-javascript-first-card-renderer).
+- **Early paint.** For anonymous readers, a small JavaScript renderer draws the front page's first cards from its existing response while the Rust app downloads. Signed-in Home waits for private history membership to avoid flashing previously read posts. It stays in JavaScript because it beat the Rust prototype in the [paired cold-load comparison](docs/PERFORMANCE.md#measured-exception-the-javascript-first-card-renderer).
- **Split WebAssembly.** Pages, the composer, writing, sign-in and the DAU dashboard are code of their own, fetched when used, so every visit downloads less.
- **Instant navigation.** Posts open from the feed's own data, cards are prefetched within a capped budget, and Back returns to the same place. CI holds smoothness to budgets ([docs/PERFORMANCE.md](docs/PERFORMANCE.md)).
- **Accessible.** Keyboard throughout, alt text, focus kept in dialogs, reduced motion respected, and it fits a phone.
diff --git a/crates/allthing-core/src/api.rs b/crates/allthing-core/src/api.rs
index 808f39c9..c408ec90 100644
--- a/crates/allthing-core/src/api.rs
+++ b/crates/allthing-core/src/api.rs
@@ -15,7 +15,7 @@ use crate::latest::Cursor;
use crate::model::{Cast, Channel, ThreadNode, User, UserSummary};
use crate::params::{channel_key_from_param, hash_from_param, percent_decode, percent_encode, username_from_param};
use crate::profile::UserCard;
-use crate::provenance::{Backend, Origin};
+use crate::provenance::{Backend, Origin, base64_decode};
use crate::ranking::{Sort, TimeRange, TrendingWindow};
use crate::website::Website;
@@ -242,6 +242,100 @@ impl ReactionKind {
}
}
+/// Library reads are small batches, independent of the account's history size.
+pub const LIBRARY_PAGE_SIZE: usize = 25;
+pub const LIBRARY_PATH: &str = "/api/v1/library";
+pub const LIBRARY_CASTS_PATH: &str = "/api/v1/library/casts";
+
+/// An active like/recast and the cast it targets. `at` is the reaction's
+/// Unix time, not the cast's publication time.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LibraryEntry {
+ pub hash: String,
+ pub author_fid: u64,
+ pub at: i64,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LibraryPayload {
+ pub fid: u64,
+ pub kind: ReactionKind,
+ pub entries: Vec,
+ /// Available casts in reaction order, with mini apps marked for settings.
+ pub pool: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub next: Option,
+ /// Omitted by a successful provider lookup; this does not assert deletion.
+ #[serde(default)]
+ pub unavailable: Vec,
+ /// Hydrated, but omitted by the app's content rules, not by the provider.
+ #[serde(default)]
+ pub filtered: Vec,
+ #[serde(default)]
+ pub notes: Annotations,
+ pub meta: Meta,
+}
+
+/// Public cast hydration for a private list. This endpoint knows only the
+/// requested public cast hashes, not whether they were bookmarked or seen.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LibraryCastsPayload {
+ pub pool: Vec,
+ #[serde(default)]
+ pub unavailable: Vec,
+ #[serde(default)]
+ pub filtered: Vec,
+ #[serde(default)]
+ pub notes: Annotations,
+ pub meta: Meta,
+}
+
+/// An opaque hub key: validate its bounded encoding, never reinterpret it as
+/// a multi-shard cursor. An account's outgoing reactions live in one shard.
+pub fn library_cursor(value: &str) -> Option {
+ if value.is_empty()
+ || value.len() > 1024
+ || !value.bytes().all(|b| b.is_ascii_alphanumeric() || b"+/=".contains(&b))
+ {
+ return None;
+ }
+ let bytes = base64_decode(value)?;
+ (!bytes.is_empty() && bytes.len() <= 512).then(|| value.to_owned())
+}
+
+/// Canonical, unique hashes in the caller's order. Never silently truncate a
+/// request: the limit applies to its raw slots too, keeping URLs bounded.
+pub fn parse_library_hashes(value: Option<&str>) -> Option> {
+ let value = value?;
+ if value.is_empty() || value.len() > LIBRARY_PAGE_SIZE * 45 {
+ return None;
+ }
+ let mut hashes = Vec::new();
+ let mut seen = HashSet::new();
+ for (index, value) in value.split(',').enumerate() {
+ if index >= LIBRARY_PAGE_SIZE {
+ return None;
+ }
+ let hash = hash_from_param(value)?;
+ if seen.insert(hash.clone()) {
+ hashes.push(hash);
+ }
+ }
+ (!hashes.is_empty()).then_some(hashes)
+}
+
+pub fn library_path(fid: u64, kind: ReactionKind, cursor: Option<&str>) -> String {
+ let mut path = format!("{LIBRARY_PATH}?fid={fid}&kind={}", kind.as_str());
+ if let Some(cursor) = cursor {
+ path.push_str(&format!("&cursor={}", percent_encode(cursor)));
+ }
+ path
+}
+
+pub fn library_casts_path(hashes: &[String]) -> String {
+ format!("{LIBRARY_CASTS_PATH}?hashes={}", hashes.join(","))
+}
+
/// Who liked or recasted a cast, newest first.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReactionsPayload {
@@ -978,6 +1072,8 @@ pub const WALLET_RETRY_CACHE: CachePolicy = CachePolicy { fresh: 300, stale: 600
pub const CHANNELS_CACHE: CachePolicy = CachePolicy { fresh: 3600, stale: 86_400, keep: 172_800, browser: 600 };
pub const SEARCH_CACHE: CachePolicy = CachePolicy { fresh: 300, stale: 1800, keep: 86_400, browser: 120 };
pub const REACTIONS_CACHE: CachePolicy = CachePolicy { fresh: 300, stale: 3600, keep: 86_400, browser: 300 };
+/// Short freshness for lists changed by writes in other Farcaster clients.
+pub const LIBRARY_CACHE: CachePolicy = CachePolicy { fresh: 60, stale: 300, keep: 3600, browser: 0 };
/// A batch of like, recast and replies previews: as fresh as a like or
/// recast preview, since that is what most of them become in the browser.
/// Its replies previews may be a few minutes older than `/replies`' own,
@@ -1011,6 +1107,34 @@ pub const MISS_CACHE: CachePolicy = CachePolicy { fresh: 300, stale: 300, keep:
mod tests {
use super::*;
+ #[test]
+ fn library_batches_preserve_order_without_silent_truncation() {
+ let (a, b) = (format!("0x{:040x}", 1), format!("0x{:040x}", 2));
+ assert_eq!(
+ parse_library_hashes(Some(&format!("{b},{},{b}", a.to_uppercase()))),
+ Some(vec![b.clone(), a.clone()])
+ );
+ let batch = vec![a; LIBRARY_PAGE_SIZE];
+ assert!(parse_library_hashes(Some(&batch.join(","))).is_some());
+ assert!(parse_library_hashes(Some(&[batch, vec![b]].concat().join(","))).is_none());
+ for value in [None, Some(""), Some("0x12"), Some("https://example.org"), Some(",")] {
+ assert!(parse_library_hashes(value).is_none());
+ }
+ }
+
+ #[test]
+ fn library_cursors_are_bounded_opaque_bytes_and_escaped_once() {
+ let token = "+/8=";
+ assert_eq!(library_cursor(token).as_deref(), Some(token));
+ assert_eq!(
+ library_path(7, ReactionKind::Likes, Some(token)),
+ "/api/v1/library?fid=7&kind=likes&cursor=%2B%2F8%3D"
+ );
+ for token in ["", "%%%", "a b", "\n", &"A".repeat(1025)] {
+ assert!(library_cursor(token).is_none());
+ }
+ }
+
#[test]
fn pages_read_what_they_ask_for() {
let hash = format!("0x{}", "ab".repeat(20));
diff --git a/crates/allthing-core/src/following.rs b/crates/allthing-core/src/following.rs
index 001ec7ca..c6970aa3 100644
--- a/crates/allthing-core/src/following.rs
+++ b/crates/allthing-core/src/following.rs
@@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
use crate::api::{API_PREFIX, Annotations, CachePolicy, Meta};
use crate::model::Cast;
+use crate::ranking::Sort;
/// Where the app reads it.
pub const FOLLOWING_PATH: &str = "/api/v1/following";
@@ -36,6 +37,19 @@ pub enum Feed {
}
impl Feed {
+ /// Following starts chronologically; For you starts with its hot ranking.
+ pub fn default_sort(self) -> Sort {
+ match self {
+ Feed::ForYou => Sort::Hot,
+ Feed::Following => Sort::New,
+ }
+ }
+
+ /// An explicit supported sort always wins over the tab's default.
+ pub fn sort(self, param: Option<&str>) -> Sort {
+ Sort::parse(param, self.default_sort())
+ }
+
/// The tab a reader sees for the page's `feed` query: Following only
/// signed in and asked for; signed out, For you is the only one.
pub fn of(signed_in: bool, param: Option<&str>) -> Feed {
@@ -162,6 +176,19 @@ mod tests {
assert_eq!(Feed::ForYou.param(), None);
}
+ #[test]
+ fn following_starts_new_while_explicit_sorts_and_signed_out_defaults_are_preserved() {
+ assert_eq!(Feed::Following.sort(None), Sort::New);
+ assert_eq!(Feed::Following.sort(Some("unsupported")), Sort::New);
+ assert_eq!(Feed::ForYou.sort(None), Sort::Hot);
+ assert_eq!(Feed::of(false, Some(FOLLOWING)).sort(None), Sort::Hot);
+ for feed in [Feed::ForYou, Feed::Following] {
+ for sort in Sort::ALL {
+ assert_eq!(feed.sort(Some(sort.as_str())), sort);
+ }
+ }
+ }
+
#[test]
fn followed_authors_are_the_pools() {
let mut b = cast("0xb", None);
diff --git a/crates/allthing-core/src/lib.rs b/crates/allthing-core/src/lib.rs
index e4024af4..2f08f106 100644
--- a/crates/allthing-core/src/lib.rs
+++ b/crates/allthing-core/src/lib.rs
@@ -26,6 +26,7 @@ pub mod guard;
pub mod hls;
pub mod index;
pub mod latest;
+pub mod library;
pub mod listing;
pub mod media;
pub mod message;
diff --git a/crates/allthing-core/src/library.rs b/crates/allthing-core/src/library.rs
new file mode 100644
index 00000000..07019f9a
--- /dev/null
+++ b/crates/allthing-core/src/library.rs
@@ -0,0 +1,589 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//! Private Library state and durable offline intent. The server owns revisions;
+//! a disconnected reader cannot turn an old bookmark/read back on after a reset.
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Account {
+ pub fid: u64,
+ pub generation: u64,
+ pub revision: u64,
+ pub seen_count: u64,
+ pub bookmark_count: u64,
+ pub cleared_at: i64,
+ pub last_clear_id: String,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Item {
+ pub fid: u64,
+ pub generation: u64,
+ pub hash: String,
+ pub seen: bool,
+ pub seen_at: i64,
+ pub seen_revision: u64,
+ pub bookmarked: bool,
+ pub bookmark_at: i64,
+ pub bookmark_revision: u64,
+ #[serde(default)]
+ pub cursor: String,
+}
+
+pub fn canonical_hash(value: &str) -> Option {
+ (value.len() == 42 && value.starts_with("0x") && value[2..].bytes().all(|b| b.is_ascii_hexdigit()))
+ .then(|| value.to_ascii_lowercase())
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ReadIntent {
+ Seen { at: i64 },
+ Unread,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PendingRead {
+ pub generation: u64,
+ pub expected_revision: u64,
+ pub intent: ReadIntent,
+ /// Distinguishes a newer local action from the write being acknowledged.
+ pub sequence: u64,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PendingBookmark {
+ pub desired: bool,
+ pub expected_revision: u64,
+ pub sequence: u64,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CachedItem {
+ pub server: Item,
+ /// False until a caller-scoped membership/page response established this row.
+ pub known: bool,
+ pub read: Option,
+ pub bookmark: Option,
+ #[serde(default)]
+ pub read_settled: u64,
+ #[serde(default)]
+ pub bookmark_settled: u64,
+}
+
+impl CachedItem {
+ fn accepts(&self, row: &Item, account: &Account) -> bool {
+ row.fid == account.fid
+ && canonical_hash(&row.hash).as_ref() == Some(&row.hash)
+ && ((!self.known && self.server.hash.is_empty())
+ || (self.server.fid == row.fid && self.server.hash == row.hash))
+ }
+
+ pub fn seen(&self, generation: u64) -> bool {
+ match self.read.as_ref().filter(|read| read.generation == generation) {
+ Some(PendingRead { intent: ReadIntent::Seen { .. }, .. }) => true,
+ Some(PendingRead { intent: ReadIntent::Unread, .. }) => false,
+ None => self.known && self.server.generation == generation && self.server.seen,
+ }
+ }
+
+ pub fn bookmarked(&self) -> Option {
+ self.bookmark.as_ref().map(|intent| intent.desired).or_else(|| self.known.then_some(self.server.bookmarked))
+ }
+
+ /// Merge only newer authoritative state, independently for read and bookmark
+ /// revisions. View caches can temporarily contain overlapping argument sets.
+ pub fn observe(&mut self, row: Item, account: &Account) {
+ if !self.accepts(&row, account) {
+ return;
+ }
+ if !self.known {
+ self.server = row.clone();
+ self.known = true;
+ }
+ if row.bookmark_revision >= self.server.bookmark_revision {
+ self.server.bookmarked = row.bookmarked;
+ self.server.bookmark_at = row.bookmark_at;
+ self.server.bookmark_revision = row.bookmark_revision;
+ }
+ if row.generation == account.generation
+ && row.generation >= self.server.generation
+ && (self.server.generation != row.generation || row.seen_revision >= self.server.seen_revision)
+ {
+ self.server.generation = row.generation;
+ self.server.seen = row.seen;
+ self.server.seen_at = row.seen_at;
+ self.server.seen_revision = row.seen_revision;
+ }
+ self.reconcile(account);
+ }
+
+ /// Apply a fresh row only after this exact sent operation succeeded. A
+ /// newer local action may have replaced it while the reducer was in flight.
+ /// Rebase that action only across our own single revision, never across an
+ /// unrelated server change or a clear. The controller must defer ordinary
+ /// observations for an in-flight item until its outcome is known; otherwise
+ /// `observe` correctly treats an unexplained revision as a remote conflict.
+ pub fn accepted_read(&mut self, sent: &PendingRead, row: Item, account: &Account) {
+ if !self.accepts(&row, account) {
+ return;
+ }
+ let own_revision = row.seen_revision == sent.expected_revision
+ || sent.expected_revision.checked_add(1) == Some(row.seen_revision);
+ let matches = match sent.intent {
+ ReadIntent::Seen { at } => row.seen && (row.seen_revision == sent.expected_revision || row.seen_at <= at),
+ ReadIntent::Unread => !row.seen,
+ };
+ if own_revision
+ && matches
+ && sent.generation == account.generation
+ && row.generation == account.generation
+ && let Some(pending) = self.read.as_mut()
+ && pending.sequence > sent.sequence
+ && pending.generation == sent.generation
+ && pending.expected_revision == sent.expected_revision
+ {
+ pending.expected_revision = row.seen_revision;
+ }
+ self.acknowledged_read(sent.sequence);
+ self.observe(row, account);
+ }
+
+ /// Bookmark counterpart of `accepted_read`. A successful same-value no-op
+ /// is safe too, provided no intervening revision has changed the row.
+ pub fn accepted_bookmark(&mut self, sent: &PendingBookmark, row: Item, account: &Account) {
+ if !self.accepts(&row, account) {
+ return;
+ }
+ let own_revision = row.bookmark_revision == sent.expected_revision
+ || sent.expected_revision.checked_add(1) == Some(row.bookmark_revision);
+ if own_revision
+ && row.bookmarked == sent.desired
+ && let Some(pending) = self.bookmark.as_mut()
+ && pending.sequence > sent.sequence
+ && pending.expected_revision == sent.expected_revision
+ {
+ pending.expected_revision = row.bookmark_revision;
+ }
+ self.acknowledged_bookmark(sent.sequence);
+ self.observe(row, account);
+ }
+
+ pub fn reconcile(&mut self, account: &Account) {
+ let generation = if self.known { account.generation.max(self.server.generation) } else { account.generation };
+ if self.read.as_ref().is_some_and(|read| {
+ read.generation != generation
+ || (self.known
+ && self.server.generation == generation
+ && self.server.seen_revision > read.expected_revision)
+ || (account.generation == generation
+ && matches!(read.intent, ReadIntent::Seen { at } if at <= account.cleared_at))
+ }) && let Some(read) = self.read.take()
+ {
+ self.read_settled = self.read_settled.max(read.sequence);
+ }
+ // A later server operation wins over disconnected intent. An identical
+ // value is already complete; a different one is a conflict, never replayed.
+ if self.bookmark.as_ref().is_some_and(|intent| {
+ self.known
+ && (self.server.bookmark_revision > intent.expected_revision
+ || self.server.bookmarked == intent.desired)
+ }) && let Some(intent) = self.bookmark.take()
+ {
+ self.bookmark_settled = self.bookmark_settled.max(intent.sequence);
+ }
+ }
+
+ pub fn acknowledged_read(&mut self, sequence: u64) {
+ self.read_settled = self.read_settled.max(sequence);
+ if self.read.as_ref().is_some_and(|read| read.sequence <= sequence) {
+ self.read = None;
+ }
+ }
+
+ pub fn acknowledged_bookmark(&mut self, sequence: u64) {
+ self.bookmark_settled = self.bookmark_settled.max(sequence);
+ if self.bookmark.as_ref().is_some_and(|intent| intent.sequence <= sequence) {
+ self.bookmark = None;
+ }
+ }
+
+ pub fn pending(&self) -> bool {
+ self.read.is_some() || self.bookmark.is_some()
+ }
+
+ /// Merge inside an IndexedDB read/write transaction. This also protects
+ /// against another tab committing an older snapshot after an acknowledgement.
+ pub fn merge_disk(&mut self, incoming: &Self) {
+ self.merge_disk_preserving_inflight(incoming, None, None);
+ }
+
+ /// Preserve a queued successor while an older operation is still in flight.
+ /// The sender must finish with `accepted_read`/`accepted_bookmark` and a fresh
+ /// row, or observe the authoritative failure state. This protection never
+ /// overrides explicit settlement or a history-generation reset.
+ pub fn merge_disk_preserving_inflight(
+ &mut self,
+ incoming: &Self,
+ read_sequence: Option,
+ bookmark_sequence: Option,
+ ) {
+ if canonical_hash(&incoming.server.hash).as_ref() != Some(&incoming.server.hash) {
+ return;
+ }
+ if self.server.hash.is_empty() {
+ self.server = incoming.server.clone();
+ }
+ if self.server.fid != incoming.server.fid || self.server.hash != incoming.server.hash {
+ return;
+ }
+ if incoming.known {
+ if !self.known || incoming.server.bookmark_revision >= self.server.bookmark_revision {
+ self.server.bookmarked = incoming.server.bookmarked;
+ self.server.bookmark_at = incoming.server.bookmark_at;
+ self.server.bookmark_revision = incoming.server.bookmark_revision;
+ }
+ if !self.known
+ || incoming.server.generation > self.server.generation
+ || (incoming.server.generation == self.server.generation
+ && incoming.server.seen_revision >= self.server.seen_revision)
+ {
+ self.server.generation = incoming.server.generation;
+ self.server.seen = incoming.server.seen;
+ self.server.seen_at = incoming.server.seen_at;
+ self.server.seen_revision = incoming.server.seen_revision;
+ }
+ self.known = true;
+ }
+ self.read_settled = self.read_settled.max(incoming.read_settled);
+ self.bookmark_settled = self.bookmark_settled.max(incoming.bookmark_settled);
+ self.read = self
+ .read
+ .iter()
+ .chain(incoming.read.iter())
+ .filter(|intent| intent.sequence > self.read_settled)
+ .max_by_key(|intent| (intent.sequence, intent.expected_revision))
+ .cloned();
+ self.bookmark = self
+ .bookmark
+ .iter()
+ .chain(incoming.bookmark.iter())
+ .filter(|intent| intent.sequence > self.bookmark_settled)
+ .max_by_key(|intent| (intent.sequence, intent.expected_revision))
+ .cloned();
+ if self.known {
+ if let Some(read) = self.read.as_ref()
+ && (read.generation < self.server.generation
+ || (read.generation == self.server.generation
+ && read.expected_revision < self.server.seen_revision
+ && !read_sequence.is_some_and(|sent| read.sequence >= sent)))
+ {
+ self.acknowledged_read(read.sequence);
+ }
+ if let Some(bookmark) = self.bookmark.as_ref()
+ && bookmark.expected_revision < self.server.bookmark_revision
+ && !bookmark_sequence.is_some_and(|sent| bookmark.sequence >= sent)
+ {
+ self.acknowledged_bookmark(bookmark.sequence);
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn item() -> Item {
+ Item { fid: 3, hash: format!("0x{:040x}", 9), ..Item::default() }
+ }
+
+ fn account() -> Account {
+ Account { fid: 3, ..Account::default() }
+ }
+
+ #[test]
+ fn old_membership_cannot_undo_newer_bookmark_or_read_state() {
+ let mut cache = CachedItem::default();
+ let row = Item {
+ seen: true,
+ seen_at: 200,
+ seen_revision: 5,
+ bookmarked: true,
+ bookmark_at: 201,
+ bookmark_revision: 9,
+ ..item()
+ };
+ cache.observe(row.clone(), &account());
+ cache.observe(item(), &account());
+ assert_eq!(cache.server, row);
+ assert_eq!(cache.bookmarked(), Some(true));
+ assert!(cache.seen(0));
+ }
+
+ #[test]
+ fn a_remote_clear_fences_all_older_offline_observations() {
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ read: Some(PendingRead {
+ generation: 0,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at: 150 },
+ sequence: 1,
+ }),
+ ..CachedItem::default()
+ };
+ let account = Account { generation: 1, cleared_at: 200, ..account() };
+ cache.reconcile(&account);
+ assert!(cache.read.is_none());
+ assert!(!cache.seen(1));
+ }
+
+ #[test]
+ fn per_item_tombstones_reject_stale_offline_reads_and_bookmarks() {
+ let mut cache = CachedItem {
+ read: Some(PendingRead {
+ generation: 0,
+ expected_revision: 2,
+ intent: ReadIntent::Seen { at: 150 },
+ sequence: 1,
+ }),
+ bookmark: Some(PendingBookmark { desired: true, expected_revision: 4, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ cache.observe(Item { seen_revision: 3, bookmark_revision: 5, ..item() }, &account());
+ assert!(!cache.pending());
+ assert!(!cache.seen(0));
+ assert_eq!(cache.bookmarked(), Some(false));
+ }
+
+ #[test]
+ fn a_lost_acknowledgement_never_removes_a_newer_local_action() {
+ let mut cache = CachedItem {
+ read: Some(PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Unread, sequence: 2 }),
+ bookmark: Some(PendingBookmark { desired: false, expected_revision: 0, sequence: 3 }),
+ ..CachedItem::default()
+ };
+ cache.acknowledged_read(1);
+ cache.acknowledged_bookmark(2);
+ assert!(cache.pending());
+ cache.acknowledged_read(2);
+ cache.acknowledged_bookmark(3);
+ assert!(!cache.pending());
+ }
+
+ #[test]
+ fn initial_unknown_state_does_not_claim_a_post_is_unbookmarked() {
+ let mut cache = CachedItem::default();
+ assert_eq!(cache.bookmarked(), None);
+ cache.observe(item(), &account());
+ assert_eq!(cache.bookmarked(), Some(false));
+ assert!(!cache.pending());
+ }
+
+ #[test]
+ fn other_accounts_and_noncanonical_rows_never_enter_the_cache() {
+ let mut cache = CachedItem::default();
+ cache.observe(Item { fid: 4, ..item() }, &account());
+ cache.observe(Item { hash: "bad".into(), ..item() }, &account());
+ assert!(!cache.known);
+ assert_eq!(canonical_hash(&format!("0x{:040X}", 10)), Some(format!("0x{:040x}", 10)));
+ assert_eq!(canonical_hash("x"), None);
+ }
+
+ #[test]
+ fn late_disk_snapshots_cannot_resurrect_settled_or_replace_newer_intent() {
+ let mut old = CachedItem {
+ server: item(),
+ bookmark: Some(PendingBookmark { desired: true, expected_revision: 0, sequence: 1 }),
+ ..CachedItem::default()
+ };
+ let mut newer = old.clone();
+ newer.bookmark = Some(PendingBookmark { desired: false, expected_revision: 0, sequence: 2 });
+ old.merge_disk(&newer);
+ assert_eq!(old.bookmark.as_ref().unwrap().sequence, 2);
+ let stale = old.clone();
+ old.acknowledged_bookmark(2);
+ old.merge_disk(&stale);
+ assert!(old.bookmark.is_none());
+ assert_eq!(old.bookmark_settled, 2);
+ }
+
+ #[test]
+ fn persisted_remote_tombstones_also_fence_another_tabs_outbox() {
+ let mut pending = CachedItem {
+ server: item(),
+ read: Some(PendingRead {
+ generation: 0,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at: 10 },
+ sequence: 1,
+ }),
+ ..CachedItem::default()
+ };
+ let remote = CachedItem { known: true, server: Item { seen_revision: 2, ..item() }, ..CachedItem::default() };
+ pending.merge_disk(&remote);
+ assert!(pending.read.is_none());
+ assert_eq!(pending.read_settled, 1);
+ }
+
+ #[test]
+ fn accepted_add_preserves_a_newer_remove_through_observe_and_disk_merge() {
+ let sent = PendingBookmark { desired: true, expected_revision: 0, sequence: 1 };
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ bookmark: Some(PendingBookmark { desired: false, expected_revision: 0, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ let stale_disk = cache.clone();
+ cache.accepted_bookmark(
+ &sent,
+ Item { bookmarked: true, bookmark_at: 150, bookmark_revision: 1, ..item() },
+ &account(),
+ );
+ assert_eq!(cache.bookmarked(), Some(false));
+ assert_eq!(cache.bookmark.as_ref().unwrap().expected_revision, 1);
+ cache.merge_disk(&stale_disk);
+ assert_eq!(cache.bookmark.as_ref().unwrap().expected_revision, 1);
+ assert_eq!(cache.bookmark.as_ref().unwrap().sequence, 2);
+ cache.observe(Item { bookmark_revision: 2, ..item() }, &account());
+ assert!(cache.bookmark.is_none());
+ assert_eq!(cache.bookmark_settled, 2);
+ }
+
+ #[test]
+ fn accepted_seen_preserves_a_newer_unread_through_observe_and_disk_merge() {
+ let sent =
+ PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Seen { at: 150 }, sequence: 1 };
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ read: Some(PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Unread, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ let stale_disk = cache.clone();
+ cache.accepted_read(&sent, Item { seen: true, seen_at: 150, seen_revision: 1, ..item() }, &account());
+ assert!(!cache.seen(0));
+ assert_eq!(cache.read.as_ref().unwrap().expected_revision, 1);
+ cache.merge_disk(&stale_disk);
+ assert_eq!(cache.read.as_ref().unwrap().expected_revision, 1);
+ cache.observe(Item { seen_revision: 2, ..item() }, &account());
+ assert!(cache.read.is_none());
+ assert_eq!(cache.read_settled, 2);
+ }
+
+ #[test]
+ fn accepted_same_value_noop_does_not_drop_a_newer_action() {
+ let mut cache = CachedItem {
+ known: true,
+ server: Item { bookmarked: true, bookmark_revision: 4, ..item() },
+ bookmark: Some(PendingBookmark { desired: false, expected_revision: 4, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ let row = cache.server.clone();
+ cache.accepted_bookmark(&PendingBookmark { desired: true, expected_revision: 4, sequence: 1 }, row, &account());
+ assert_eq!(cache.bookmark.as_ref().unwrap().sequence, 2);
+ assert_eq!(cache.bookmark_settled, 1);
+ }
+
+ #[test]
+ fn successful_old_calls_never_rebase_across_foreign_changes_or_clears() {
+ let sent = PendingBookmark { desired: true, expected_revision: 0, sequence: 1 };
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ bookmark: Some(PendingBookmark { desired: true, expected_revision: 0, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ cache.accepted_bookmark(&sent, Item { bookmark_revision: 2, ..item() }, &account());
+ assert!(cache.bookmark.is_none());
+ assert_eq!(cache.bookmarked(), Some(false));
+ cache.read = Some(PendingRead {
+ generation: 0,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at: 150 },
+ sequence: 4,
+ });
+ cache.accepted_read(
+ &PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Seen { at: 100 }, sequence: 3 },
+ Item { generation: 1, ..item() },
+ &Account { generation: 1, cleared_at: 200, ..account() },
+ );
+ assert!(cache.read.is_none());
+ assert!(!cache.seen(1));
+ }
+
+ #[test]
+ fn existing_rows_cannot_mix_accounts_even_when_the_cast_hash_matches() {
+ for known in [false, true] {
+ let mut cache = CachedItem { known, server: item(), ..CachedItem::default() };
+ cache.observe(
+ Item { fid: 4, bookmarked: true, bookmark_revision: 9, ..item() },
+ &Account { fid: 4, ..account() },
+ );
+ assert_eq!(cache.server, item());
+ assert_eq!(cache.known, known);
+ }
+ }
+
+ #[test]
+ fn cached_equal_value_is_not_an_ack_for_a_queued_inverse() {
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ bookmark: Some(PendingBookmark { desired: false, expected_revision: 0, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ cache.merge_disk(&CachedItem { known: true, server: item(), ..CachedItem::default() });
+ assert_eq!(cache.bookmark.as_ref().unwrap().sequence, 2);
+ }
+
+ #[test]
+ fn inflight_disk_merge_waits_for_provenance_but_never_defeats_a_clear() {
+ let sent =
+ PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Seen { at: 150 }, sequence: 1 };
+ let mut cache = CachedItem {
+ known: true,
+ server: item(),
+ read: Some(PendingRead { generation: 0, expected_revision: 0, intent: ReadIntent::Unread, sequence: 2 }),
+ ..CachedItem::default()
+ };
+ let fresh = Item { seen: true, seen_at: 150, seen_revision: 1, ..item() };
+ cache.merge_disk_preserving_inflight(
+ &CachedItem { known: true, server: fresh.clone(), ..CachedItem::default() },
+ Some(1),
+ None,
+ );
+ assert_eq!(cache.read.as_ref().unwrap().expected_revision, 0);
+ cache.accepted_read(&sent, fresh, &account());
+ assert_eq!(cache.read.as_ref().unwrap().expected_revision, 1);
+ cache.merge_disk_preserving_inflight(
+ &CachedItem { known: true, server: Item { generation: 1, ..item() }, ..CachedItem::default() },
+ Some(1),
+ None,
+ );
+ assert!(cache.read.is_none());
+ }
+
+ #[test]
+ fn stale_account_and_membership_cannot_roll_back_a_known_reset() {
+ let mut cache = CachedItem {
+ known: true,
+ server: Item { generation: 1, ..item() },
+ read: Some(PendingRead {
+ generation: 1,
+ expected_revision: 0,
+ intent: ReadIntent::Seen { at: 250 },
+ sequence: 2,
+ }),
+ ..CachedItem::default()
+ };
+ cache.observe(Item { seen: true, seen_at: 150, seen_revision: 10, ..item() }, &account());
+ assert_eq!(cache.server.generation, 1);
+ assert!(!cache.server.seen);
+ assert_eq!(cache.read.as_ref().unwrap().generation, 1);
+ cache.reconcile(&Account { generation: 1, cleared_at: 200, ..account() });
+ assert!(cache.seen(1));
+ }
+}
diff --git a/crates/allthing-core/src/seen.rs b/crates/allthing-core/src/seen.rs
index 1423a554..bcf4e91d 100644
--- a/crates/allthing-core/src/seen.rs
+++ b/crates/allthing-core/src/seen.rs
@@ -79,6 +79,16 @@ impl History {
self.scopes.iter().filter(|s| s.name == scope).flat_map(|s| s.posts.iter().map(|p| p.hash.clone())).collect()
}
+ /// The existing, bounded browser cache, for a one-time private server
+ /// migration. The server, rather than this cache, owns durable history.
+ pub fn entries(&self, scope: &str) -> Vec<(String, i64)> {
+ self.scopes
+ .iter()
+ .filter(|s| s.name == scope)
+ .flat_map(|s| s.posts.iter().map(|p| (p.hash.clone(), p.at)))
+ .collect()
+ }
+
pub fn record(&mut self, name: &str, value: &str, now: i64) {
self.record_at(name, value, now, now);
}
diff --git a/crates/allthing-data/src/hypersnap.rs b/crates/allthing-data/src/hypersnap.rs
index 5c75184c..7fed9f7a 100644
--- a/crates/allthing-data/src/hypersnap.rs
+++ b/crates/allthing-data/src/hypersnap.rs
@@ -6,7 +6,7 @@
use std::collections::HashMap;
-use allthing_core::api::FollowsDir;
+use allthing_core::api::{FollowsDir, LIBRARY_PAGE_SIZE, LibraryEntry, ReactionKind, library_cursor};
use allthing_core::channels::{
canonical_parent_url, channel_id_for_url, channel_url_for_id, legacy_url, parent_url_variants,
short_parent_url_label,
@@ -125,6 +125,138 @@ pub async fn casts_bulk(inv: &Invocation, hashes: &[String]) -> Result Result, UpstreamError> {
+ #[derive(Deserialize)]
+ struct Found {
+ casts: Vec,
+ }
+ if hashes.is_empty() {
+ return Ok(Vec::new());
+ }
+ if hashes.len() > LIBRARY_PAGE_SIZE {
+ return Err(UpstreamError::Parse("too many Library casts".into()));
+ }
+ let list = hashes.join(",");
+ let found: Found = get_json(inv, Upstream::Hypersnap, &v2("casts", &[("casts", &list)]), false)
+ .await?
+ .ok_or(UpstreamError::Status(404))?;
+ if found.casts.len() > LIBRARY_PAGE_SIZE
+ || found.casts.iter().any(|cast| {
+ allthing_core::params::hash_from_param(&cast.hash).is_none()
+ || cast.author.as_ref().is_none_or(|author| author.fid == 0)
+ })
+ {
+ return Err(UpstreamError::Parse("invalid Library cast lookup".into()));
+ }
+ Ok(found.casts)
+}
+
+#[derive(Deserialize)]
+struct LibraryReactionPage {
+ messages: Vec,
+ #[serde(rename = "nextPageToken")]
+ next: String,
+}
+#[derive(Deserialize)]
+struct LibraryReactionMessage {
+ data: LibraryReactionData,
+}
+#[derive(Deserialize)]
+struct LibraryReactionData {
+ fid: u64,
+ timestamp: u64,
+ #[serde(rename = "type")]
+ kind: serde_json::Value,
+ #[serde(rename = "reactionBody")]
+ reaction: LibraryReactionBody,
+}
+#[derive(Deserialize)]
+struct LibraryReactionBody {
+ #[serde(rename = "type")]
+ kind: serde_json::Value,
+ #[serde(rename = "targetCastId")]
+ target: Option,
+ #[serde(rename = "targetUrl")]
+ target_url: Option,
+}
+#[derive(Deserialize)]
+struct LibraryTarget {
+ fid: u64,
+ hash: String,
+}
+
+/// A page of the account's retained active reactions. Follow the opaque
+/// `next` key to the end; unlike a target's reactors this is a single shard.
+pub async fn library_reactions(
+ inv: &Invocation,
+ fid: u64,
+ kind: ReactionKind,
+ cursor: Option<&str>,
+) -> Result<(Vec, Option), UpstreamError> {
+ let fid_text = fid.to_string();
+ let size = LIBRARY_PAGE_SIZE.to_string();
+ let (number, name, query_kind) = match kind {
+ ReactionKind::Likes => (1, "REACTION_TYPE_LIKE", "Like"),
+ ReactionKind::Recasts => (2, "REACTION_TYPE_RECAST", "Recast"),
+ };
+ let mut query = vec![
+ ("fid", fid_text.as_str()),
+ ("reaction_type", query_kind),
+ ("pageSize", size.as_str()),
+ ("reverse", "true"),
+ ];
+ if let Some(cursor) = cursor {
+ query.push(("pageToken", cursor));
+ }
+ let page: LibraryReactionPage = get_json(inv, Upstream::Hypersnap, &v1("reactionsByFid", &query), false)
+ .await?
+ .ok_or(UpstreamError::Status(404))?;
+ let invalid = || UpstreamError::Parse("invalid Library reaction page".into());
+ if page.messages.len() > LIBRARY_PAGE_SIZE {
+ return Err(invalid());
+ }
+ let mut seen = std::collections::HashSet::new();
+ let mut entries = Vec::new();
+ for message in page.messages {
+ let data = message.data;
+ let matches =
+ |value: &serde_json::Value, number, name| value.as_u64() == Some(number) || value.as_str() == Some(name);
+ if data.fid != fid
+ || !matches(&data.kind, 3, "MESSAGE_TYPE_REACTION_ADD")
+ || !matches(&data.reaction.kind, number, name)
+ {
+ return Err(invalid());
+ }
+ // A reaction to a URL is valid network history, but is not a cast.
+ let Some(target) = data.reaction.target else {
+ if data.reaction.target_url.as_deref().is_some_and(|url| !url.trim().is_empty()) {
+ continue;
+ }
+ return Err(invalid());
+ };
+ let hash = allthing_core::params::hash_from_param(&target.hash).ok_or_else(invalid)?;
+ let at = i64::try_from(data.timestamp)
+ .ok()
+ .and_then(|at| at.checked_add(allthing_core::message::EPOCH))
+ .ok_or_else(invalid)?;
+ if target.fid == 0 {
+ return Err(invalid());
+ }
+ if seen.insert(hash.clone()) {
+ entries.push(LibraryEntry { hash, author_fid: target.fid, at });
+ }
+ }
+ let next = (!page.next.is_empty()).then_some(page.next);
+ if let Some(next) = &next
+ && (library_cursor(next).is_none() || Some(next.as_str()) == cursor)
+ {
+ return Err(invalid());
+ }
+ Ok((entries, next))
+}
+
/// Who liked (`likes`) or recasted (`recasts`) a cast, at most 100
/// (Hypersnap does not page further), as Hypersnap lists them: each shard's
/// reactions oldest first, one shard after another ([`normalize::reactors`]
diff --git a/crates/allthing-data/src/lib.rs b/crates/allthing-data/src/lib.rs
index f01c5f34..ef118355 100644
--- a/crates/allthing-data/src/lib.rs
+++ b/crates/allthing-data/src/lib.rs
@@ -217,6 +217,10 @@ pub enum Endpoint {
Wallets(u64),
Channels(Option),
Search(String),
+ /// One page of an account's retained active likes or recasts.
+ Library(u64, ReactionKind, Option),
+ /// Public cast hydration for an ordered private Library page.
+ LibraryCasts(Vec),
Reactions(String, ReactionKind, u32),
/// An account's followers or following, by name (`fid:` for later
/// pages), from the start or from a cursor.
@@ -260,6 +264,25 @@ impl Endpoint {
}
"/latest" => Ok(Endpoint::Latest),
"/activity" => Ok(Endpoint::Activity),
+ "/library" => {
+ let fid = query("fid")
+ .and_then(|v| v.parse::().ok())
+ .filter(|fid| *fid > 0)
+ .ok_or(bad("Expected ?fid="))?;
+ let kind =
+ query("kind").and_then(|v| ReactionKind::parse(&v)).ok_or(bad("Expected ?kind=likes|recasts"))?;
+ let cursor = match query("cursor") {
+ None => None,
+ Some(cursor) => Some(
+ allthing_core::api::library_cursor(&cursor)
+ .ok_or(bad("Expected ?cursor="))?,
+ ),
+ };
+ Ok(Endpoint::Library(fid, kind, cursor))
+ }
+ "/library/casts" => allthing_core::api::parse_library_hashes(query("hashes").as_deref())
+ .map(Endpoint::LibraryCasts)
+ .ok_or(bad("Expected ?hashes=")),
"/reactions" => {
let hash = query("hash").and_then(|v| hash_from_param(&v)).ok_or(bad("Expected ?hash="))?;
let kind =
@@ -365,6 +388,14 @@ impl Endpoint {
}
Endpoint::Latest => "latest".into(),
Endpoint::Activity => "activity".into(),
+ Endpoint::Library(fid, kind, cursor) => allthing_core::api::library_path(*fid, *kind, cursor.as_deref())
+ .trim_start_matches(API_PREFIX)
+ .trim_start_matches('/')
+ .to_owned(),
+ Endpoint::LibraryCasts(hashes) => allthing_core::api::library_casts_path(hashes)
+ .trim_start_matches(API_PREFIX)
+ .trim_start_matches('/')
+ .to_owned(),
Endpoint::Reactions(hash, kind, limit) => {
format!("reactions?hash={hash}&type={}&limit={limit}", kind.as_str())
}
@@ -396,6 +427,8 @@ impl Endpoint {
Endpoint::Home(offset, window) => pages::home(inv, offset, window, previous).await,
Endpoint::Latest => pages::latest(inv, previous).await,
Endpoint::Activity => pages::activity(inv).await,
+ Endpoint::Library(fid, kind, cursor) => pages::library(inv, fid, kind, cursor, previous).await,
+ Endpoint::LibraryCasts(hashes) => pages::library_casts(inv, hashes, previous).await,
Endpoint::Reactions(hash, kind, limit) => pages::reactions(inv, hash, kind, limit).await,
Endpoint::Engagement(request) => pages::engagement(inv, request).await,
Endpoint::Replies(hash) => pages::replies(inv, hash).await,
@@ -416,6 +449,26 @@ impl Endpoint {
mod tests {
use super::*;
+ #[test]
+ fn library_routes_scope_and_escape_pages_and_keep_hydration_order() {
+ let page = Endpoint::from_path("/api/v1/library?fid=3&kind=recasts&cursor=%2B%2F8%3D").unwrap();
+ assert_eq!(page, Endpoint::Library(3, ReactionKind::Recasts, Some("+/8=".into())));
+ assert_eq!(page.cache_name(), "library?fid=3&kind=recasts&cursor=%2B%2F8%3D");
+ let (a, b) = (format!("0x{:040x}", 1), format!("0x{:040x}", 2));
+ let batch = Endpoint::from_path(&format!("/api/v1/library/casts?hashes={b},{},{b}", a.to_uppercase())).unwrap();
+ assert_eq!(batch, Endpoint::LibraryCasts(vec![b.clone(), a.clone()]));
+ assert_eq!(batch.cache_name(), format!("library/casts?hashes={b},{a}"));
+ for path in [
+ "/api/v1/library",
+ "/api/v1/library?fid=0&kind=likes",
+ "/api/v1/library?fid=3&kind=other",
+ "/api/v1/library?fid=3&kind=likes&cursor=bad!",
+ "/api/v1/library/casts?hashes=0x12",
+ ] {
+ assert_eq!(Endpoint::from_path(path).map_err(|error| error.status), Err(400));
+ }
+ }
+
#[test]
fn counts_share_one_entry_whatever_the_order() {
let (a, b) = (format!("0x{:040x}", 1), format!("0x{:040x}", 2));
diff --git a/crates/allthing-data/src/pages.rs b/crates/allthing-data/src/pages.rs
index 3418eb9d..b2764e59 100644
--- a/crates/allthing-data/src/pages.rs
+++ b/crates/allthing-data/src/pages.rs
@@ -7,8 +7,8 @@ use std::rc::Rc;
use allthing_core::activity::AppsWindow;
use allthing_core::api::{
self, Annotations, CastPayload, ChannelPayload, ChannelsPayload, EngagementPayload, EngagementRequest, FollowsDir,
- FollowsPayload, HomePayload, IndexCoverage, LatestPayload, Meta, QuotedCast, ReactionKind, ReactionsPayload,
- RepliesPayload, SearchPayload, UserPayload,
+ FollowsPayload, HomePayload, IndexCoverage, LatestPayload, LibraryCastsPayload, LibraryPayload, Meta, QuotedCast,
+ ReactionKind, ReactionsPayload, RepliesPayload, SearchPayload, UserPayload,
};
use allthing_core::channels::{channel_id_for_url, channel_url_for_id, is_channel_id};
use allthing_core::embeds::link_key;
@@ -533,6 +533,268 @@ pub async fn following(
Built::json(&payload, following::FOLLOWING_CACHE)
}
+/// Hydrate a bounded Library page in the caller's order. The provider's bulk
+/// order is not the list's order. Errors retain the caller's cursor for Retry;
+/// successful omissions and content filtering have separate classifications.
+async fn hydrate_library(
+ inv: &Invocation,
+ hashes: &[String],
+ previous: Option>>,
+) -> Result {
+ let raw = hypersnap::library_casts(inv, hashes).await.map_err(Failure::upstream)?;
+ let mut by_hash: HashMap =
+ raw.iter().map(normalize::cast).map(|cast| (cast.hash.clone(), cast)).collect();
+ let unavailable = hashes.iter().filter(|hash| !by_hash.contains_key(*hash)).cloned().collect();
+ let ordered: Vec = hashes.iter().filter_map(|hash| by_hash.remove(hash)).collect();
+ let filtered = ordered.iter().filter(|cast| cast.spam).map(|cast| cast.hash.clone()).collect();
+ let mut notes = carried(previous.as_deref().map(Vec::as_slice));
+ let known_links = std::mem::take(&mut notes.links);
+ let pool = drop_hidden(inv, ordered, &mut notes, &known_links, true).await;
+ let quoted = annotate_listing(inv, &mut notes, &pool, ORIGIN_AUTHORS, &known_links).await;
+ notes.keep_users(read_cards(&raw, quoted), listed_accounts(&pool, ¬es.quotes));
+ keep_shown(&mut notes, &pool);
+ Ok(LibraryCastsPayload { pool, unavailable, filtered, notes, meta: meta(inv) })
+}
+
+/// The account's retained active likes or recasts, newest reaction first,
+/// including older pages until the hub has no continuation token. The
+/// browser's recent-reaction cache does not limit this history.
+pub async fn library(
+ inv: Rc,
+ fid: u64,
+ kind: ReactionKind,
+ cursor: Option,
+ previous: Option>>,
+) -> Result {
+ let (entries, next) =
+ hypersnap::library_reactions(&inv, fid, kind, cursor.as_deref()).await.map_err(Failure::upstream)?;
+ let hashes: Vec = entries.iter().map(|entry| entry.hash.clone()).collect();
+ let LibraryCastsPayload { pool, unavailable, filtered, notes, meta } =
+ hydrate_library(&inv, &hashes, previous).await?;
+ Built::json(
+ &LibraryPayload { fid, kind, entries, pool, next, unavailable, filtered, notes, meta },
+ api::LIBRARY_CACHE,
+ )
+}
+
+/// Public cast data for bookmarks/seen metadata supplied by the browser, at
+/// most 25 hashes. This does not read or expose any private Library records.
+pub async fn library_casts(
+ inv: Rc,
+ hashes: Vec,
+ previous: Option>>,
+) -> Result {
+ Built::json(&hydrate_library(&inv, &hashes, previous).await?, api::LIBRARY_CACHE)
+}
+
+#[cfg(test)]
+mod library_tests {
+ use super::*;
+ use crate::{FetchError, Host, HttpRequest, HttpResponse};
+ use allthing_core::provenance::{base64_decode, base64_encode};
+ use futures::future::{FutureExt, LocalBoxFuture};
+ use serde_json::json;
+ use std::cell::{Cell, RefCell};
+
+ struct Hub {
+ total: usize,
+ missing: HashSet,
+ reaction_status: u16,
+ reaction_body: Option,
+ bulk_status: u16,
+ bulk_body: Option,
+ timeout: bool,
+ clock: Cell,
+ requests: RefCell>,
+ }
+ impl Hub {
+ fn new(total: usize) -> Self {
+ Self {
+ total,
+ missing: HashSet::new(),
+ reaction_status: 200,
+ reaction_body: None,
+ bulk_status: 200,
+ bulk_body: None,
+ timeout: false,
+ clock: Cell::new(1_800_000_000_000.0),
+ requests: RefCell::new(Vec::new()),
+ }
+ }
+ }
+ fn hash(n: usize) -> String {
+ format!("0x{n:040x}")
+ }
+ fn query(url: &str, name: &str) -> Option {
+ url.split_once('?')?.1.split('&').find_map(|part| {
+ let (key, value) = part.split_once('=')?;
+ (key == name).then(|| allthing_core::params::percent_decode(value)).flatten()
+ })
+ }
+ impl Host for Hub {
+ fn now(&self) -> f64 {
+ // Independent invocations can refill the upstream test bucket;
+ // no timing or performance behavior is being measured here.
+ let now = self.clock.get();
+ self.clock.set(now + 10_000.0);
+ now
+ }
+ fn fetch(&self, request: HttpRequest) -> LocalBoxFuture<'static, Result> {
+ let url = request.url;
+ assert!(url.starts_with(hypersnap::BASE), "Library never switches read providers: {url}");
+ self.requests.borrow_mut().push(url.clone());
+ let answer = |status, body: String| {
+ Ok(HttpResponse { status, retry_after: None, body: (status == 200).then(|| body.into_bytes()) })
+ };
+ let response = if url.contains("/v1/reactionsByFid?") {
+ if self.timeout {
+ return async { Err(FetchError::Timeout) }.boxed_local();
+ }
+ assert_eq!(query(&url, "fid").as_deref(), Some("3"));
+ assert_eq!(query(&url, "pageSize").as_deref(), Some("25"));
+ assert_eq!(query(&url, "reverse").as_deref(), Some("true"));
+ let offset = query(&url, "pageToken")
+ .map(|token| String::from_utf8(base64_decode(&token).unwrap()).unwrap().parse::().unwrap())
+ .unwrap_or(0);
+ let end = (offset + api::LIBRARY_PAGE_SIZE).min(self.total);
+ let kind = if query(&url, "reaction_type").as_deref() == Some("Recast") {
+ "REACTION_TYPE_RECAST"
+ } else {
+ "REACTION_TYPE_LIKE"
+ };
+ let messages: Vec<_> = (offset..end)
+ .map(|n| {
+ json!({"data": {
+ "fid":3,"type":"MESSAGE_TYPE_REACTION_ADD","timestamp":1_000_000 - n,
+ "reactionBody":{"type":kind,"targetCastId":{"fid":n+1000,"hash":hash(n)}}
+ }})
+ })
+ .collect();
+ let next = if end < self.total { base64_encode(end.to_string().as_bytes()) } else { String::new() };
+ answer(
+ self.reaction_status,
+ self.reaction_body
+ .clone()
+ .unwrap_or_else(|| json!({"messages":messages,"nextPageToken":next}).to_string()),
+ )
+ } else if url.contains("/v2/farcaster/casts?") {
+ let hashes = query(&url, "casts").unwrap();
+ let casts: Vec<_> = hashes.split(',').rev().filter_map(|h| {
+ let n = usize::from_str_radix(h.trim_start_matches("0x"),16).unwrap();
+ if self.missing.contains(&n) { return None; }
+ let text = if n == 2 { "Just unlocked my DEGEN rewards" } else { "A saved cast" };
+ Some(json!({"hash":h,"author":{"fid":n+1000,"username":format!("author{n}"),"follower_count":1},"text":text,
+ "timestamp":"2026-01-01T00:00:00Z","embeds": if n == 3 { vec![json!({"url":"https://farcaster.xyz/miniapps/abc/game"})] } else { vec![] }}))
+ }).collect();
+ answer(self.bulk_status, self.bulk_body.clone().unwrap_or_else(|| json!({"casts":casts}).to_string()))
+ } else {
+ answer(404, String::new())
+ };
+ async move { response }.boxed_local()
+ }
+ fn spawn(&self, _: LocalBoxFuture<'static, ()>) {}
+ }
+ fn read(hub: Rc, kind: ReactionKind, cursor: Option) -> Result {
+ let built = library(Rc::new(Invocation::new(hub, 43)), 3, kind, cursor, None).now_or_never().unwrap()?;
+ Ok(serde_json::from_slice(&built.body).unwrap())
+ }
+
+ #[test]
+ fn library_pages_continue_past_recent_cache_limits_to_the_actual_end() {
+ for kind in [ReactionKind::Likes, ReactionKind::Recasts] {
+ let hub = Rc::new(Hub::new(131));
+ let (mut cursor, mut entries, mut pages) = (None, Vec::new(), 0);
+ loop {
+ let page = read(hub.clone(), kind, cursor).unwrap();
+ assert_eq!((page.fid, page.kind), (3, kind));
+ entries.extend(page.entries);
+ pages += 1;
+ cursor = page.next;
+ if cursor.is_none() {
+ break;
+ }
+ assert!(pages < 10, "cursor must finish");
+ }
+ assert_eq!(entries.len(), 131);
+ assert_eq!(
+ entries.iter().map(|entry| entry.hash.clone()).collect::>(),
+ (0..131).map(hash).collect::>()
+ );
+ assert!(entries.windows(2).all(|rows| rows[0].at > rows[1].at));
+ assert_eq!(pages, 6);
+ assert_eq!(hub.requests.borrow().iter().filter(|url| url.contains("/v1/reactionsByFid?")).count(), 6);
+ assert_eq!(hub.requests.borrow().iter().filter(|url| url.contains("/v2/farcaster/casts?")).count(), 6);
+ }
+ }
+
+ #[test]
+ fn library_hydration_keeps_list_order_and_separates_unavailable_from_filtered() {
+ let mut hub = Hub::new(0);
+ hub.missing.insert(1);
+ let hub = Rc::new(hub);
+ let ordered = [3, 1, 2, 4].map(hash).to_vec();
+ let built = library_casts(Rc::new(Invocation::new(hub, 43)), ordered, None).now_or_never().unwrap().unwrap();
+ let page: LibraryCastsPayload = serde_json::from_slice(&built.body).unwrap();
+ assert_eq!(page.pool.iter().map(|cast| cast.hash.clone()).collect::>(), [hash(3), hash(4)]);
+ assert_eq!(page.unavailable, [hash(1)]);
+ assert_eq!(page.filtered, [hash(2)]);
+ assert!(page.pool[0].mini_app, "settings, not server hydration, decide whether mini apps show");
+ assert!(page.notes.users.contains_key(&1003));
+ assert!(!page.notes.users.contains_key(&1002));
+ }
+
+ #[test]
+ fn library_source_failures_and_bad_shapes_never_become_empty_success() {
+ for status in [400, 404, 500] {
+ let mut hub = Hub::new(1);
+ hub.reaction_status = status;
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ let mut hub = Hub::new(1);
+ hub.bulk_status = status;
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ }
+ let mut hub = Hub::new(1);
+ hub.timeout = true;
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ for body in ["{}", r#"{"messages":[]}"#, r#"{"messages":[],"nextPageToken":"invalid!"}"#] {
+ let mut hub = Hub::new(1);
+ hub.reaction_body = Some(body.into());
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ }
+ let mut hub = Hub::new(1);
+ hub.bulk_body = Some("{}".into());
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ let mut hub = Hub::new(1);
+ hub.reaction_body = Some(json!({"messages":[{"data":{"fid":3,"timestamp":1,"type":"MESSAGE_TYPE_REACTION_ADD","reactionBody":{"type":"REACTION_TYPE_LIKE"}}}],"nextPageToken":""}).to_string());
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).is_err());
+ let mut hub = Hub::new(1);
+ hub.reaction_body = Some(json!({"messages":[{"data":{"fid":3,"timestamp":1,"type":"MESSAGE_TYPE_REACTION_ADD","reactionBody":{"type":"REACTION_TYPE_LIKE","targetUrl":"https://example.org"}}}],"nextPageToken":""}).to_string());
+ assert!(read(Rc::new(hub), ReactionKind::Likes, None).unwrap().entries.is_empty());
+ }
+
+ #[test]
+ fn library_empty_history_is_explicit_and_does_not_fetch_casts() {
+ let hub = Rc::new(Hub::new(0));
+ let page = read(hub.clone(), ReactionKind::Likes, None).unwrap();
+ assert!(page.entries.is_empty() && page.pool.is_empty() && page.unavailable.is_empty() && page.next.is_none());
+ assert_eq!(hub.requests.borrow().len(), 1);
+ }
+
+ #[test]
+ fn library_annotations_stay_within_the_shared_request_budget() {
+ let hub = Rc::new(Hub::new(25));
+ let page = read(hub.clone(), ReactionKind::Recasts, None).unwrap();
+ assert_eq!(page.entries.len(), 25);
+ let urls = hub.requests.borrow();
+ assert!(urls.len() <= 43, "the cache reserves its two calls from the45 budget: {}", urls.len());
+ assert_eq!(urls.iter().filter(|url| url.contains("/v2/farcaster/casts?")).count(), 1);
+ assert!(
+ urls.iter().filter(|url| url.contains("/v1/castById?")).count() <= ORIGIN_AUTHORS,
+ "only the existing bounded provenance annotator may read individual casts"
+ );
+ }
+}
+
/// How many voted each way in Allthing's poll, so a reader who voted sees
/// them with no request of their own: one D1 read of the two counts its
/// votes keep, given an index and a subrequest for it. `None` before anyone
diff --git a/crates/allthing-web/Cargo.toml b/crates/allthing-web/Cargo.toml
index b6013a04..f898e48f 100644
--- a/crates/allthing-web/Cargo.toml
+++ b/crates/allthing-web/Cargo.toml
@@ -110,6 +110,8 @@ features = [
"CryptoKeyPair",
"SubtleCrypto",
"IdbDatabase",
+ "IdbKeyRange",
+ "DomException",
"IdbFactory",
"IdbObjectStore",
"IdbObjectStoreParameters",
diff --git a/crates/allthing-web/src/components/bookmark.rs b/crates/allthing-web/src/components/bookmark.rs
new file mode 100644
index 00000000..18188ae3
--- /dev/null
+++ b/crates/allthing-web/src/components/bookmark.rs
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//! A private saved-post toggle. It needs a session, never a Farcaster signer.
+
+use leptos::prelude::*;
+
+#[cfg(not(feature = "static-host"))]
+#[component]
+pub fn BookmarkButton(hash: String) -> impl IntoView {
+ use crate::components::signin::use_session;
+ use crate::library::{SyncStatus, use_library};
+
+ let library = use_library();
+ let session = use_session();
+ // Split pages can construct a view under a short-lived async owner before
+ // mounting it. Follow the mounted control's effect lifetime, as CastCard's
+ // viewport observer does, so Back retains a live membership subscription.
+ let node = NodeRef::::new();
+ let watched = hash.clone();
+ Effect::new(move |_| {
+ if node.get().is_some() {
+ library.watch(&watched);
+ }
+ });
+ let key = hash.clone();
+ let saved = Memo::new(move |_| library.bookmarked(&key));
+ let signed_in = Memo::new(move |_| session.user.with(Option::is_some));
+ let waiting = Memo::new(move |_| signed_in.get() && saved.get().is_none());
+ let message = RwSignal::new(None::);
+ Effect::new(move |_| {
+ session.user.with(|user| user.as_ref().map(|user| user.fid));
+ message.set(None);
+ });
+ let label = move || if saved.get() == Some(true) { "Remove bookmark" } else { "Save post" };
+ view! {
+
+
+
+ {move || message.get()}
+
+
+ }
+}
+
+/// The public-only static copy has no private account state or save action.
+#[cfg(feature = "static-host")]
+#[component]
+pub fn BookmarkButton(hash: String) -> impl IntoView {
+ let _ = hash;
+}
diff --git a/crates/allthing-web/src/components/cast.rs b/crates/allthing-web/src/components/cast.rs
index 1865e2f4..e5c6865c 100644
--- a/crates/allthing-web/src/components/cast.rs
+++ b/crates/allthing-web/src/components/cast.rs
@@ -45,6 +45,9 @@ pub fn CastCard(
#[prop(optional)] index: usize,
#[prop(optional)] show_parent: bool,
#[prop(optional)] track_seen: bool,
+ /// A Library activity line/action, inside the existing card element.
+ #[prop(optional_no_strip)]
+ row_footer: Option>,
) -> impl IntoView {
let href = cast_href(&cast.hash);
// Quoted casts the payload carries show as cards; others as links.
@@ -64,13 +67,18 @@ pub fn CastCard(
let reactions = crate::components::reactions::card_reactions(&cast, ¬es);
#[cfg(feature = "static-host")]
let reactions = ();
+ let extra = row_footer.map(|footer| footer.run(cast.clone()));
let gone = crate::components::deleted(&cast.hash);
// Drawn as Back or Forward puts the reader's place back, a card is where
// it was: it doesn't rise into place again.
let entrance = if crate::nav::restoring() { "; animation: none" } else { "" };
let node = NodeRef::::new();
let seen = crate::seen::use_seen();
+ let read_mark = track_seen.then(|| seen.read_mark(cast.hash.clone()));
Effect::new(move |_| {
+ if let Some(read_mark) = read_mark {
+ read_mark.track();
+ }
if let Some(element) = node.get() {
if track_seen {
crate::seen::watch(&element, seen);
@@ -133,6 +141,7 @@ pub fn CastCard(
{reactions}
+ {extra}
}
}
@@ -166,14 +175,29 @@ pub fn CastList(
/// and keeping its place as they reorder: the front page's poll.
#[prop(default = None)]
inset: Option AnyView>,
+ /// Optional per-cast Library metadata, without wrapping card elements.
+ #[prop(optional_no_strip)]
+ row_footer: Option>,
) -> impl IntoView {
// Mini app casts, which payloads keep marked, are left out unless the
// reader chose to see them in the Settings panel (`crate::miniapps`).
let all = casts;
- let seen_feed = crate::seen::Feed::new(hide_seen);
+ let candidates = Signal::derive(move || {
+ let show = crate::miniapps::shown();
+ all.with(|casts| {
+ casts
+ .iter()
+ .filter(|cast| !cast.hidden_for(show))
+ .filter_map(|cast| allthing_core::library::canonical_hash(&cast.hash))
+ .collect()
+ })
+ });
+ let seen_feed = crate::seen::Feed::new(hide_seen, candidates);
let hidden_seen = Memo::new(move |_| {
let show_apps = crate::miniapps::shown();
- all.with(|casts| casts.iter().filter(|cast| !cast.hidden_for(show_apps) && seen_feed.hides(&cast.hash)).count())
+ all.with(|casts| {
+ casts.iter().filter(|cast| !cast.hidden_for(show_apps) && seen_feed.hidden(&cast.hash)).count()
+ })
});
let casts: Signal> = Memo::new(move |_| {
let show = crate::miniapps::shown();
@@ -277,6 +301,9 @@ pub fn CastList(
}
});
let grow = move || {
+ if seen_feed.pending() {
+ return;
+ }
// Still drawing what is shown: its end isn't the list's end yet.
if drawn.with_value(|(count, _)| *count) < shown.get_untracked().min(casts.with_untracked(Vec::len)) {
return;
@@ -298,7 +325,7 @@ pub fn CastList(
// A short or filtered page can leave the sentinel inside the observer's
// margin, so there is no new intersection event to continue the feed.
Effect::new(move |_| {
- let _ = (casts.with(Vec::len), shown.get(), drew_more.get(), page_cursor.get());
+ let _ = (casts.with(Vec::len), shown.get(), drew_more.get(), page_cursor.get(), seen_feed.pending());
request_animation_frame(move || {
let Some(element) = sentinel.try_get_untracked().flatten() else { return };
if !element.is_connected() {
@@ -320,8 +347,13 @@ pub fn CastList(
// fewer, as while the first card waits for its image's size, it was at
// the list's top, and was pushed down as they came.
let items = move || {
- let mut items: Vec<(String, Option<(usize, Cast)>)> =
- visible().into_iter().map(|(index, cast)| (cast.hash.clone(), Some((index, cast)))).collect();
+ let mut items: Vec<(String, Option<(usize, Cast)>)> = visible()
+ .into_iter()
+ .map(|(index, cast)| {
+ seen_feed.preserve(&cast.hash);
+ (cast.hash.clone(), Some((index, cast)))
+ })
+ .collect();
let all = casts.with(Vec::len);
if inset.is_some() && !items.is_empty() && items.len() >= INSET_AFTER.min(all) {
let at = INSET_AFTER.min(items.len());
@@ -332,13 +364,16 @@ pub fn CastList(
};
view! {
- "Nothing here yet."
}>
+ }.into_any() }
+ else { view! {
"Nothing here yet."
}.into_any() }
+ }>
view! { }.into_any(),
+ Some((index, cast)) => view! { }.into_any(),
None => inset.map(|inset| inset()).into_any(),
}
/>
diff --git a/crates/allthing-web/src/components/chips.rs b/crates/allthing-web/src/components/chips.rs
index 95b5e6e8..5e4aac0c 100644
--- a/crates/allthing-web/src/components/chips.rs
+++ b/crates/allthing-web/src/components/chips.rs
@@ -181,7 +181,13 @@ pub fn Engagement(
};
// The ranking note is for screen readers: a native tooltip on the row
// would sit on top of the counts' own popovers.
- view! {
{likes}{recasts}{replies}{title}
}
+ view! {
+
+ {likes}{recasts}{replies}
+
+ {title}
+
+ }
}
/// The reply count of the cast `hash`, `read` as the page has it, with the
diff --git a/crates/allthing-web/src/components/compose.rs b/crates/allthing-web/src/components/compose.rs
index c6b893a1..ed913fb4 100644
--- a/crates/allthing-web/src/components/compose.rs
+++ b/crates/allthing-web/src/components/compose.rs
@@ -2,7 +2,7 @@
//! Writing a cast, a reply or a quote, with a GIF or emoji, for any reader.
//! Cast, at the top right of the front page's feed or a channel's (a round
//! button over the feed on a phone), starts a cast, in the channel on a
-//! channel page, and so does the same pill on the reader's own profile;
+//! channel page, and so does the same pill on Notifications or the reader's own profile;
//! Reply and Quote under a post or a reply start a reply to it or a cast
//! that quotes it.
//!
@@ -1271,11 +1271,13 @@ fn channel_in_view(path: &str) -> Option {
is_channel_id(&id).then_some(id)
}
-/// Whether `path` is a feed with a Cast button: the front page's or a
-/// channel's.
-fn on_feed(path: &str) -> bool {
+/// Whether `path` has Cast for every reader: a feed or Notifications.
+fn casts_page(path: &str) -> bool {
let path = path.strip_prefix(BASE).unwrap_or(path);
- path.is_empty() || path == "/" || channel_in_view(path).is_some()
+ path.is_empty()
+ || path == "/"
+ || channel_in_view(path).is_some()
+ || matches!(path, "/notifications" | "/notifications/")
}
/// Whether `path` is `user`'s own profile, the reader's: `/u/`, or
@@ -1293,15 +1295,14 @@ fn own_profile(path: &str, name: Option<&str>, user: &UserSummary) -> bool {
.is_some_and(|named| named == user.username.to_lowercase() || fid_username(&named) == Some(user.fid))
}
-/// Whether the page has Cast: the front page's feed, a channel's, and the
-/// reader's own profile.
+/// Whether the page has Cast: a feed, Notifications, or the reader's own profile.
fn use_casts_here() -> Memo {
let location = use_location();
let query = use_query_map();
let session = use_session();
Memo::new(move |_| {
let path = location.pathname.get();
- on_feed(&path)
+ casts_page(&path)
|| session.user.with(|user| {
user.as_ref().is_some_and(|user| own_profile(&path, query.read().get("name").as_deref(), user))
})
@@ -1336,7 +1337,7 @@ fn CastPill() -> impl IntoView {
/// Cast on a computer: the pill in the site header, before the account,
/// always in view as the page scrolls, on the pages that have Cast (the
-/// front page's feed, a channel's, the reader's own profile). On the others
+/// front page's feed, a channel's, Notifications, the reader's own profile). On the others
/// its place is kept, unseen and out of reach, so the header doesn't move
/// from page to page. On a phone, `CastFab` instead.
#[component]
@@ -1365,7 +1366,7 @@ fn takes_typing(element: &web_sys::Element) -> bool {
/// Cast on a phone: a round button floating at the bottom right of the
/// pages that have Cast, within the thumb's reach. The page leaves room
/// after its end for it, and it steps out of the way of a dialog, the
-/// lightbox and a popover (`style/allthing.css`), and of the keyboard,
+/// lightbox and content popovers (`style/allthing.css`), and of the keyboard,
/// while a text box has focus (`away`).
#[component]
pub fn CastFab() -> impl IntoView {
@@ -2798,13 +2799,22 @@ mod tests {
use super::*;
#[test]
- fn cast_is_on_the_feeds_and_the_readers_own_profile() {
+ fn cast_is_on_feeds_notifications_and_the_readers_own_profile() {
let reader = UserSummary { fid: 3, username: "reader.eth".into(), display_name: String::new(), pfp_url: None };
- for path in ["/", "", "/c/dev", "/c/dev/"] {
- assert!(on_feed(path), "{path}");
- }
- for path in ["/p/0x01", "/u/reader.eth", "/search", "/channels", "/scheduled", "/dau"] {
- assert!(!on_feed(path), "{path}");
+ for path in ["/", "", "/c/dev", "/c/dev/", "/notifications", "/notifications/"] {
+ assert!(casts_page(path), "{path}");
+ }
+ for path in [
+ "/p/0x01",
+ "/u/reader.eth",
+ "/search",
+ "/channels",
+ "/scheduled",
+ "/dau",
+ "/notifications-old",
+ "/notifications/settings",
+ ] {
+ assert!(!casts_page(path), "{path}");
}
for (path, name) in [
("/u/reader.eth", None),
diff --git a/crates/allthing-web/src/components/layout.rs b/crates/allthing-web/src/components/layout.rs
index 875dbc78..f50d621a 100644
--- a/crates/allthing-web/src/components/layout.rs
+++ b/crates/allthing-web/src/components/layout.rs
@@ -31,6 +31,7 @@ pub fn SiteHeader() -> impl IntoView {
{presence()}
@@ -164,14 +165,20 @@ fn offered(range: TimeRange) -> bool {
/// Reddit's time filter: how far back the front page reaches, kept in the
/// URL as `t` next to the sort.
#[component]
-pub fn TimeFilter(path: String, sort: Sort, current: TimeRange) -> impl IntoView {
+pub fn TimeFilter(
+ path: String,
+ sort: Sort,
+ current: TimeRange,
+ #[prop(optional)] default_sort: Option,
+) -> impl IntoView {
+ let default_sort = default_sort.unwrap_or(Sort::Hot);
let navigate = use_go();
let change = move |ev: leptos::ev::Event| {
let range = TimeRange::parse(Some(&event_target_value(&ev)));
let href = with_query(
&path,
&[
- ("sort", (sort != Sort::Hot).then(|| sort.as_str().to_owned())),
+ ("sort", (sort != default_sort).then(|| sort.as_str().to_owned())),
("t", (range != TimeRange::default()).then(|| range.as_str().to_owned())),
],
);
diff --git a/crates/allthing-web/src/components/mod.rs b/crates/allthing-web/src/components/mod.rs
index 010f2699..1fe29972 100644
--- a/crates/allthing-web/src/components/mod.rs
+++ b/crates/allthing-web/src/components/mod.rs
@@ -3,6 +3,7 @@
#[cfg(not(feature = "static-host"))]
pub mod attach;
+pub mod bookmark;
pub mod cast;
pub mod chips;
pub mod clients;
diff --git a/crates/allthing-web/src/components/popover.rs b/crates/allthing-web/src/components/popover.rs
index a04e9b0a..f2717729 100644
--- a/crates/allthing-web/src/components/popover.rs
+++ b/crates/allthing-web/src/components/popover.rs
@@ -10,7 +10,8 @@
//! and doesn't also open the post under the tap, follow a link or open
//! another panel; the next tap does. A panel the pointer opened by resting
//! on it closes as the pointer leaves, and a click elsewhere does what it
-//! does.
+//! does. A caller can explicitly allow one outside control to activate as
+//! it dismisses the panel; Settings uses this for Cast.
//!
//! The trigger is a button, or the caller's own links (`links`), such as a
//! profile's picture and name: a mouse click or the keyboard follows them as
@@ -91,6 +92,12 @@ pub fn Popover(
/// Enter and Space open, as a press would.
#[prop(optional)]
no_focus_open: bool,
+ /// An explicitly permitted outside control dismisses this panel and also
+ /// activates on the same press (including keyboard-generated clicks).
+ /// Other outside presses retain light dismissal. Only trusted selectors
+ /// supplied by the component caller are used here.
+ #[prop(optional)]
+ outside_activation: Option<&'static str>,
/// Place the panel against the viewport rather than inside the page's
/// flow, so a container around the trigger that scrolls or clips (a
/// dialog's list, say) does not cut it off. Anything scrolling closes it.
@@ -499,6 +506,9 @@ pub fn Popover(
// A press on `target` dismisses the panel lightly: it is outside the
// popover, and not in a modal dialog over it (one the popover is in, a
// list of who liked a cast, is no exception).
+ let activates_outside = move |target: &web_sys::Element| {
+ outside_activation.is_some_and(|selector| target.closest(selector).ok().flatten().is_some())
+ };
let dismisses = move |target: &web_sys::Element| {
let over = target.closest(MODAL).ok().flatten().is_some_and(|layer| {
!root.try_get_untracked().flatten().is_some_and(|root| layer.contains(Some(root.unchecked_ref())))
@@ -534,17 +544,19 @@ pub fn Popover(
captures.update_value(|list| {
list.push(Capture::new("pointerdown", move |ev| {
let primary = ev.dyn_ref::().is_some_and(|ev| ev.button() == 0);
- if primary && target_element(&ev).is_some_and(|target| dismisses(&target)) {
+ if primary
+ && target_element(&ev).is_some_and(|target| dismisses(&target) && !activates_outside(&target))
+ {
ev.prevent_default();
ev.stop_propagation();
}
}));
list.push(Capture::new("click", move |ev| {
let Some(target) = target_element(&ev) else { return };
- if inside_root(&target) || selected_in_panel() {
+ if inside_root(&target) || selected_in_panel() && !activates_outside(&target) {
return;
}
- if dismisses(&target) {
+ if dismisses(&target) && !activates_outside(&target) {
ev.prevent_default();
ev.stop_propagation();
}
diff --git a/crates/allthing-web/src/components/signin.rs b/crates/allthing-web/src/components/signin.rs
index 6762111b..0b7a005d 100644
--- a/crates/allthing-web/src/components/signin.rs
+++ b/crates/allthing-web/src/components/signin.rs
@@ -650,6 +650,7 @@ pub fn Account() -> impl IntoView {
"@"{username.clone()}"Your profile"
+ "Library"
diff --git a/crates/allthing-web/src/lib.rs b/crates/allthing-web/src/lib.rs
index 817f2227..d8849913 100644
--- a/crates/allthing-web/src/lib.rs
+++ b/crates/allthing-web/src/lib.rs
@@ -13,6 +13,10 @@ mod kept;
#[cfg(not(feature = "static-host"))]
mod keys;
mod leanings;
+#[cfg(not(feature = "static-host"))]
+mod library;
+#[cfg(not(feature = "static-host"))]
+mod library_disk;
mod miniapps;
mod nav;
#[cfg(not(feature = "static-host"))]
@@ -79,6 +83,8 @@ fn App() -> impl IntoView {
#[cfg(not(feature = "static-host"))]
provide_context(components::signin::Session::new());
#[cfg(not(feature = "static-host"))]
+ provide_context(library::Library::new());
+ #[cfg(not(feature = "static-host"))]
provide_context(components::notifications::Notifications::new());
#[cfg(not(feature = "static-host"))]
provide_context(components::compose::Composer::new());
@@ -112,6 +118,7 @@ fn App() -> impl IntoView {
+
diff --git a/crates/allthing-web/src/library.rs b/crates/allthing-web/src/library.rs
new file mode 100644
index 00000000..4dbc6699
--- /dev/null
+++ b/crates/allthing-web/src/library.rs
@@ -0,0 +1,1657 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//! Private Library state, caller-scoped subscriptions and a committed offline outbox.
+//! Shares the verified notification connection; never touches chat or public presence.
+
+use std::cell::{Cell, RefCell};
+use std::collections::{BTreeMap, BTreeSet, VecDeque};
+use std::rc::Rc;
+
+use allthing_core::library::{Account, CachedItem, Item, PendingBookmark, PendingRead, ReadIntent, canonical_hash};
+use allthing_core::stdb::Called;
+use js_sys::Array;
+use leptos::prelude::*;
+use serde::{Deserialize, Serialize};
+use wasm_bindgen::JsValue;
+
+use crate::library_disk;
+use crate::stdb_bridge::{method, parse};
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum SyncStatus {
+ SignedOut,
+ Connecting,
+ Ready,
+ Offline,
+ Failed(String),
+}
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum PrivateKind {
+ Seen,
+ Bookmarks,
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PrivateCursor {
+ pub generation: u64,
+ pub after: String,
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PrivateEntry {
+ pub hash: String,
+ pub at: i64,
+}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PrivatePage {
+ pub entries: Vec,
+ pub next: Option,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub(crate) struct PendingClear {
+ operation: String,
+ expected_generation: u64,
+ sequence: u64,
+}
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default)]
+pub(crate) struct Metadata {
+ pub(crate) fid: u64,
+ server: Account,
+ known: bool,
+ pending_clear: Option,
+ sequence: u64,
+ clear_settled: u64,
+ legacy_imported: bool,
+}
+impl Metadata {
+ pub(crate) fn merge_disk(&mut self, incoming: &Self) {
+ if self.fid != incoming.fid {
+ return;
+ }
+ if incoming.known
+ && (!self.known
+ || (incoming.server.generation, incoming.server.revision)
+ >= (self.server.generation, self.server.revision))
+ {
+ self.server = incoming.server.clone();
+ self.known = true;
+ }
+ self.sequence = self.sequence.max(incoming.sequence);
+ self.clear_settled = self.clear_settled.max(incoming.clear_settled);
+ self.legacy_imported |= incoming.legacy_imported;
+ self.pending_clear = self
+ .pending_clear
+ .iter()
+ .chain(incoming.pending_clear.iter())
+ .filter(|p| p.sequence > self.clear_settled)
+ .max_by_key(|p| p.sequence)
+ .cloned();
+ if self.pending_clear.as_ref().is_some_and(|p| self.known && self.server.generation > p.expected_generation)
+ && let Some(p) = self.pending_clear.take()
+ {
+ self.clear_settled = self.clear_settled.max(p.sequence);
+ }
+ }
+ fn effective(&self) -> Account {
+ let mut account = self.server.clone();
+ account.fid = self.fid;
+ if let Some(clear) = &self.pending_clear {
+ account.generation = clear.expected_generation.saturating_add(1);
+ }
+ account
+ }
+}
+
+#[derive(Clone)]
+struct State {
+ fid: Option,
+ capability: Option,
+ status: SyncStatus,
+ revision: u64,
+ metadata: Metadata,
+ items: BTreeMap,
+ checked: BTreeSet,
+ loaded: BTreeSet,
+}
+impl Default for State {
+ fn default() -> Self {
+ Self {
+ fid: None,
+ capability: None,
+ status: SyncStatus::SignedOut,
+ revision: 0,
+ metadata: Metadata::default(),
+ items: BTreeMap::new(),
+ checked: BTreeSet::new(),
+ loaded: BTreeSet::new(),
+ }
+ }
+}
+#[derive(Clone, Copy)]
+pub struct Library(RwSignal, crate::components::signin::Session);
+thread_local! {
+ static HANDLE: Cell