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
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,24 @@ design.
sibling JSON file `outbound_dms.json` in the riverctl data dir
(consistent with `rooms.json`'s plaintext-on-disk threat model
— full-disk encryption is the user's responsibility).
- Phase 6 (PR #265, issue #261) added **hide-stale-DM-threads** —
a local-only view filter that lets the user dismiss a DM thread
from the left rail. Storage piggybacks **the same**
`OUTBOUND_DMS_STORAGE_KEY = b"outbound_dms"` blob — `OutboundDmStore`
grew a `hidden_threads: Vec<HiddenDmThreadEntry>` field with
`#[serde(default)]` so pre-#261 bytes still decode. **Do not add a
second top-level delegate storage key for hide state**: a new key
would need its own probe in `fire_legacy_migration_request` and its
own routing in `response_handler.rs` (per the legacy-migration note
above), AND would split the multi-device save path into two writes
that can race. The decision rationale lives on the Phase 5 prune
path's "we only act on purge envelopes" comment in
`chat_delegate::prune_outbound_dms_for_purges`. Filter helper
`chat_delegate::is_thread_hidden` uses strict `<=`; the rail-side
pure helper `dm_rail_section::filter_rail_entries` is pinned by
`filter_rail_entries_*` tests, and the "click Hide again after
revival must re-hide" branch is pinned by
`hide_unhide_rehide_round_trip`.

## Private Room Support
- Messages, metadata, and member nicknames are encrypted with AES-256-GCM.
Expand Down
2 changes: 1 addition & 1 deletion cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "riverctl"
version = "0.1.56"
version = "0.1.57"
edition = "2021"
authors = ["Freenet Project"]
description = "Command-line interface for River decentralized chat on Freenet"
Expand Down
Binary file modified cli/contracts/room_contract.wasm
Binary file not shown.
10 changes: 8 additions & 2 deletions cli/src/commands/dm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,10 @@ mod tests {
let sender = MemberId(FastHash(11));
let recipient = MemberId(FastHash(22));

let mut store = OutboundDmStore { entries: vec![] };
let mut store = OutboundDmStore {
entries: vec![],
hidden_threads: vec![],
};
let over_cap = MAX_DM_MESSAGES_PER_PAIR + 5;
for i in 0..over_cap {
store.entries.push(OutboundDmEntry {
Expand Down Expand Up @@ -923,7 +926,10 @@ mod tests {
let alice = MemberId(FastHash(11));
let bob = MemberId(FastHash(22));

let mut store = OutboundDmStore { entries: vec![] };
let mut store = OutboundDmStore {
entries: vec![],
hidden_threads: vec![],
};
// Fill (me -> alice) to over-cap; (me -> bob) only one entry.
for i in 0..MAX_DM_MESSAGES_PER_PAIR + 3 {
store.entries.push(OutboundDmEntry {
Expand Down
234 changes: 234 additions & 0 deletions common/src/chat_delegate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,50 @@ pub const OUTBOUND_DMS_STORAGE_KEY: &[u8] = b"outbound_dms";
/// `freenet/.claude/rules/bug-prevention-patterns.md`. Lookups are
/// linear, which is fine: the store is bounded by per-pair caps
/// (`MAX_DM_MESSAGES_PER_PAIR`) and pruned on purge tombstones.
///
/// Piggybacks the `hidden_threads` list (issue freenet/river#261) — a
/// purely local "hide this DM thread from my left rail until a fresh
/// message arrives" view filter. We pack it into the same delegate
/// blob so a single chat-delegate fetch hydrates both, and so a hide
/// on device A is visible on device B without a second storage key.
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OutboundDmStore {
#[serde(default)]
pub entries: Vec<OutboundDmEntry>,
/// Per-`(room, peer)` "hidden-at" cutoff timestamps. Filter rule:
/// a thread is hidden iff `hidden_at_ts >= max(message.timestamp)`
/// for messages between the local user and `peer` in that room.
/// `#[serde(default)]` so pre-#261 wire bytes (a `Vec<entries>`-only
/// `OutboundDmStore`) keep decoding into an empty `hidden_threads`.
#[serde(default)]
pub hidden_threads: Vec<HiddenDmThreadEntry>,
}

/// A single user-driven "hide this DM thread until further notice" entry.
///
/// `Vec`-of-struct rather than `HashMap` for the same reason as
/// [`OutboundDmStore::entries`] — JSON object keys must serialize as
/// strings (see "Non-string map keys in JSON-serialized API types" in
/// `freenet/.claude/rules/bug-prevention-patterns.md`), and the
/// `(VerifyingKey, MemberId)` lookup tuple does not. The local UI hot
/// path materialises this list into a HashMap for O(1) render-time
/// lookup — see `OutboundDmsCache` in the river-ui crate.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HiddenDmThreadEntry {
/// Room owner verifying key — disambiguates the same peer being a
/// member of multiple rooms. Raw 32 bytes to match the `RoomKey`
/// convention used elsewhere in this module and to keep the type
/// JSON-friendly.
pub room_owner_vk: [u8; 32],
/// Counterparty in the DM thread.
pub peer: MemberId,
/// Unix seconds at the moment the user clicked "Hide thread".
/// Captured from the most-recent message timestamp in the thread at
/// that moment (or `now()` if the thread had no messages yet — an
/// edge case that can happen if the user composes-and-hides from
/// the picker without ever sending) so any subsequent message
/// strictly later than this revives the thread.
pub hidden_at_ts: u64,
}

/// A single outbound DM the local user composed and sent.
Expand Down Expand Up @@ -218,6 +258,32 @@ pub enum ChatDelegateResponseMsg {
},
}

/// Pure helper: should a DM thread for `(room, peer)` currently be
/// hidden from the left rail?
///
/// Returns `true` iff the user has a `HiddenDmThreadEntry` for the
/// thread AND no message in the thread has `timestamp > hidden_at_ts`.
/// The strict `>` (not `>=`) on `max_message_ts` ensures that the
/// message used to populate `hidden_at_ts` does not itself revive the
/// thread. Any newer DM (inbound or outbound) crosses the threshold
/// and revives.
///
/// `hidden_threads` is the full slice as loaded from the delegate;
/// the lookup is linear because the list is tiny (bounded by the
/// number of distinct DM pairs the user has actually hidden, which
/// in practice is well under a hundred). Issue freenet/river#261.
pub fn is_thread_hidden(
hidden_threads: &[HiddenDmThreadEntry],
room_owner_vk: &[u8; 32],
peer: MemberId,
max_message_ts: u64,
) -> bool {
hidden_threads
.iter()
.find(|h| &h.room_owner_vk == room_owner_vk && h.peer == peer)
.is_some_and(|h| max_message_ts <= h.hidden_at_ts)
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -234,6 +300,14 @@ mod tests {
}
}

fn sample_hidden() -> HiddenDmThreadEntry {
HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer: MemberId(FastHash(0x1234_5678)),
hidden_at_ts: 1_700_000_000,
}
}

/// Per the "Non-string map keys in JSON-serialized API types" rule
/// in `freenet/.claude/rules/bug-prevention-patterns.md`, any
/// wire-boundary type stored in the delegate that may eventually be
Expand All @@ -244,6 +318,7 @@ mod tests {
fn outbound_dm_store_json_round_trips() {
let store = OutboundDmStore {
entries: vec![sample_entry()],
hidden_threads: vec![],
};
let json = serde_json::to_string(&store).expect("serialize JSON");
let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
Expand All @@ -256,6 +331,7 @@ mod tests {
fn outbound_dm_store_cbor_round_trips() {
let store = OutboundDmStore {
entries: vec![sample_entry(), sample_entry()],
hidden_threads: vec![],
};
let mut buf = Vec::new();
ciborium::ser::into_writer(&store, &mut buf).expect("serialize CBOR");
Expand All @@ -274,4 +350,162 @@ mod tests {
let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
assert_eq!(parsed, store);
}

/// Issue freenet/river#261 — `hidden_threads` is now part of the
/// stored blob. JSON round-trip pins the load-bearing wire shape
/// (Vec of struct, not HashMap) per the "non-string map keys"
/// bug-prevention pattern.
#[test]
fn outbound_dm_store_with_hidden_threads_json_round_trips() {
let store = OutboundDmStore {
entries: vec![sample_entry()],
hidden_threads: vec![sample_hidden()],
};
let json = serde_json::to_string(&store).expect("serialize JSON");
let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
assert_eq!(parsed, store);
}

/// CBOR is the on-the-wire encoding used by the chat delegate, so
/// `hidden_threads` must also CBOR round-trip.
#[test]
fn outbound_dm_store_with_hidden_threads_cbor_round_trips() {
let store = OutboundDmStore {
entries: vec![],
hidden_threads: vec![sample_hidden(), sample_hidden()],
};
let mut buf = Vec::new();
ciborium::ser::into_writer(&store, &mut buf).expect("serialize CBOR");
let parsed: OutboundDmStore =
ciborium::de::from_reader(buf.as_slice()).expect("parse CBOR");
assert_eq!(parsed, store);
}

/// Issue freenet/river#261 BACKWARDS COMPAT: pre-#261 delegate
/// blobs serialized BEFORE `hidden_threads` existed must still
/// decode into an `OutboundDmStore` with an empty `hidden_threads`
/// (via `#[serde(default)]`). Without this, the first reload
/// after upgrading River would fail to hydrate the outbound-DM
/// cache for every user whose delegate already has the #256 blob.
///
/// We pin both JSON and CBOR: JSON via a hand-written legacy
/// payload (the shape `serde_json::to_string` would have produced
/// before this PR), and CBOR by serializing a synthetic
/// "legacy" store that contains only the `entries` field via the
/// same path the delegate writes.
#[test]
fn outbound_dm_store_decodes_legacy_json_without_hidden_threads() {
let legacy_json = r#"{"entries":[]}"#;
let parsed: OutboundDmStore =
serde_json::from_str(legacy_json).expect("legacy JSON must decode");
assert!(parsed.entries.is_empty());
assert!(parsed.hidden_threads.is_empty());
}

#[test]
fn outbound_dm_store_decodes_legacy_cbor_without_hidden_threads() {
// Simulate a pre-#261 OutboundDmStore wire shape by hand-rolling
// a CBOR map with only the `entries` key. `ciborium` writes
// structs as definite-length maps keyed by field name, so we
// reproduce that here:
// { "entries": [ <one OutboundDmEntry> ] }
#[derive(Serialize)]
struct LegacyStore {
entries: Vec<OutboundDmEntry>,
}
let legacy = LegacyStore {
entries: vec![sample_entry()],
};
let mut buf = Vec::new();
ciborium::ser::into_writer(&legacy, &mut buf).expect("serialize legacy CBOR");

let parsed: OutboundDmStore =
ciborium::de::from_reader(buf.as_slice()).expect("legacy CBOR must decode");
assert_eq!(parsed.entries.len(), 1);
assert!(parsed.hidden_threads.is_empty());
}

/// `is_thread_hidden` returns false on an empty hidden list. This
/// is the common-case fast-path for users who have never hidden a
/// thread.
#[test]
fn is_thread_hidden_returns_false_for_empty_list() {
let peer = MemberId(FastHash(0x42));
assert!(!is_thread_hidden(&[], &[0u8; 32], peer, 0));
assert!(!is_thread_hidden(&[], &[0u8; 32], peer, 1_000));
}

/// `is_thread_hidden` returns true when the only message in the
/// thread is the one whose timestamp was captured as
/// `hidden_at_ts`. The strict `>` rule means equal-timestamp does
/// NOT revive — otherwise hiding a thread whose most-recent message
/// is exactly `now()` would instantly fail to hide.
#[test]
fn is_thread_hidden_equal_timestamp_stays_hidden() {
let peer = MemberId(FastHash(0x42));
let hidden = vec![HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer,
hidden_at_ts: 1_000,
}];
assert!(is_thread_hidden(&hidden, &[9u8; 32], peer, 1_000));
}

/// Any message strictly later than `hidden_at_ts` must revive the
/// thread.
#[test]
fn is_thread_hidden_strictly_later_message_revives() {
let peer = MemberId(FastHash(0x42));
let hidden = vec![HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer,
hidden_at_ts: 1_000,
}];
assert!(!is_thread_hidden(&hidden, &[9u8; 32], peer, 1_001));
}

/// A `HiddenDmThreadEntry` for the same peer in a DIFFERENT room
/// must NOT hide the thread in the current room. The lookup is
/// `(room, peer)`, not just `peer`.
#[test]
fn is_thread_hidden_is_scoped_per_room() {
let peer = MemberId(FastHash(0x42));
let hidden = vec![HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer,
hidden_at_ts: 1_000,
}];
// Different room — must be visible.
assert!(!is_thread_hidden(&hidden, &[7u8; 32], peer, 500));
}

/// A `HiddenDmThreadEntry` for a DIFFERENT peer in the same room
/// must NOT hide the thread.
#[test]
fn is_thread_hidden_is_scoped_per_peer() {
let peer_a = MemberId(FastHash(0x42));
let peer_b = MemberId(FastHash(0x99));
let hidden = vec![HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer: peer_a,
hidden_at_ts: 1_000,
}];
assert!(!is_thread_hidden(&hidden, &[9u8; 32], peer_b, 500));
}

/// Thread with no messages at all (max_message_ts = 0) and a
/// `hidden_at_ts` of 0 stays hidden — the strict `<=` rule still
/// applies. This matches the design intent: a freshly hidden
/// empty thread should stay hidden until either party sends a
/// (necessarily later, since unix ts > 0) message.
#[test]
fn is_thread_hidden_zero_max_zero_hidden_stays_hidden() {
let peer = MemberId(FastHash(0x42));
let hidden = vec![HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer,
hidden_at_ts: 0,
}];
assert!(is_thread_hidden(&hidden, &[9u8; 32], peer, 0));
}
}
7 changes: 7 additions & 0 deletions legacy_delegates.toml
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,10 @@ description = "Before #256: outbound_dms storage key + OutboundDmStore types add
date = "2026-05-16"
delegate_key = "11db7236c2211dd54ab6853826915464ce429a7a044cbf2813aa5a3d74ba5b1f"
code_hash = "6229c2a136b30a7868c07099efdea86bee260734339d872aa1e68177733b5fdd"

[[entry]]
version = "V19"
description = "Before #261: HiddenDmThreadsV1 added to OutboundDmStore for hide-stale-DM-threads feature"
date = "2026-05-16"
delegate_key = "497b522ac5c1f0a6234f832d7f593ab78c2b7f91136607400e9d1839509e6839"
code_hash = "47e54c906561d394cf473e89180350ea9e4bc6e75091e1debcf7306e5b971c80"
Binary file modified ui/public/contracts/chat_delegate.wasm
Binary file not shown.
Binary file modified ui/public/contracts/room_contract.wasm
Binary file not shown.
Loading
Loading