Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`, `/p/<hash>`, `/u/<name>`, `/u/<name>/followers`, `/u/<name>/following`, `/channels`, `/search?q=`, `/dau`, `/scheduled`, `/messages` (`?c=<conversation>`, `?g=<group>`, `?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/<id>`, `/p/<hash>`, `/u/<name>`, `/u/<name>/followers`, `/u/<name>/following`, `/channels`, `/search?q=`, `/dau`, `/scheduled`, `/messages` (`?c=<conversation>`, `?g=<group>`, `?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.**
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -50,14 +50,15 @@ 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.

### 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.
Expand Down
126 changes: 125 additions & 1 deletion crates/allthing-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<LibraryEntry>,
/// Available casts in reaction order, with mini apps marked for settings.
pub pool: Vec<Cast>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next: Option<String>,
/// Omitted by a successful provider lookup; this does not assert deletion.
#[serde(default)]
pub unavailable: Vec<String>,
/// Hydrated, but omitted by the app's content rules, not by the provider.
#[serde(default)]
pub filtered: Vec<String>,
#[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<Cast>,
#[serde(default)]
pub unavailable: Vec<String>,
#[serde(default)]
pub filtered: Vec<String>,
#[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<String> {
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<Vec<String>> {
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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down
27 changes: 27 additions & 0 deletions crates/allthing-core/src/following.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions crates/allthing-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading