From 8bbc430651f98f8c45c64979e37cc1c98c5b64e9 Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:26:06 +0100 Subject: [PATCH 1/9] feat(moderation): sync private mute and block across sessions --- AGENTS.md | 2 +- CHANGELOG.md | 2 + README.md | 5 +- crates/allthing-core/src/clients.rs | 1 + crates/allthing-core/src/compose.rs | 1 + crates/allthing-core/src/drafts.rs | 9 +- crates/allthing-core/src/emoji.rs | 1 + crates/allthing-core/src/following.rs | 1 + crates/allthing-core/src/index.rs | 1 + crates/allthing-core/src/latest.rs | 1 + crates/allthing-core/src/lib.rs | 1 + crates/allthing-core/src/model.rs | 5 + crates/allthing-core/src/moderation.rs | 595 +++++++++++ crates/allthing-core/src/normalize.rs | 83 ++ crates/allthing-core/src/notifications.rs | 24 +- crates/allthing-core/src/quotes.rs | 1 + crates/allthing-core/src/ranking.rs | 1 + crates/allthing-core/src/scheduled.rs | 40 +- crates/allthing-core/src/thread.rs | 1 + crates/allthing-data/src/miniapps.rs | 1 + crates/allthing-data/src/pages.rs | 7 + .../src/components/account_controls.rs | 184 ++++ crates/allthing-web/src/components/cast.rs | 41 +- crates/allthing-web/src/components/compose.rs | 78 +- crates/allthing-web/src/components/media.rs | 34 +- crates/allthing-web/src/components/mod.rs | 2 + .../src/components/notifications.rs | 10 +- .../allthing-web/src/components/outgoing.rs | 1 + .../src/components/profile_card.rs | 14 + .../allthing-web/src/components/reactions.rs | 1 + crates/allthing-web/src/components/search.rs | 20 +- crates/allthing-web/src/components/thread.rs | 23 +- crates/allthing-web/src/components/who.rs | 48 +- crates/allthing-web/src/components/writer.rs | 35 + crates/allthing-web/src/lib.rs | 6 + crates/allthing-web/src/library_disk.rs | 90 ++ crates/allthing-web/src/miniapps.rs | 13 + crates/allthing-web/src/moderation.rs | 965 ++++++++++++++++++ crates/allthing-web/src/moderation_static.rs | 34 + crates/allthing-web/src/notification_sync.rs | 56 +- .../allthing-web/src/pages/messages/people.rs | 30 +- .../src/pages/messages/realtime/chat.rs | 16 +- .../src/pages/messages/realtime/encrypted.rs | 93 +- .../src/pages/messages/realtime/inbox.rs | 38 +- .../allthing-web/src/pages/notifications.rs | 7 +- crates/allthing-web/src/pages/post.rs | 53 +- crates/allthing-web/src/pages/search.rs | 2 +- crates/allthing-web/src/pages/user.rs | 14 + crates/allthing-worker/src/lib.rs | 4 +- docs/ARCHITECTURE.md | 26 +- docs/BUDGET.md | 4 + e2e/early-paint.fixture.spec.ts | 67 ++ e2e/fixtures/moderation-sync-wire.ts | 48 + e2e/moderation.fixture.spec.ts | 542 ++++++++++ e2e/realtime-messages.fixture.spec.ts | 192 +++- spacetime/HANDOFF.md | 11 +- spacetime/spacetimedb/src/lib.rs | 62 +- spacetime/spacetimedb/src/moderation.rs | 198 ++++ style/allthing.css | 36 + tests/stdb-bridge.test.mjs | 63 ++ web/paint.js | 26 +- web/stdb/bridge.ts | 13 + web/stdb/module_bindings/index.ts | 17 + .../my_account_filters_table.ts | 24 + .../set_account_filter_reducer.ts | 20 + web/stdb/module_bindings/types.ts | 13 + web/stdb/module_bindings/types/reducers.ts | 2 + 67 files changed, 3937 insertions(+), 122 deletions(-) create mode 100644 crates/allthing-core/src/moderation.rs create mode 100644 crates/allthing-web/src/components/account_controls.rs create mode 100644 crates/allthing-web/src/moderation.rs create mode 100644 crates/allthing-web/src/moderation_static.rs create mode 100644 e2e/fixtures/moderation-sync-wire.ts create mode 100644 e2e/moderation.fixture.spec.ts create mode 100644 spacetime/spacetimedb/src/moderation.rs create mode 100644 web/stdb/module_bindings/my_account_filters_table.ts create mode 100644 web/stdb/module_bindings/set_account_filter_reducer.ts diff --git a/AGENTS.md b/AGENTS.md index ef19748b..f130e414 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Allthing is a Farcaster client for reading, written in Rust: a Leptos WebAssembly app plus a workers-rs API Worker, deployed to Cloudflare Workers (Free plan) at https://allthing.social. Read `docs/ARCHITECTURE.md` first. - **One QR flow: sign in, and write, with a key the reader approves once.** By the owner's decision, where the Worker endorses keys there is one approval and one dialog: the reader approves Allthing's key request in the Farcaster app (a QR code on a computer, a link to the app on a phone), which signs them in (`POST /api/v1/auth/key`) and lets them cast, reply, quote, like, recast, follow and delete from here, with no second scan or separate grant step. A reader signed in without a key in this browser (signed in before keys, after Revoke permissions, or with its storage cleared) meets the same dialog, in its approve form, on their first write (`Session::grant`), never by itself on a page load. Never add a second approval dialog or QR flow. Sign In With Farcaster by QR code is only the fallback where the Worker endorses no keys, and makes no relay request where it does (`allthing-worker/src/auth.rs`, `allthing-core/src/siwf.rs`, `allthing-web/src/components/signin.rs`; see `docs/ARCHITECTURE.md#sign-in`). Once a reader has approved Allthing in the Farcaster app, casts, replies, quotes, likes, recasts, follows and deletes go from Allthing: the browser makes and keeps the key and signs (`allthing-core/src/app_key.rs` and `message.rs`, `allthing-web/src/keys.rs` and `components/writer.rs`; see `docs/ARCHITECTURE.md#liking-and-recasting`), and the Worker keeps scheduled casts the browser signed until their time (`allthing-worker/src/scheduled_casts.rs`, `allthing-data/src/scheduled.rs`). Allthing asks for those keys as its own Farcaster account, @allthingapp (FID 3356318, `ALLTHING_FID` in `wrangler.toml`), which its owner registered for it, and shows the casts "via Allthing" (`allthing-core/src/clients.rs`). By the owner's decision that is Allthing's only account: never add another, a seed phrase import or a wallet connection. The Worker endorses readers' keys with that account's custody key, the `ALLTHING_CUSTODY_KEY` secret, set in Cloudflare only (`allthing-worker/src/app_key.rs`; see `docs/DEPLOY.md#secrets`), and uses it for nothing else; it never holds a reader's key or signs as a reader. Until the secret is set (`writes.ready` in `/api/v1/status`), and for a reader signed out, a write opens the cast, the profile or a draft ready to post in the Farcaster app instead (`allthing-web/src/components/handoff.rs`, `allthing-core/src/compose.rs`; see `docs/ARCHITECTURE.md#handing-writes-to-the-farcaster-app`). Never add other login methods, wallet connections or write paths unless asked to. Messages (`/messages`) is Allthing's own, on SpacetimeDB (`allthing-web/src/pages/messages`, `spacetime/`; see `docs/ARCHITECTURE.md#messages-on-spacetimedb`): never add Farcaster Direct Cast API calls or an API-key paste step to it unless asked. -- **Never hide content** because of who posted it. Allthing shows no account labels; spam is judged by a cast's content (see Mini apps and spam below). +- **No automatic account-based exclusions.** Allthing shows no account labels; spam is judged by a cast's content (see Mini apps and spam below). The owner's explicit exception is reader-selected private Mute and Block: hide content involving those accounts throughout that reader's Allthing view, including identifiable mentions, quoted/replied-to/recasting accounts and notifications. Keep these preferences caller-private and synced through SpacetimeDB; Block also rejects new Allthing DMs in either direction using verified FIDs. This does not delete public posts or establish a block in another Farcaster client. Preserve the recognition limits and undo/sync explanations in `docs/ARCHITECTURE.md#private-mute-and-block`; never turn these choices into a shared author blacklist. - **Rust first.** The owner prefers Rust for application behavior and native tooling. Put pure shared rules in `allthing-core`, interactive UI in `allthing-web`, and native build/check policy in `xtask`. Keep `web/paint.js`'s inert first-card renderer: the owner chose it after a paired cold-load comparison beat the Rust prototype (see `docs/PERFORMANCE.md#measured-exception-the-javascript-first-card-renderer`). Retain the existing Node static test server (`e2e/pages-server.mjs`) after an unresolved pre-mount stall in migration CI; the server's role in that stall was not established (see `docs/PERFORMANCE.md#retained-node-static-test-server`). Keep the small JavaScript loader and inline preload/data hints, the SpacetimeDB TypeScript SDK adapter/generated bindings, and official Playwright browser tests at their platform boundaries. CSS, SQL, HTML and config remain platform data. Preserve measured performance, license rules and independent test oracles; do not mask handwritten files with Linguist attributes. - **Where code goes.** - Pure logic and data types, with unit tests, go in `crates/allthing-core`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 405e81d7..71afba99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ### Added +- Private, account-wide Mute and Block controls on profiles and hover cards, with searchable undo controls in Settings and keyboard/touch-accessible help. Preferences sync through SpacetimeDB and suppress posts, quoted/replied-to/recasting accounts, identifiable mentions and account links, notifications and their unread counts. Block also hides existing Allthing conversations locally and rejects new DMs in either direction using verified account ownership; public posts and other Farcaster clients are unaffected. +- Durable offline mute/block intent, independent per-choice revisions and retained clear tombstones. Fresh signed-in sessions wait for private filters before drawing content; realtime changes remove already shown content, and newer remote choices supersede stale offline intent. - 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. diff --git a/README.md b/README.md index c3432555..0fb4ea89 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Allthing is an independent Farcaster client, written in Rust, that shows trendin - **Provenance.** Each cast names the app that registered its signing key ([docs/PROVENANCE.md](docs/PROVENANCE.md)). - **Daily active accounts.** [/dau](https://allthing.social/dau) counts Farcaster's active accounts from the network's event stream, exactly as [SnapMeter](https://github.com/ael-dev3/SnapMeter) does. - **Allthing's poll.** One question on the front page: would you like an Allthing mobile client? -- **Less noise, judged by content.** Spam caught by a few fixed content rules is left out by the Worker. Casts that embed or link to mini apps or frames, which Allthing doesn't run, are hidden too unless you turn on Show mini app casts in Settings; then each links to open in Farcaster. Nothing is hidden because of who posted it. +- **Less noise, judged by content.** Spam caught by a few fixed content rules is left out by the Worker. Casts that embed or link to mini apps or frames, which Allthing doesn't run, are hidden too unless you turn on Show mini app casts in Settings; then each links to open in Farcaster. There are no automatic account-based exclusions; signed-in readers can choose their own private Mute and Block preferences. ### Signed in @@ -52,13 +52,14 @@ Allthing is an independent Farcaster client, written in Rust, that shows trendin - **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. +- **Private Mute and Block.** Use the buttons on another account's profile or hover card to hide posts and notifications involving it, including identifiable mentions, quotes, replies, recasts and account links. Block also stops new Allthing DMs in both directions. Choices sync across signed-in sessions through SpacetimeDB; Settings → Muted and blocked accounts lets you find and undo them separately. The help button explains pending sync, recognition limits and the scope: public posts remain public, other Farcaster clients are unaffected, and arbitrary names or text in images cannot always be recognized. - **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.** 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). +- **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 and account filters to avoid flashing previously read or muted/blocked 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/clients.rs b/crates/allthing-core/src/clients.rs index 853a0028..f7a21642 100644 --- a/crates/allthing-core/src/clients.rs +++ b/crates/allthing-core/src/clients.rs @@ -181,6 +181,7 @@ mod tests { recasts: 0, replies: 0, mini_app: false, + references: Default::default(), spam: false, } } diff --git a/crates/allthing-core/src/compose.rs b/crates/allthing-core/src/compose.rs index 5dcde777..69498842 100644 --- a/crates/allthing-core/src/compose.rs +++ b/crates/allthing-core/src/compose.rs @@ -734,6 +734,7 @@ mod tests { recasts: 0, replies: 0, mini_app: false, + references: Default::default(), spam: false, } } diff --git a/crates/allthing-core/src/drafts.rs b/crates/allthing-core/src/drafts.rs index 205de3f4..a3f898a2 100644 --- a/crates/allthing-core/src/drafts.rs +++ b/crates/allthing-core/src/drafts.rs @@ -130,6 +130,7 @@ mod tests { fid: 42, username: "alice".into(), excerpt: "What next?".into(), + ..SavedCast::default() }), ..SavedDraft::default() }; @@ -139,7 +140,13 @@ mod tests { #[test] fn each_draft_is_kept_for_what_it_is_for() { let quote = SavedDraft { - quote: Some(SavedCast { hash: "0xAB".into(), fid: 7, username: "bob".into(), excerpt: String::new() }), + quote: Some(SavedCast { + hash: "0xAB".into(), + fid: 7, + username: "bob".into(), + excerpt: String::new(), + ..SavedCast::default() + }), ..SavedDraft::default() }; let books = SavedDraft { channel: Some("books".into()), ..SavedDraft::default() }; diff --git a/crates/allthing-core/src/emoji.rs b/crates/allthing-core/src/emoji.rs index e1329553..82240fa6 100644 --- a/crates/allthing-core/src/emoji.rs +++ b/crates/allthing-core/src/emoji.rs @@ -962,6 +962,7 @@ mod tests { recasts: 0, replies: 0, mini_app: false, + references: Default::default(), spam: false, } } diff --git a/crates/allthing-core/src/following.rs b/crates/allthing-core/src/following.rs index c6970aa3..5936cb81 100644 --- a/crates/allthing-core/src/following.rs +++ b/crates/allthing-core/src/following.rs @@ -143,6 +143,7 @@ mod tests { recasts: 0, replies: 0, mini_app: false, + references: Default::default(), spam: false, } } diff --git a/crates/allthing-core/src/index.rs b/crates/allthing-core/src/index.rs index 48bed0f6..505ae894 100644 --- a/crates/allthing-core/src/index.rs +++ b/crates/allthing-core/src/index.rs @@ -415,6 +415,7 @@ mod tests { recasts: 0, replies: 0, mini_app: false, + references: Default::default(), spam: false, } } diff --git a/crates/allthing-core/src/latest.rs b/crates/allthing-core/src/latest.rs index a4b6cca4..99f7c15b 100644 --- a/crates/allthing-core/src/latest.rs +++ b/crates/allthing-core/src/latest.rs @@ -321,6 +321,7 @@ mod tests { recasts: 0, replies: 0, mini_app: false, + references: Default::default(), spam: false, } } diff --git a/crates/allthing-core/src/lib.rs b/crates/allthing-core/src/lib.rs index 2f08f106..4fb9cfff 100644 --- a/crates/allthing-core/src/lib.rs +++ b/crates/allthing-core/src/lib.rs @@ -32,6 +32,7 @@ pub mod media; pub mod message; pub mod metadata; pub mod model; +pub mod moderation; pub mod normalize; pub mod notification_state; pub mod notifications; diff --git a/crates/allthing-core/src/model.rs b/crates/allthing-core/src/model.rs index 227067c3..b807838c 100644 --- a/crates/allthing-core/src/model.rs +++ b/crates/allthing-core/src/model.rs @@ -205,6 +205,11 @@ pub struct Cast { pub text: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub mentions: Vec, + /// Account provenance that an excerpt cannot retain: known reply parents, + /// recasters and inline quoted-cast identities. Reader filtering also + /// checks this cast's own author, mentions, text and hydrated quotes. + #[serde(default, skip_serializing_if = "crate::moderation::References::is_empty")] + pub references: crate::moderation::References, /// Unix seconds. pub ts: i64, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/allthing-core/src/moderation.rs b/crates/allthing-core/src/moderation.rs new file mode 100644 index 00000000..4f16ca56 --- /dev/null +++ b/crates/allthing-core/src/moderation.rs @@ -0,0 +1,595 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +//! Reader-selected account filtering. Identity comes from FIDs, structured +//! mentions and explicit account references, never from a display name. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use serde::{Deserialize, Serialize}; + +use crate::model::{Cast, Embed, UserSummary}; +use crate::notifications::{Group, GroupTime}; +use crate::text::Token; + +/// A finite payload can carry every account mentioned in normal casts and +/// notification pages. Oversized metadata is marked incomplete rather than +/// silently dropping an identity and accidentally showing protected content. +const MAX_REFERENCES: usize = 512; +const MAX_QUOTE_NODES: usize = 128; + +/// Compact account identities retained when a cast becomes an excerpt or a +/// notification's displayed actors are truncated. Usernames are unresolved +/// explicit references only; known structured identities use their stable FID. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct References { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub usernames: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub incomplete: bool, +} + +impl References { + pub fn is_empty(&self) -> bool { + self.fids.is_empty() && self.usernames.is_empty() && !self.incomplete + } + + pub fn add_fid(&mut self, fid: u64) { + if fid != 0 { + insert_bounded(&mut self.fids, fid, &mut self.incomplete); + } + } + + pub fn add_username(&mut self, username: &str) { + if let Some(username) = normalize_username(username) { + if let Some(fid) = crate::params::fid_username(&username) { + self.add_fid(fid); + } else { + insert_bounded(&mut self.usernames, username, &mut self.incomplete); + } + } + } + + pub fn add_user(&mut self, user: &UserSummary) { + if user.fid != 0 { self.add_fid(user.fid) } else { self.add_username(&user.username) } + } + + pub fn extend(&mut self, other: &References) { + for &fid in &other.fids { + self.add_fid(fid); + } + for username in &other.usernames { + self.add_username(username); + } + self.incomplete |= other.incomplete; + } + + /// Explicit @handles and links to profiles on Farcaster's two official + /// hosts. Email addresses, arbitrary URLs and plain names do not count. + pub fn of_text(text: &str) -> Self { + Self::text_with_resolved(text, &[]) + } + + /// A cast's own identity references, including normalized reply/recast + /// provenance. [`FilterSet::hides_cast`] also walks hydrated quoted casts. + pub fn of_cast(cast: &Cast) -> Self { + let mut references = Self::text_with_resolved(&cast.text, &cast.mentions); + references.extend(&cast.references); + references.add_user(&cast.author); + for mention in &cast.mentions { + if mention.fid != 0 { references.add_fid(mention.fid) } else { references.add_username(&mention.username) } + } + for embed in &cast.embeds { + match embed { + Embed::Cast { fid: Some(fid), .. } => references.add_fid(*fid), + Embed::Image { url } | Embed::Video { url } | Embed::Link { url, .. } => { + references.add_profile_url(url) + } + _ => {} + } + } + references + } + + /// Retain all identities reachable through the quotes that can display + /// inline. A cycle is visited once. The node guard fails closed while any + /// filters are active, rather than exposing media from an uninspected node. + pub fn with_quotes(cast: &Cast, quotes: &BTreeMap) -> Self { + let mut references = Self::of_cast(cast); + let mut pending = vec![(cast, 0)]; + let mut visited = HashSet::new(); + visited.insert(cast.hash.as_str()); + while let Some((current, depth)) = pending.pop() { + if depth >= crate::quotes::QUOTE_DEPTH { + continue; + } + for hash in current.quoted() { + if visited.contains(hash) { + continue; + } + if visited.len() >= MAX_QUOTE_NODES { + references.incomplete = true; + return references; + } + visited.insert(hash); + if let Some(quote) = quotes.get(hash) { + references.extend(&Self::of_cast(quote)); + pending.push((quote, depth + 1)); + } + } + } + references + } + + /// Structured mentions resolve an alias to a FID. A later holder of a + /// muted account's former username must not be hidden by the old alias. + pub fn text_with_resolved(text: &str, mentions: &[crate::model::Mention]) -> Self { + let mut references = Self::default(); + let resolved: BTreeSet = mentions + .iter() + .filter(|mention| mention.fid != 0) + .filter_map(|mention| normalize_username(&mention.username)) + .collect(); + for token in crate::text::tokenize(text, &[]) { + match token { + Token::Url(url) => { + if let Some(username) = profile_username(&url) + && !resolved.contains(&username) + { + references.add_username(&username); + } + } + Token::Text(text) => { + // Unlike the compose helper this has no ten-mention cap: + // an explicit handle beyond the protocol mention limit + // still matters to a reader who requested full hiding. + for (at, _) in text.match_indices('@') { + let previous = text[..at].chars().next_back(); + if previous.is_some_and(|c| c.is_alphanumeric() || matches!(c, '_' | '/' | '@' | '.' | '-')) { + continue; + } + let rest = &text[at + 1..]; + let stop = rest.find(|c: char| !name_char(c)).unwrap_or(rest.len()); + let name = rest[..stop].trim_end_matches('.'); + if let Some(username) = normalize_username(name) + && !resolved.contains(&username) + { + references.add_username(&username); + } + } + } + _ => {} + } + } + references + } + + pub fn add_profile_url(&mut self, url: &str) { + if let Some(username) = profile_username(url) { + self.add_username(&username); + } + } +} + +fn insert_bounded(items: &mut Vec, item: T, incomplete: &mut bool) { + match items.binary_search(&item) { + Ok(_) => {} + Err(at) if items.len() < MAX_REFERENCES => items.insert(at, item), + Err(_) => *incomplete = true, + } +} + +fn name_char(c: char) -> bool { + c.is_alphanumeric() + || matches!(c, '_' | '.' | '-') + || (!c.is_ascii() && !c.is_whitespace() && !c.is_control() && !",。!?:;、()【】“”‘’—–…«»‹›".contains(c)) +} + +/// Normalize an actual handle, not a display name or a URL. FID fallback +/// handles are supported for accounts without a username. +pub fn normalize_username(value: &str) -> Option { + let value = value.trim().strip_prefix('@').unwrap_or(value.trim()).to_lowercase(); + if let Some(fid) = crate::params::fid_username(&value) { + return (fid != 0).then(|| format!("fid:{fid}")); + } + let first = value.chars().next()?; + let last = value.chars().next_back()?; + (value.chars().count() <= 253 + && !matches!(first, '.' | '-' | '_') + && !matches!(last, '.' | '-' | '_') + && value.chars().all(name_char)) + .then_some(value) +} + +fn profile_username(url: &str) -> Option { + let (scheme, rest) = url.split_once("://")?; + if !scheme.eq_ignore_ascii_case("https") && !scheme.eq_ignore_ascii_case("http") { + return None; + } + let (authority, path) = rest.split_once('/')?; + if !["farcaster.xyz", "warpcast.com", "www.farcaster.xyz", "www.warpcast.com"] + .iter() + .any(|host| authority.eq_ignore_ascii_case(host)) + { + return None; + } + let path = path.split(['?', '#']).next()?; + let mut segments = path.split('/').filter(|segment| !segment.is_empty()); + let name = segments.next()?; + // Official FID profile URLs have no username that can be reassigned. + if name == "~" { + if segments.next()? != "profiles" { + return None; + } + let fid: u64 = segments.next()?.parse().ok()?; + return (fid != 0 && segments.next().is_none()).then(|| format!("fid:{fid}")); + } + if ["miniapps", "mini-apps", "frames", "channel", "channels", "settings", "search", "invite", "login", "signup"] + .iter() + .any(|reserved| name.eq_ignore_ascii_case(reserved)) + { + return None; + } + // /name and /name/0xhash are identity-bearing; arbitrary routes are not. + if let Some(hash) = segments.next() + && (!cast_hash_link(hash) || segments.next().is_some()) + { + return None; + } + let decoded = crate::params::percent_decode(name)?; + normalize_username(&decoded) +} + +fn cast_hash_link(value: &str) -> bool { + let Some(hex) = value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")) else { return false }; + // The Farcaster app also shares eight-digit cast hash prefixes. + (8..=40).contains(&hex.len()) && hex.len().is_multiple_of(2) && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +/// The union of the reader's active mutes and blocks. Their storage semantics +/// differ, but either requests the same complete content hiding in Allthing. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FilterSet { + fids: BTreeSet, + usernames: BTreeSet, +} + +impl FilterSet { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, fid: u64, username: &str) { + if fid != 0 { + self.fids.insert(fid); + } + if let Some(username) = normalize_username(username) { + self.usernames.insert(username); + } + } + + pub fn is_empty(&self) -> bool { + self.fids.is_empty() && self.usernames.is_empty() + } + + pub fn hides_user(&self, user: &UserSummary) -> bool { + if user.fid != 0 { + self.fids.contains(&user.fid) + } else { + normalize_username(&user.username).is_some_and(|name| { + crate::params::fid_username(&name).is_some_and(|fid| self.fids.contains(&fid)) + || self.usernames.contains(&name) + }) + } + } + + pub fn hides_references(&self, references: &References) -> bool { + if self.is_empty() { + return false; + } + references.incomplete + || references.fids.iter().any(|fid| self.fids.contains(fid)) + || references.usernames.iter().any(|name| { + crate::params::fid_username(name).is_some_and(|fid| self.fids.contains(&fid)) + || self.usernames.contains(name) + }) + } + + pub fn hides_text(&self, text: &str) -> bool { + !self.is_empty() && self.hides_references(&References::of_text(text)) + } + + pub fn hides_cast(&self, cast: &Cast, quotes: &BTreeMap) -> bool { + !self.is_empty() && self.hides_references(&References::with_quotes(cast, quotes)) + } + + pub fn hides_group(&self, group: &Group) -> bool { + if self.is_empty() { + return false; + } + // Old cached excerpts cannot prove that no protected account appears + // beyond their text/actor cutoff. Wait for full reference metadata. + if group.references.is_empty() + && (group.cast.is_some() || group.actors.is_empty() || group.count as usize > group.actors.len()) + { + return true; + } + self.hides_references(&group.references) || group.actors.iter().any(|actor| self.hides_user(actor)) + } + + pub fn hides_group_time(&self, group: &GroupTime) -> bool { + if self.is_empty() { + return false; + } + if group.references.is_empty() + && (group.cast.is_some() + || group.actors.is_empty() + || group.count.is_none_or(|count| count as usize > group.actors.len())) + { + return true; + } + self.hides_references(&group.references) || group.actors.iter().any(|actor| self.hides_user(actor)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Mention; + use crate::notifications::{Event, Kind, Times, group}; + + fn user(fid: u64, username: &str) -> UserSummary { + UserSummary { fid, username: username.into(), display_name: "Alice".into(), pfp_url: None } + } + + fn cast(hash: &str, fid: u64, text: &str) -> Cast { + serde_json::from_value(serde_json::json!({ + "hash": hash, "author": user(fid, &format!("user{fid}")), "text": text, "ts": 100 + })) + .unwrap() + } + + fn filters() -> FilterSet { + let mut filters = FilterSet::new(); + filters.insert(88, "@Alice.ETH"); + filters + } + + #[test] + fn explicit_handles_use_whole_tokens_and_never_plain_names_or_emails() { + let filter = filters(); + for text in ["@alice.eth", "Hello (@ALICE.ETH).", "@alice.eth!", "a message\n@alice.eth", "@alice.eth—hello"] + { + assert!(filter.hides_text(text), "{text}"); + } + for text in [ + "Alice", + "alice.eth", + "@alice.eth2", + "@alice.eth-extra", + "@alice.eth_other", + "other@alice.eth", + "first.last@alice.eth", + "https://example.com/@alice.eth", + "https://alice.eth", + "@@alice.eth", + ] { + assert!(!filter.hides_text(text), "{text}"); + } + assert!(filter.hides_text("@alice.eth/example")); + } + + #[test] + fn handles_after_the_protocol_mention_limit_still_hide_the_whole_post() { + let text = (0..30).map(|n| format!("@other{n} ")).collect::() + "@alice.eth"; + assert!(filters().hides_text(&text)); + } + + #[test] + fn profiles_and_cast_links_require_exact_official_hosts_and_real_routes() { + let filter = filters(); + for text in [ + "https://farcaster.xyz/alice.eth", + "http://warpcast.com/ALICE.ETH?x=1", + "(https://www.farcaster.xyz/%61lice.eth/)", + "https://farcaster.xyz/alice.eth/0x1234abcd", + "https://warpcast.com/~/profiles/88", + "HTTPS://FARCASTER.XYZ/alice.eth#casts", + ] { + assert!(filter.hides_text(text), "{text}"); + } + for text in [ + "https://farcaster.xyz.evil.test/alice.eth", + "https://farcaster.xyz@evil.test/alice.eth", + "https://evil.test/?next=https://farcaster.xyz/alice.eth", + "https://farcaster.xyz/alice.eth2", + "https://farcaster.xyz/~/channel/alice.eth", + "https://farcaster.xyz/miniapps/alice.eth", + "https://farcaster.xyz/alice.eth/something", + "https://farcaster.xyz/alice.eth%2Fevil", + "https://farcaster.xyz/~/profiles/888", + "https://farcaster.xyz/alice.eth/0xNOTAHASH", + ] { + assert!(!filter.hides_text(text), "{text}"); + } + } + + #[test] + fn known_fids_survive_renames_without_hiding_a_reassigned_alias() { + let filter = filters(); + assert!(filter.hides_user(&user(88, "new-name"))); + assert!(!filter.hides_user(&user(99, "alice.eth"))); + assert!(filter.hides_user(&user(0, "ALICE.ETH"))); + assert!(filter.hides_user(&user(0, "fid:88"))); + let mut mention = cast("0xm", 1, "hello @alice.eth"); + mention.mentions.push(Mention { fid: 99, username: "alice.eth".into() }); + assert!(!filter.hides_cast(&mention, &BTreeMap::new())); + mention.mentions[0] = Mention { fid: 88, username: "new-name".into() }; + mention.text = "hello @new-name".into(); + assert!(filter.hides_cast(&mention, &BTreeMap::new())); + } + + #[test] + fn author_mentions_parents_recasters_and_embedded_media_are_filtered_before_display() { + let filter = filters(); + let empty = BTreeMap::new(); + assert!(filter.hides_cast(&cast("0xauthor", 88, ""), &empty)); + let mut normal = cast("0xnormal", 1, "Alice is just a display name here"); + assert!(!filter.hides_cast(&normal, &empty)); + normal.mentions.push(Mention { fid: 88, username: "renamed".into() }); + assert!(filter.hides_cast(&normal, &empty)); + normal.mentions.clear(); + normal.references.add_fid(88); + normal.embeds.push(Embed::Image { url: "https://example.com/private-photo.jpg".into() }); + assert!(filter.hides_cast(&normal, &empty)); + normal.references = References::default(); + normal.embeds.push(Embed::Cast { hash: "0xunhydrated".into(), fid: Some(88) }); + assert!(filter.hides_cast(&normal, &empty)); + } + + #[test] + fn nested_quotes_hide_the_entire_outer_post_and_cycles_terminate() { + let mut outer = cast("0xouter", 1, "ordinary post with attached media"); + outer.embeds.push(Embed::Cast { hash: "0xmiddle".into(), fid: Some(2) }); + let mut middle = cast("0xmiddle", 2, "ordinary quote"); + middle.embeds.push(Embed::Cast { hash: "0xinner".into(), fid: Some(3) }); + let mut inner = cast("0xinner", 3, "hello @alice.eth"); + inner.embeds.push(Embed::Cast { hash: "0xouter".into(), fid: Some(1) }); + let mut quotes = BTreeMap::from([(middle.hash.clone(), middle), (inner.hash.clone(), inner)]); + assert!(filters().hides_cast(&outer, "es)); + quotes.get_mut("0xinner").unwrap().text = "entirely unrelated".into(); + assert!(!filters().hides_cast(&outer, "es)); + } + + #[test] + fn normalization_keeps_reply_recast_and_inline_nested_quote_identities() { + use crate::normalize::{RawCast, cast as normalize}; + for provenance in [ + serde_json::json!({"parent_author":{"fid":88}}), + serde_json::json!({"recasted_by":{"fid":88,"username":"renamed"}}), + serde_json::json!({"recast_by":[{"fid":2},{"fid":88}]}), + serde_json::json!({"embeds":[{"cast":{"hash":"1","author":{"fid":2},"text":"x", + "embeds":[{"cast":{"hash":"2","author":{"fid":3},"text":"hello @alice.eth"}}]}}]}), + serde_json::json!({"embeds":[{"cast":{"hash":"1","author":{"fid":2}, + "mentioned_profiles":[{"fid":88,"username":"renamed"}]}}]}), + ] { + let mut raw = serde_json::json!({"hash":"1","author":{"fid":1,"username":"one"},"text":"ordinary"}); + raw.as_object_mut().unwrap().extend(provenance.as_object().unwrap().clone()); + let normalized = normalize(&serde_json::from_value::(raw).unwrap()); + assert!(filters().hides_cast(&normalized, &BTreeMap::new()), "{provenance}"); + } + } + + #[test] + fn all_group_actors_and_full_text_reach_rows_and_the_unread_badge() { + let mut events: Vec = (1..=7) + .map(|fid| Event { + kind: Kind::Like, + actor: user(fid, &format!("person{fid}")), + ts: 100 - fid as i64, + cast: Some(cast("0xmine", 500, "ordinary excerpt")), + mentions_you: false, + }) + .collect(); + events.last_mut().unwrap().actor = user(88, "renamed"); + let mut groups = group(events, 500); + assert_eq!(groups[0].actors.len(), 5); + assert_eq!(groups[0].count, 7); + assert!(filters().hides_group(&groups[0])); + let times: Times = serde_json::from_value(serde_json::json!({"groups":groups,"newest":100})).unwrap(); + assert!(filters().hides_group_time(×.groups[0])); + groups = group( + vec![Event { + kind: Kind::Mention, + actor: user(2, "two"), + ts: 100, + cast: Some(cast("0xmention", 2, &("unrelated words ".repeat(20) + "@alice.eth"))), + mentions_you: true, + }], + 500, + ); + assert!(!groups[0].cast.as_ref().unwrap().excerpt.contains("alice")); + assert!(filters().hides_group(&groups[0])); + } + + #[test] + fn notification_reference_metadata_preserves_resolved_rename_safety() { + let mut target = cast("0xmention", 2, "hello @alice.eth"); + target.mentions.push(Mention { fid: 99, username: "alice.eth".into() }); + let groups = group( + vec![Event { kind: Kind::Mention, actor: user(2, "two"), ts: 100, cast: Some(target), mentions_you: true }], + 500, + ); + assert!(!filters().hides_group(&groups[0])); + let times: Times = serde_json::from_value(serde_json::json!({"groups":groups})).unwrap(); + assert!(!filters().hides_group_time(×.groups[0])); + } + + #[test] + fn old_cached_notifications_without_complete_references_stay_hidden_until_refresh() { + let times: Times = serde_json::from_value(serde_json::json!({"groups":[ + {"ts":100,"actors":[user(88,"renamed")]}, + {"ts":101,"actors":[user(2,"two")],"cast":{"hash":"0xm","excerpt":"hello @alice.eth"}}, + {"ts":102,"actors":[user(2,"two")],"cast":{"hash":"0xn","excerpt":"unrelated"}}, + {"ts":103,"actors":[user(2,"two")],"count":2}, + {"ts":104,"actors":[user(2,"two")],"count":1}, + {"ts":105} + ]})) + .unwrap(); + assert!(filters().hides_group_time(×.groups[0])); + assert!(filters().hides_group_time(×.groups[1])); + // Even an ordinary visible excerpt cannot prove that text/actors + // outside its cutoff do not involve a selected account. + assert!(filters().hides_group_time(×.groups[2])); + assert!(filters().hides_group_time(×.groups[3])); + assert!(!filters().hides_group_time(×.groups[4])); + assert!(filters().hides_group_time(×.groups[5])); + for group in ×.groups { + assert!(!FilterSet::default().hides_group_time(group)); + } + let follow: Group = serde_json::from_value(serde_json::json!({ + "kind":"follow","ts":100,"actors":[user(2,"two")],"count":1 + })) + .unwrap(); + assert!(!filters().hides_group(&follow)); + let mut truncated = follow.clone(); + truncated.count = 7; + assert!(filters().hides_group(&truncated)); + assert!(!FilterSet::default().hides_group(&truncated)); + let mut cast_group = follow; + cast_group.kind = Kind::Like; + cast_group.cast = + Some(crate::notifications::Target { hash: "0xlegacy".into(), excerpt: "A short ordinary start…".into() }); + assert!(filters().hides_group(&cast_group)); + assert!(!FilterSet::default().hides_group(&cast_group)); + } + + #[test] + fn oversized_reference_lists_do_not_silently_leak_protected_content() { + let mut references = References::default(); + for fid in 1000..2000 { + references.add_fid(fid); + } + assert_eq!(references.fids.len(), MAX_REFERENCES); + assert!(references.incomplete); + assert!(filters().hides_references(&references)); + assert!(!FilterSet::default().hides_references(&references)); + let mut copy = References::default(); + copy.extend(&references); + assert_eq!(references, copy); + } + + #[test] + fn unicode_ens_handles_and_fid_links_are_normalized_without_name_guessing() { + let mut filter = FilterSet::new(); + filter.insert(88, "🌴.eth"); + assert!(filter.hides_text("hey @🌴.eth")); + assert!(filter.hides_text("https://farcaster.xyz/%F0%9F%8C%B4.eth")); + assert!(filter.hides_text("https://farcaster.xyz/~/profiles/88")); + assert!(!filter.hides_text("a 🌴.eth display name")); + assert_eq!(normalize_username("@ALICE.ETH"), Some("alice.eth".into())); + for invalid in ["", "@", ".eth", "alice/name", "alice?x", "alice:name", "hello world", "alice\nname"] { + assert_eq!(normalize_username(invalid), None, "{invalid}"); + } + } +} diff --git a/crates/allthing-core/src/normalize.rs b/crates/allthing-core/src/normalize.rs index fe6eb1e8..d0230d35 100644 --- a/crates/allthing-core/src/normalize.rs +++ b/crates/allthing-core/src/normalize.rs @@ -71,12 +71,17 @@ pub struct RawCastId { #[derive(Debug, Default, Deserialize)] pub struct RawEmbeddedCastAuthor { pub fid: Option, + pub username: Option, } #[derive(Debug, Default, Deserialize)] pub struct RawEmbeddedCast { pub hash: Option, pub author: Option, + pub text: Option, + pub mentioned_profiles: Option>, + pub parent_author: Option, + pub embeds: Option>, } #[derive(Debug, Default, Deserialize)] @@ -160,6 +165,11 @@ pub struct RawCast { pub mentioned_profiles: Option>, pub mentioned_profiles_ranges: Option>, pub direct_replies: Option>, + /// Some feed shapes carry the recaster separately from the cast author. + /// Accept either one account or an array without imposing a provider + /// shape on otherwise usable casts. Unrecognized fields are ignored. + #[serde(alias = "recast_by")] + pub recasted_by: Option, } #[derive(Debug, Default, Deserialize)] @@ -764,6 +774,78 @@ fn text_has_mini_app(text: &str) -> bool { .any(|t| matches!(t, crate::text::Token::Url(url) if crate::embeds::is_mini_app_url(url))) } +/// Preserve identities that normalization would otherwise discard. Inline +/// nested quote bodies are bounded independently of displayed quote hydration; +/// a deeper body is marked incomplete rather than silently losing protection. +fn raw_account_references(raw: &RawCast) -> crate::moderation::References { + use crate::moderation::References; + + let mut references = References::default(); + if let Some(fid) = raw.parent_author.as_ref().and_then(|parent| parent.fid) { + references.add_fid(fid); + } + let add_recast = |references: &mut References, actor: &Value| { + if let Some(fid) = actor.get("fid").and_then(Value::as_u64).filter(|fid| *fid != 0) { + references.add_fid(fid); + } else if let Some(username) = actor.get("username").and_then(Value::as_str) { + references.add_username(username); + } + }; + if let Some(actor) = &raw.recasted_by { + match actor { + Value::Array(actors) => { + for actor in actors { + add_recast(&mut references, actor); + } + } + _ => add_recast(&mut references, actor), + } + } + let mut pending: Vec<(&RawEmbed, usize)> = raw.embeds.iter().flatten().map(|embed| (embed, 0)).collect(); + let mut visited = 0; + while let Some((embed, depth)) = pending.pop() { + visited += 1; + if visited > 128 || depth > 8 { + references.incomplete = true; + break; + } + if let Some(fid) = embed.cast_id.as_ref().and_then(|id| id.fid) { + references.add_fid(fid); + } + if let Some(url) = &embed.url { + references.add_profile_url(url); + } + if let Some(quote) = &embed.cast { + if let Some(author) = "e.author { + if let Some(fid) = author.fid.filter(|fid| *fid != 0) { + references.add_fid(fid); + } else if let Some(username) = &author.username { + references.add_username(username); + } + } + if let Some(fid) = quote.parent_author.as_ref().and_then(|parent| parent.fid) { + references.add_fid(fid); + } + let mentions: Vec = quote + .mentioned_profiles + .iter() + .flatten() + .map(|user| Mention { fid: user.fid, username: username_for(user) }) + .collect(); + references.extend(&References::text_with_resolved(quote.text.as_deref().unwrap_or(""), &mentions)); + for mention in mentions { + if mention.fid != 0 { + references.add_fid(mention.fid) + } else { + references.add_username(&mention.username) + } + } + pending.extend(quote.embeds.iter().flatten().map(|embed| (embed, depth + 1))); + } + } + references +} + pub fn cast(raw: &RawCast) -> Cast { let mentions: Vec = raw .mentioned_profiles @@ -788,6 +870,7 @@ pub fn cast(raw: &RawCast) -> Cast { ), text, mentions, + references: raw_account_references(raw), ts: raw.timestamp.as_deref().and_then(parse_rfc3339).unwrap_or(0), parent_hash: non_empty(raw.parent_hash.as_deref()).map(|h| normalize_hash(&h)), parent_url: non_empty(raw.parent_url.as_deref()), diff --git a/crates/allthing-core/src/notifications.rs b/crates/allthing-core/src/notifications.rs index 5fda4e9a..d0bdf2c1 100644 --- a/crates/allthing-core/src/notifications.rs +++ b/crates/allthing-core/src/notifications.rs @@ -20,6 +20,7 @@ use crate::api::{API_PREFIX, Annotations, Meta}; use crate::embeds::embed_label; use crate::format::full_number; use crate::model::{Cast, Embed, UserSummary}; +use crate::moderation::References; use crate::params::percent_encode; use crate::text::truncate_title; @@ -273,6 +274,10 @@ pub struct Group { /// A quote that mentions the reader, which Mentions shows too. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub mentions_you: bool, + /// All actors, including those beyond the five displayed, and the full + /// target cast's account references before its text becomes an excerpt. + #[serde(default, skip_serializing_if = "References::is_empty")] + pub references: References, } /// Where a group's row goes. @@ -365,12 +370,17 @@ pub fn group(mut events: Vec, me: u64) -> Vec { ts: event.ts, cast: event.cast.as_ref().map(Target::of), mentions_you: false, + references: References::default(), }; groups.push((group, HashSet::new())); groups.len() - 1 } }; let (group, people) = &mut groups[at]; + group.references.add_user(&event.actor); + if let Some(cast) = &event.cast { + group.references.extend(&References::of_cast(cast)); + } if event.kind.rank() > group.kind.rank() { group.kind = event.kind; } @@ -462,9 +472,20 @@ pub struct Times { pub newest: i64, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] pub struct GroupTime { pub ts: i64, + /// The same compact references used by notification rows, so a hidden + /// notification cannot leak back through the unread badge. + #[serde(default)] + pub references: References, + /// Backward compatibility for cached payloads predating references. + #[serde(default)] + pub actors: Vec, + #[serde(default)] + pub count: Option, + #[serde(default)] + pub cast: Option, } impl Times { @@ -551,6 +572,7 @@ mod tests { recasts: 0, replies: 0, mini_app: false, + references: Default::default(), spam: false, } } diff --git a/crates/allthing-core/src/quotes.rs b/crates/allthing-core/src/quotes.rs index d723bc13..dc3ac14b 100644 --- a/crates/allthing-core/src/quotes.rs +++ b/crates/allthing-core/src/quotes.rs @@ -98,6 +98,7 @@ mod tests { recasts: 0, replies: 0, mini_app: false, + references: Default::default(), spam: false, } } diff --git a/crates/allthing-core/src/ranking.rs b/crates/allthing-core/src/ranking.rs index fff5932b..1b55dc97 100644 --- a/crates/allthing-core/src/ranking.rs +++ b/crates/allthing-core/src/ranking.rs @@ -577,6 +577,7 @@ mod tests { recasts, replies, mini_app: false, + references: Default::default(), spam: false, } } diff --git a/crates/allthing-core/src/scheduled.rs b/crates/allthing-core/src/scheduled.rs index 9ec2360b..72918115 100644 --- a/crates/allthing-core/src/scheduled.rs +++ b/crates/allthing-core/src/scheduled.rs @@ -280,6 +280,9 @@ pub struct SavedCast { pub username: String, #[serde(default)] pub excerpt: String, + /// Full account provenance survives excerpt truncation and draft reloads. + #[serde(default, skip_serializing_if = "crate::moderation::References::is_empty")] + pub references: crate::moderation::References, } impl SavedDraft { @@ -305,6 +308,35 @@ impl SavedDraft { } } +#[cfg(test)] +mod reference_tests { + use super::*; + + #[test] + fn saved_target_provenance_survives_excerpt_truncation_and_legacy_drafts_parse() { + let legacy = + SavedDraft::parse(r#"{"text":"","reply":{"hash":"0x01","fid":42,"username":"alice","excerpt":"short"}}"#); + assert!(legacy.reply.unwrap().references.is_empty()); + let mut references = crate::moderation::References::default(); + references.add_fid(42); + references.add_fid(99); + let draft = SavedDraft { + text: "My editable draft".into(), + reply: Some(SavedCast { + hash: "0x01".into(), + fid: 42, + username: "alice".into(), + excerpt: "short".into(), + references, + }), + ..SavedDraft::default() + }; + let restored = SavedDraft::parse(&draft.to_json()); + assert_eq!(restored, draft); + assert_eq!(restored.reply.unwrap().references.fids, [42, 99]); + } +} + /// How long until a scheduled cast goes out, `seconds` from now: "in 5 /// min", "in 3 h", "in 2 days"; or "now" once its time has come. pub fn until(seconds: i64) -> String { @@ -432,7 +464,13 @@ mod tests { width: 200, height: 150, }), - reply: Some(SavedCast { hash: "0x01".into(), fid: 42, username: "alice".into(), excerpt: "Pick".into() }), + reply: Some(SavedCast { + hash: "0x01".into(), + fid: 42, + username: "alice".into(), + excerpt: "Pick".into(), + ..SavedCast::default() + }), ..SavedDraft::default() }; assert_eq!(SavedDraft::parse(&draft.to_json()), draft); diff --git a/crates/allthing-core/src/thread.rs b/crates/allthing-core/src/thread.rs index 0aae9ea5..edd2e6d9 100644 --- a/crates/allthing-core/src/thread.rs +++ b/crates/allthing-core/src/thread.rs @@ -157,6 +157,7 @@ mod tests { recasts: 0, replies: 0, mini_app: false, + references: Default::default(), spam: false, }, children, diff --git a/crates/allthing-data/src/miniapps.rs b/crates/allthing-data/src/miniapps.rs index 7410c84b..070bb66e 100644 --- a/crates/allthing-data/src/miniapps.rs +++ b/crates/allthing-data/src/miniapps.rs @@ -156,6 +156,7 @@ mod tests { recasts: 0, replies: 0, mini_app: false, + references: Default::default(), spam: false, } } diff --git a/crates/allthing-data/src/pages.rs b/crates/allthing-data/src/pages.rs index b2764e59..91f99c12 100644 --- a/crates/allthing-data/src/pages.rs +++ b/crates/allthing-data/src/pages.rs @@ -1724,6 +1724,11 @@ pub async fn notifications( } events.retain(|event| !is_cast(event.kind) || shown_cast(event).is_some_and(|cast| !cast.hidden())); + for event in &mut events { + if let Some(cast) = event.cast.as_mut() { + cast.references = allthing_core::moderation::References::with_quotes(cast, ¬es.quotes); + } + } let groups = notifications::group(events, fid); // The profile cards of the people shown, from the user objects that came // with them: no request. @@ -2523,6 +2528,7 @@ mod quote_tests { recasts: 0, replies: 0, mini_app: false, + references: Default::default(), spam: false, } } @@ -3869,6 +3875,7 @@ mod reaction_tests { recasts: 0, replies, mini_app: false, + references: Default::default(), spam: false, } } diff --git a/crates/allthing-web/src/components/account_controls.rs b/crates/allthing-web/src/components/account_controls.rs new file mode 100644 index 00000000..73303f5f --- /dev/null +++ b/crates/allthing-web/src/components/account_controls.rs @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +//! Private account hiding, with the same controls on profiles and hover cards. +//! Preferences belong to the signed-in reader, never to a Farcaster write key. + +use leptos::prelude::*; + +use crate::components::popover::Popover; +use crate::moderation::{SyncStatus, use_moderation}; + +const MUTE_WHAT: &str = "Hide posts and notifications involving this account in Allthing, including identifiable mentions, quotes, replies and account links. Private; synced across signed-in sessions."; +const BLOCK_WHAT: &str = "Hide the same content as Mute and stop new Allthing direct messages in both directions. Private; synced across signed-in sessions. Other Farcaster clients are unaffected."; + +/// More than a native title: mouse hover, keyboard focus and an explicit +/// touch press all open this explanation. A separate help control never +/// changes the preference merely to reveal what the preference does. +#[component] +fn AccountHelp() -> impl IntoView { + view! { +