This commit is contained in:
2026-09-22 16:03:03 +07:00
parent 28f4c1596d
commit 6fac58d494
7 changed files with 774 additions and 117 deletions
+217 -21
View File
@@ -1,5 +1,6 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use anyhow::Result;
use concord::cord02::{ControlFold, ImageRef};
@@ -10,6 +11,7 @@ use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, Held
use concord::{ChannelId, CommunityId, Epoch};
use gpui::{App, AppContext, Context, EventEmitter, Task};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use crate::cache;
@@ -25,9 +27,12 @@ const CATCH_UP_PAGES: usize = 20;
pub const LOAD_OLDER_PAGES: usize = 6;
/// Rows one timeline read returns before the caller asks for more.
pub const TIMELINE_PAGE: usize = 100;
/// Side events read per row, so a reaction flood cannot displace the rows it
/// decorates.
/// Side events read per row, so a reaction flood cannot displace the rows it decorates.
const SIDE_EVENT_FACTOR: usize = 4;
/// The shortest gap between two automatic catch-up rounds for one channel.
pub const MIN_ROUND_INTERVAL: Duration = Duration::from_secs(30);
/// How long a channel may go unsynced before the scheduler repairs it.
const STALE_AFTER: Duration = Duration::from_secs(300);
/// Which direction a sync round reads.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -41,6 +46,7 @@ pub enum Intent {
pub struct Progress {
pub fetched: usize,
pub opened: usize,
pub unreadable: usize,
pub exhausted: bool,
pub failed: bool,
pub errors: usize,
@@ -102,6 +108,10 @@ pub enum CommunityEvent {
Open(CommunityId),
Close(CommunityId),
Channel(CommunityId, ChannelId),
/// History exists here that no held key can open.
Unreadable(CommunityId),
/// The last round could not reach the community's relays.
Failed(CommunityId),
Error(String),
}
@@ -119,9 +129,17 @@ pub struct Community {
icon_task: Option<Task<Result<()>>>,
banner_task: Option<Task<Result<()>>>,
rounds: HashMap<ChannelId, Round>,
/// The last completed round per channel, for the panel's honest states.
progress: HashMap<ChannelId, Progress>,
/// Wraps held per channel that no key we hold can open.
unreadable: BTreeMap<ChannelId, usize>,
/// When each channel's last round started, which paces the automatic ones.
last_round: HashMap<ChannelId, Instant>,
pages: PageRegistry,
rekey_task: Option<Task<Result<()>>>,
rekey_dirty: bool,
/// Spawned folds, round bookkeeping and publishes, cancelled on drop
tasks: SmallVec<[Task<Result<()>>; 2]>,
}
impl EventEmitter<CommunityEvent> for Community {}
@@ -142,9 +160,13 @@ impl Community {
icon_task: None,
banner_task: None,
rounds: HashMap::new(),
progress: HashMap::new(),
unreadable: BTreeMap::new(),
last_round: HashMap::new(),
pages,
rekey_task: None,
rekey_dirty: false,
tasks: smallvec![],
}
}
@@ -212,6 +234,44 @@ impl Community {
self.state.stranded
}
/// Wraps held here that no key we hold can open.
pub fn unreadable(&self, channel: &ChannelId) -> usize {
self.unreadable.get(channel).copied().unwrap_or(0)
}
/// The last round's outcome for `channel`, when one has run.
pub fn progress(&self, channel: &ChannelId) -> Option<Progress> {
self.progress.get(channel).copied()
}
/// The epoch a private channel's key is missing for, when we hold none.
pub fn missing_key(&self, channel: &ChannelId) -> Option<Epoch> {
self.state
.channels
.iter()
.find(|held| held.id == *channel && held.private && held.key.is_none())
.map(|held| held.epoch)
}
/// The epoch a channel rotation removed us at, when it removed us.
pub fn channel_removed_at(&self, channel: &ChannelId) -> Option<Epoch> {
self.state.channel_cuts.get(channel).map(|cut| Epoch(*cut))
}
/// Whether an automatic catch-up for `channel` is worth asking for yet.
pub fn due(&self, channel: &ChannelId) -> bool {
self.last_round
.get(channel)
.is_none_or(|at| at.elapsed() >= MIN_ROUND_INTERVAL)
}
/// Whether `channel` has been synced before and has since gone stale.
fn stale(&self, channel: &ChannelId) -> bool {
self.last_round
.get(channel)
.is_some_and(|at| at.elapsed() >= STALE_AFTER)
}
pub fn channels(&self) -> &[ChannelKeyRef] {
&self.state.channels
}
@@ -222,12 +282,20 @@ impl Community {
/// A public channel derives its write plane from the community root.
fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])> {
if self.state.removed_at.is_some() || self.state.stranded {
return None;
}
let held = self
.state
.channels
.iter()
.find(|held| held.id == *channel)?;
if self.state.channel_cut(channel, held.epoch) {
return None;
}
held.current()
.or_else(|| (!held.private).then_some((held.epoch, self.state.community_root)))
}
@@ -296,18 +364,23 @@ impl Community {
round.queued = None;
}
self.last_round.insert(channel, Instant::now());
let round = cx.background_spawn(async move {
sync_round(&client, &pages, &channel, &held, &relays, saved, intent).await
});
cx.spawn(async move |this, cx| {
let finisher = cx.spawn(async move |this, cx| {
let result = round.await;
if let Err(error) = this.update(cx, |this, cx| this.finish_round(channel, result, cx)) {
log::warn!("community: a channel round outlived its community: {error}");
}
})
.detach();
Ok(())
});
self.tasks.push(finisher);
}
fn finish_round(
@@ -342,20 +415,56 @@ impl Community {
self.start_round(channel, intent, cx);
}
if let Ok(progress) = &outcome
&& progress.failed
&& progress.errors > 0
&& progress.fetched == 0
{
cx.emit(CommunityEvent::Error(format!(
"could not reach {} of the community's relays",
progress.errors
)));
if let Ok(progress) = &outcome {
self.progress.insert(channel, *progress);
self.record_unreadable(channel, progress.unreadable);
if progress.failed && progress.errors > 0 {
cx.emit(CommunityEvent::Failed(self.state.id));
}
if progress.unreadable > 0 {
cx.emit(CommunityEvent::Unreadable(self.state.id));
}
}
cx.emit(CommunityEvent::Updated(self.state.id));
}
/// Remember the wraps no held key could open.
fn record_unreadable(&mut self, channel: ChannelId, count: usize) {
if count == 0 {
return;
}
let seen = self.unreadable.entry(channel).or_default();
*seen = (*seen).max(count);
}
pub(crate) fn tick(&mut self, cx: &mut Context<Self>) {
let Some(channel) = self.active_channel() else {
return;
};
if !self.stale(&channel) {
return;
}
self.refresh(cx);
let catch_up = self.sync_channel(&channel, Intent::CatchUp, cx);
let task = cx.spawn(async move |_this, _cx| {
if let Err(error) = catch_up.await {
log::warn!("community: the scheduler's catch-up failed: {error}");
}
Ok(())
});
self.tasks.push(task);
}
/// Fold a round's cursor findings in, monotonically, and persist them.
fn merge_cursor(&mut self, channel: ChannelId, cursor: ChannelCursor, cx: &mut Context<Self>) {
let held = self
@@ -375,16 +484,19 @@ impl Community {
}
/// Write the local state document out.
fn persist(&self, cx: &Context<Self>) {
fn persist(&mut self, cx: &Context<Self>) {
let client = NostrRegistry::global(cx).read(cx).client();
let state = self.state.clone();
cx.background_spawn(async move {
let task = cx.background_spawn(async move {
if let Err(error) = cache::save_state(&client, &state).await {
log::warn!("community: failed to persist the local state: {error}");
}
})
.detach();
Ok(())
});
self.tasks.push(task);
}
/// The channel's timeline, folded from the local cache, oldest first.
@@ -647,12 +759,15 @@ impl Community {
let catch_up = self.sync_channel(&channel, Intent::CatchUp, cx);
cx.spawn(async move |_this, _cx| {
let task = cx.spawn(async move |_this, _cx| {
if let Err(error) = catch_up.await {
log::warn!("community: the catch-up after a rekey failed: {error}");
}
})
.detach();
Ok(())
});
self.tasks.push(task);
}
/// Rebuilds the community from the wraps in the local database.
@@ -686,6 +801,17 @@ impl Community {
self.control = snapshot.control;
self.members = snapshot.members;
self.load_images(cx);
let reported = !snapshot.unreadable.is_empty();
for (channel, count) in snapshot.unreadable {
self.record_unreadable(channel, count);
}
if reported {
cx.emit(CommunityEvent::Unreadable(self.state.id));
}
cx.emit(CommunityEvent::Updated(self.state.id));
cx.notify();
}
@@ -886,7 +1012,77 @@ async fn sync_round(
fn absorb(progress: &mut Progress, page: &WrapPage) {
progress.fetched += page.raw;
progress.opened += page.opened.len();
progress.unreadable += page.unreadable;
progress.exhausted |= page.exhausted;
progress.failed |= page.failed;
progress.errors += page.errors;
}
#[cfg(test)]
mod tests {
use super::*;
/// One public channel under a held root, and nothing else.
fn state(channel: ChannelId) -> CommunityState {
CommunityState {
id: CommunityId::from_bytes([0x42; 32]),
name: None,
owner: Keys::generate().public_key(),
owner_salt: [0x01; 32],
community_root: [0x02; 32],
root_epoch: Epoch(0),
control_root: None,
control_pks: BTreeMap::new(),
channels: vec![ChannelKeyRef {
id: channel,
name: "general".to_owned(),
private: false,
epoch: Epoch(0),
key: None,
priors: Vec::new(),
}],
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms: 0,
}
}
fn community(state: CommunityState) -> Community {
Community::new(state, PageRegistry::default())
}
/// A rotation that excluded us leaves no key to write under: a wrap sealed
/// with the retired root would reach nobody who rotated with it.
#[test]
fn a_removed_member_holds_no_write_key() {
let channel = ChannelId::from_bytes([0x9c; 32]);
// A public channel writes from the community root while we hold it.
assert_eq!(
community(state(channel)).channel_secret(&channel),
Some((Epoch(0), [0x02; 32]))
);
// A base removal closes the community...
let mut removed = state(channel);
removed.removed_at = Some(Epoch(1));
assert_eq!(community(removed).channel_secret(&channel), None);
// ...a strand closes it too, because the rotation moved past our epoch...
let mut stranded = state(channel);
stranded.stranded = true;
assert_eq!(community(stranded).channel_secret(&channel), None);
// ...and a channel cut closes the one channel.
let mut cut = state(channel);
cut.channel_cuts.insert(channel, 0);
assert_eq!(community(cut).channel_secret(&channel), None);
}
}
+69 -11
View File
@@ -4,8 +4,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use anyhow::Result;
use concord::cord01::KIND_WRAP_EPHEMERAL;
use anyhow::{Result, bail};
use concord::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
use concord::cord03::{self, ChatRumor};
use concord::derive::channel_group_key;
use concord::state::{ChannelCursor, HeldKey};
@@ -137,6 +137,8 @@ impl PageRegistry {
pub struct WrapPage {
pub opened: Vec<ChatRumor>,
pub raw: usize,
/// Wraps that reached us under a held plane but that no held key could open.
pub unreadable: usize,
pub newest_ms: Option<u64>,
pub oldest_ms: Option<u64>,
pub exhausted: bool,
@@ -210,15 +212,8 @@ pub async fn page(
continue;
};
// A retired key reads only what was sealed before its rotation published.
if held
.retired_at
.is_some_and(|retired| wrap.created_at.as_secs() > retired)
{
continue;
}
let Ok((stream, rumor)) = cord03::open(&wrap, group, channel, held.epoch) else {
let Ok((stream, rumor)) = read_under(&wrap, held, group, channel) else {
walk.unreadable += 1;
continue;
};
@@ -231,6 +226,23 @@ pub async fn page(
Ok(walk.finish(opened))
}
/// Opens one wrap under a held key.
fn read_under(
wrap: &Event,
held: &HeldKey,
group: &GroupKey,
channel: &ChannelId,
) -> Result<(OpenedStream, ChatRumor)> {
if held
.retired_at
.is_some_and(|retired| wrap.created_at.as_secs() > retired)
{
bail!("sealed after the key that reads it was retired");
}
Ok(cord03::open(wrap, group, channel, held.epoch)?)
}
/// The one filter a page is asked for.
fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter {
let mut filter = Filter::new()
@@ -369,6 +381,8 @@ struct Walk {
oldest_ms: Option<u64>,
raw: usize,
errors: usize,
/// Wraps the caller could not read under any held key.
unreadable: usize,
/// A short page ended the walk.
bottom: bool,
}
@@ -395,6 +409,7 @@ impl Walk {
oldest_ms: None,
raw: 0,
errors: 0,
unreadable: 0,
bottom: false,
}
}
@@ -458,6 +473,7 @@ impl Walk {
WrapPage {
opened,
raw: self.raw,
unreadable: self.unreadable,
newest_ms: self.newest_ms,
oldest_ms: self.oldest_ms,
exhausted: swept && self.raw > 0,
@@ -586,6 +602,48 @@ mod tests {
assert_eq!(page.raw, 3);
}
/// A wrap that reaches us and still will not open is history we cannot read,
/// not history that does not exist.
#[test]
fn a_wrap_no_held_key_can_open_reads_as_unreadable() {
let channel = ChannelId::from_bytes([0x9cu8; 32]);
let other = ChannelId::from_bytes([0x9du8; 32]);
let author = Keys::generate();
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
let held = HeldKey {
epoch: Epoch(0),
key: SECRET,
retired_at: Some(1_000),
};
let wrap_at = |channel: &ChannelId, at_ms: u64| {
let rumor = build_message(
author.public_key(),
channel,
Epoch(0),
"sealed",
None,
at_ms,
None,
);
smol::block_on(seal_rumor(&rumor, &group, &author, false))
.expect("seals")
.0
};
// Sealed before the rotation superseded this key, so it still reads.
let before = wrap_at(&channel, 999_000);
assert!(read_under(&before, &held, &group, &channel).is_ok());
// Sealed after the cutoff the rotation set on that key.
let after = wrap_at(&channel, 1_001_000);
assert!(read_under(&after, &held, &group, &channel).is_err());
// Sealed to this plane but bound to another channel.
let misbound = wrap_at(&other, 999_000);
assert!(read_under(&misbound, &held, &group, &channel).is_err());
}
#[test]
fn an_empty_answer_never_seals_the_channel() {
let database: BTreeSet<Event> = BTreeSet::new();
+29 -1
View File
@@ -5,7 +5,7 @@ use anyhow::Result;
pub use concord::cord02::CommunityMetadata;
pub use concord::cord03::{ChatMessage, ReplyRef};
use concord::state::CommunityState;
pub use concord::{ChannelId, CommunityId};
pub use concord::{ChannelId, CommunityId, Epoch};
use futures::future::{Either, select};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
use nostr_sdk::prelude::*;
@@ -152,6 +152,8 @@ pub struct CommunityRegistry {
notification_listener: Option<Task<Result<()>>>,
/// Signal consumer task (cancelled on signer change)
signal_consumer: Option<Task<Result<()>>>,
/// The round scheduler (cancelled on signer change)
scheduler: Option<Task<Result<()>>>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
@@ -203,6 +205,7 @@ impl CommunityRegistry {
tasks: smallvec![],
notification_listener: None,
signal_consumer: None,
scheduler: None,
_subscriptions: subscriptions,
}
}
@@ -272,6 +275,7 @@ impl CommunityRegistry {
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.notification_listener = None;
self.signal_consumer = None;
self.scheduler = None;
self.tasks.clear();
self.observers.clear();
self.pages.clear();
@@ -424,6 +428,13 @@ impl CommunityRegistry {
community.update(cx, |community, cx| community.rekey(cx));
}
/// One scheduler pass: every community repairs itself if it has gone stale.
fn tick(&mut self, cx: &mut Context<Self>) {
for community in self.communities.clone() {
community.update(cx, |community, cx| community.tick(cx));
}
}
/// Re-subscribe every community whose held planes moved.
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -491,6 +502,7 @@ impl CommunityRegistry {
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
self.notification_listener = None;
self.signal_consumer = None;
self.scheduler = None;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -558,6 +570,22 @@ impl CommunityRegistry {
}
Ok(())
}));
self.scheduler = Some(cx.spawn(async move |this, cx| {
loop {
cx.background_executor().timer(MIN_ROUND_INTERVAL).await;
if let Some(registry) = this.upgrade() {
registry.update(cx, |this, cx| {
this.tick(cx);
});
} else {
break;
}
}
Ok(())
}));
}
}
+60 -6
View File
@@ -172,6 +172,8 @@ pub struct Snapshot {
pub state: CommunityState,
pub control: ControlFold,
pub members: BTreeSet<PublicKey>,
/// Wraps the store holds per channel that no held key can open.
pub unreadable: BTreeMap<ChannelId, usize>,
}
pub async fn create<S>(
@@ -529,6 +531,7 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
let mut editions = Vec::new();
let mut observed: BTreeMap<PublicKey, u64> = BTreeMap::new();
let mut guestbook_rumors = Vec::new();
let mut unreadable: BTreeMap<ChannelId, usize> = BTreeMap::new();
let mut cached: BTreeMap<ChannelId, BTreeMap<EventId, Observed>> = BTreeMap::new();
for plane in &planes {
@@ -544,19 +547,23 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
continue;
};
// A retired key reads only what was sealed before its rotation published.
match plane.kind {
PlaneKind::Control(_) => {
// A retired root reads only what was sealed before its rotation.
if !plane.accepts(wrap) {
continue;
}
match plane.kind {
PlaneKind::Control(_) => {
if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true)
{
editions.push(edition);
}
}
PlaneKind::Guestbook => {
if !plane.accepts(wrap) {
continue;
}
if let Ok((_, rumor)) = cord02::guestbook::open(wrap, &plane.group) {
observe(&mut observed, rumor.author, rumor.at_ms);
guestbook_rumors.push(rumor);
@@ -568,12 +575,19 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
continue;
}
if let Ok((opened, rumor)) =
concord::cord03::open(wrap, &plane.group, &channel, epoch)
{
let opened = if plane.accepts(wrap) {
concord::cord03::open(wrap, &plane.group, &channel, epoch).ok()
} else {
None
};
match opened {
Some((opened, rumor)) => {
cache::cache_rumor(client, &channel, &opened).await?;
observe(&mut observed, rumor.author, rumor.at_ms);
}
None => *unreadable.entry(channel).or_default() += 1,
}
}
}
}
@@ -627,6 +641,7 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
state,
control,
members,
unreadable,
}))
}
@@ -1137,4 +1152,43 @@ mod tests {
assert!(retired.accepts(&event_at(1_000_000)));
assert!(!retired.accepts(&event_at(1_001_000)));
}
/// A wrap addressed to a held channel plane that will not open is counted, so
/// the panel can tell a quiet room from one whose history it cannot read.
#[test]
fn a_fold_counts_the_channel_wraps_it_cannot_open() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let created = create(&client, &signer, &metadata("coop"))
.await
.expect("creates");
let (channel, plane) = planes(&created)
.expect("planes")
.into_iter()
.find_map(|plane| match plane.kind {
PlaneKind::Channel(channel, _) => Some((channel, plane)),
_ => None,
})
.expect("a channel plane");
// Sealed to the channel's own address, but not a seal at all, so no
// held key opens it.
let junk = EventBuilder::new(Kind::GiftWrap, "not a seal")
.custom_created_at(Timestamp::from_secs(1_700_000_000))
.finalize(plane.group.keys())
.expect("signs");
client.database().save_event(&junk).await.expect("saves");
let snapshot = fold(&client, &created)
.await
.expect("folds")
.expect("a control plane");
assert_eq!(snapshot.unreadable.get(&channel).copied(), Some(1));
});
}
}
+192 -25
View File
@@ -1,9 +1,10 @@
use std::collections::{HashMap, HashSet};
use std::fmt;
use anyhow::Result;
use community::{
ChannelId, ChatMessage, Community, CommunityEvent, Intent, LOAD_OLDER_PAGES, TIMELINE_PAGE,
Timeline,
ChannelId, ChatMessage, Community, CommunityEvent, Epoch, Intent, LOAD_OLDER_PAGES,
TIMELINE_PAGE, Timeline,
};
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -20,13 +21,66 @@ use ui::dock::{Panel, PanelEvent};
use ui::input::{InputEvent, Textarea, TextareaState};
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex};
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
mod message;
/// How near the top row a scroll has to come before the panel splices older history in.
const LOAD_OLDER_THRESHOLD: usize = 20;
/// Why a room is not showing the messages it knows about.
///
/// "No messages yet" is a claim, and a room we cannot read is not an empty one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Notice {
Stranded,
Removed(Epoch),
ChannelRemoved(Epoch),
MissingKey(Epoch),
Unreachable,
Unreadable(usize),
}
impl fmt::Display for Notice {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Notice::Stranded => formatter.write_str(
"This invite is stale, the community has rotated past the epoch it names",
),
Notice::Removed(epoch) => write!(
formatter,
"You were removed from this community at epoch {}. Its history stays readable",
epoch.0
),
Notice::ChannelRemoved(epoch) => write!(
formatter,
"A rotation removed you from this channel at epoch {}",
epoch.0
),
Notice::MissingKey(epoch) => write!(
formatter,
"Messages here can't be read yet — this channel's key for epoch {} is missing",
epoch.0
),
Notice::Unreachable => formatter.write_str("Couldn't reach the community's relays"),
Notice::Unreadable(1) => {
formatter.write_str("1 message here can't be read yet — no key we hold opens it")
}
Notice::Unreadable(count) => write!(
formatter,
"{count} messages here can't be read yet — no key we hold opens them"
),
}
}
}
impl Notice {
/// Whether the room still accepts writes, rather than only being readable.
fn writable(self) -> bool {
matches!(self, Notice::Unreachable | Notice::Unreadable(_))
}
}
pub fn init(
community: Entity<Community>,
window: &mut Window,
@@ -53,6 +107,8 @@ pub struct CommunityPanel {
list_state: ListState,
/// Message input state
input: Entity<TextareaState>,
/// Spawned reads and publishes, cancelled when the panel closes
tasks: SmallVec<[Task<Result<()>>; 4]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
@@ -89,21 +145,24 @@ impl CommunityPanel {
subscriptions.push(cx.subscribe_in(
&community,
window,
|_this, _community, event, window, cx| match event {
// The fold holds the community, and a round caches rows nothing
// has read yet, so re-read once either is released.
CommunityEvent::Updated(_) => {
|_this, _community, event, window, cx| {
match event {
CommunityEvent::Updated(_)
| CommunityEvent::Unreadable(_)
| CommunityEvent::Failed(_) => {
cx.defer_in(window, |this, window, cx| this.reload(window, cx));
}
// The sidebar picked another channel while the community was updating.
CommunityEvent::Channel(..) => {
cx.defer_in(window, |this, window, cx| this.load(window, cx));
}
CommunityEvent::Error(error) => {
window
.push_notification(Notification::error(error.clone()).autohide(false), cx);
window.push_notification(
Notification::error(error.clone()).autohide(false),
cx,
);
}
CommunityEvent::Open(_) | CommunityEvent::Close(_) => {}
};
},
));
@@ -117,6 +176,7 @@ impl CommunityPanel {
loading: false,
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
input,
tasks: smallvec![],
_subscriptions: subscriptions,
};
@@ -195,10 +255,58 @@ impl CommunityPanel {
};
self.reload(window, cx);
// Opening a channel twice in a breath asks the relays once.
if self.due(channel, cx) {
self.round(channel, Intent::CatchUp, window, cx);
}
}
/// Run a round for `channel`; its completion re-reads the timeline.
/// Whether the community would actually round `channel`, or just serve it.
fn due(&self, channel: ChannelId, cx: &App) -> bool {
self.community
.read_with(cx, |community, _cx| community.due(&channel))
.unwrap_or(true)
}
/// Why the room is not showing messages, when it is not simply empty.
fn notice(&self, cx: &App) -> Option<Notice> {
let channel = self.channel?;
self.community
.read_with(cx, |community, _cx| {
if community.stranded() {
return Some(Notice::Stranded);
}
if let Some(epoch) = community.removed_at() {
return Some(Notice::Removed(epoch));
}
if let Some(epoch) = community.channel_removed_at(&channel) {
return Some(Notice::ChannelRemoved(epoch));
}
if let Some(epoch) = community.missing_key(&channel) {
return Some(Notice::MissingKey(epoch));
}
if community
.progress(&channel)
.is_some_and(|progress| progress.failed && progress.errors > 0)
{
return Some(Notice::Unreachable);
}
let unreadable = community.unreadable(&channel);
(unreadable > 0).then_some(Notice::Unreadable(unreadable))
})
.ok()
.flatten()
}
/// Run a round for `channel`, its completion re-reads the timeline.
fn round(
&mut self,
channel: ChannelId,
@@ -211,8 +319,9 @@ impl CommunityPanel {
};
self.loading = true;
cx.notify();
cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
let result = round.await;
this.update_in(cx, |this, window, cx| {
@@ -227,8 +336,18 @@ impl CommunityPanel {
})?;
Ok(())
})
.detach();
});
self.tasks.push(task);
}
/// Run the newest round again after a relay failure.
fn retry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(channel) = self.channel else {
return;
};
self.round(channel, Intent::CatchUp, window, cx);
}
/// Read the newest page and fold it into what is on screen.
@@ -241,7 +360,7 @@ impl CommunityPanel {
return;
};
cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
match timeline.await {
Ok(timeline) => this.update(cx, |this, cx| this.apply(channel, timeline, cx))?,
Err(error) => {
@@ -255,8 +374,9 @@ impl CommunityPanel {
}
Ok(())
})
.detach();
});
self.tasks.push(task);
}
/// Splice the page of history above the oldest row on screen.
@@ -283,7 +403,7 @@ impl CommunityPanel {
self.loading = true;
cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
let timeline = match page.await {
Ok(timeline) => timeline,
Err(error) => {
@@ -356,8 +476,9 @@ impl CommunityPanel {
}
Ok(())
})
.detach();
});
self.tasks.push(task);
}
/// Fold a freshly read window into the rows on screen.
@@ -446,6 +567,14 @@ impl CommunityPanel {
return;
};
// A rotation that excluded us moved the write plane out of reach, and the
// community refuses to seal a message nobody could read. The notice above
// the list is what says why.
if let Some(notice) = self.notice(cx).filter(|notice| !notice.writable()) {
window.push_notification(Notification::error(notice.to_string()).autohide(false), cx);
return;
}
let Ok(send) = self.community.read_with(cx, |community, cx| {
community.send(&channel, &content, None, cx)
}) else {
@@ -461,7 +590,7 @@ impl CommunityPanel {
input.set_value("", window, cx);
});
cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
match send.await {
Ok(_) => {
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
@@ -477,8 +606,34 @@ impl CommunityPanel {
}
Ok(())
});
self.tasks.push(task);
}
/// The honest reason this room has nothing to show, with a way out of it.
fn render_notice(&self, notice: Notice, cx: &mut Context<Self>) -> AnyElement {
h_flex()
.w_full()
.justify_center()
.items_center()
.gap_2()
.px_3()
.py_2()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child(notice.to_string())
.when(notice == Notice::Unreachable, |this| {
this.child(
Button::new("retry-round")
.label("Retry")
.ghost()
.small()
.loading(self.loading)
.on_click(cx.listener(|this, _event, window, cx| this.retry(window, cx))),
)
})
.detach();
.into_any_element()
}
/// The row at index 0: the affordance that pages older history in.
@@ -524,6 +679,8 @@ impl CommunityPanel {
}
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
let writable = self.notice(cx).is_none_or(|notice| notice.writable());
h_flex()
.flex_shrink_0()
.w_full()
@@ -537,6 +694,7 @@ impl CommunityPanel {
.tooltip("Send")
.ghost()
.large()
.disabled(!writable)
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
})),
@@ -591,17 +749,26 @@ impl Render for CommunityPanel {
.min_h_0()
.relative()
.map(|this| {
let notice = self.notice(cx);
if self.rows.is_empty() {
this.child(
h_flex()
v_flex().size_full().justify_center().child(match notice {
Some(notice) => self.render_notice(notice, cx),
None => h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child("No messages yet"),
.child("No messages yet")
.into_any_element(),
}),
)
} else {
this.child(
this.when_some(notice, |this, notice| {
this.child(self.render_notice(notice, cx))
})
.child(
list(
self.list_state.clone(),
cx.processor(move |this, ix, window, cx| {
+166 -30
View File
@@ -37,7 +37,7 @@ wait, and it is reported rather than rendered as an empty channel.
Read path today (phase 1 landed the walk, phase 2a moved each layer, phase 2b
swapped the transport and put the pump in charge of settling pages, phase 3 added
the rekey watch and held epochs):
the rekey watch and held epochs, phase 4 made an empty or unreadable room say so):
```
CommunityPanel::load community_ui/src/lib.rs:192
@@ -72,6 +72,12 @@ live wire community/src/lib.rs:309 (sync_subscriptions)
back from the database,
adopts one epoch at a
time)
scheduler community/src/lib.rs (one tick per
-> Community::tick community/src/community.rs MIN_ROUND_INTERVAL;
a channel that has
passed STALE_AFTER
re-folds and re-rounds)
```
## What is wrong today
@@ -85,7 +91,7 @@ live wire community/src/lib.rs:309 (sync_subscriptions)
| 5 | **Private channels are never subscribed and never folded**: `planes()` skips `channel.private`, so a private channel gets no live REQ and no `cache_rumor` from the subscription. | fixed in phase 2b (`planes` derives a private channel's plane from the held key; it is subscribed, paged and folded like a public one) | `community/src/sync.rs` |
| 6 | The standing REQ asks for **kind 1059 only**, and the pump drops anything that is not 1059, so 21059 (ephemeral) wraps can never be routed even though the read path asks for both kinds. | fixed in phase 2b (`live_filter`/`plane_filter` ask for both kinds; the pump routes by subscription id and never inspects the kind) | `community/src/sync.rs`, `community/src/lib.rs` |
| 7 | A **rekey strands history**: `ChannelKeyRef` holds one epoch/key, `channel_secret` returns one plane, `sync::refresh` overwrites a held key in place, and the rekey pseudonyms are never watched. | fixed in phase 3 (`priors`/`held_roots` + `retired_at`, the rekey watch, and strict one-epoch-at-a-time adoption) | `community/src/rekey.rs`, `concord/src/state.rs` |
| 8 | **"No messages yet" is three different states**: unreadable wraps are dropped silently, a failed round is logged, and the panel renders all of them as an empty room. | open (phase 4; counts already exist in `Progress`) | `community/src/community.rs:38-44`, `community_ui/src/lib.rs:594-602` |
| 8 | **"No messages yet" is three different states**: unreadable wraps are dropped silently, a failed round is logged, and the panel renders all of them as an empty room. | fixed in phase 4 (`WrapPage`/`Progress`/`Snapshot` carry `unreadable`, the community exposes `progress`/`unreadable`/`missing_key`/`channel_removed_at`/`removed_at`/`stranded`, and the panel renders a reason plus a retry instead of an empty room) | `community/src/community.rs:46-53`, `community_ui/src/lib.rs:235` |
| 9 | A new message **replaced the whole timeline and forced `scroll_to_end()`**. | fixed in phase 1 (`FollowMode::Tail`, in-place merge) | `community_ui/src/lib.rs:121`, `:364-411` |
| 10 | Backfill fetched through `client.fetch_events(..)` with `ReqTarget::auto`, i.e. every relay in the pool. | fixed in phase 1 (relay-scoped, no `fetch_events` anywhere) | `community/src/community.rs:262` |
| 11 | The local cache document is authored by a **process-random key**, so the same rumor cached in two runs is a different event id and the store keeps both copies; `fold` then re-reads all of it on every inbound wrap. | fixed in phase 2a (one fixed cache key) | `community/src/cache.rs` |
@@ -515,19 +521,96 @@ Three deviations from the sketch above, all of them smaller than planned:
adoptable, so a member who missed several rotations catches up in one database
read instead of one pass per poll. The lookahead window is what feeds it.
Still deferred, and named here so it is not mistaken for landed: the panel does
not yet render `Community::removed_at()` / `Community::stranded()` (phase 4), and
a base removal is not enforced at send time — `channel_secret` still hands the
composer the retired root.
Still deferred, and named here so it is not mistaken for landed: a rotation this
client published itself is not adopted locally (no rekey writer exists yet, so the
watch only ever adopts another member's).
### 7. Honest states (phase 4)
### 7. Honest states (phase 4) — **landed**
- `Progress` already carries `fetched`, `opened`, `exhausted`, `failed`, `errors`;
the fold reports what it could not open; `Snapshot` carries those counts.
- `CommunityEvent` gains "history exists that we cannot read" and "the last round
failed", and the panel renders "N messages here can't be read yet — the channel's
key for epoch 3 is missing" or "Couldn't reach the community's relays" with a
retry, instead of "No messages yet" (finding 8).
"No messages yet" is a claim, and finding 8 is that the client made it in three
situations it could not tell apart. The rule is that a room only says it is empty
when it *knows* it is empty; otherwise it says what is actually wrong.
What is carried out of the read path:
- `history::WrapPage.unreadable` counts the wraps a page reached under a held
plane that no held key could open — sealed past the cutoff a rotation set on the
key that reads them, or bound to another channel. `Walk`'s count is the page's;
`Progress.unreadable` sums the pages of a round.
- `sync::Snapshot.unreadable` is the same count over the whole store, per channel,
so a wrap is counted whether it arrived on a round or on the live wire. A wrap
already opened on the way in stays readable through its cached row and is never
counted.
- `Community` keeps the last completed round's `Progress` per channel and the
`unreadable` counts, merged monotonically: an unreadable wrap stays unreadable,
so a later quiet round cannot erase the count.
What the community answers:
- `progress(channel)`, `unreadable(channel)`, `missing_key(channel)` (a private
channel we know and hold no key for, with the epoch), `channel_removed_at(channel)`
(a channel rotation's cut), plus the phase-3 `removed_at()` and `stranded()`.
- `due(channel)`: whether an automatic catch-up is worth asking for yet.
What the panel renders, in precedence order, instead of an empty room:
| State | Rendered |
| ----- | -------- |
| `stranded()` | "This invite is stale — the community has rotated past the epoch it names" |
| `removed_at()` | "You were removed from this community at epoch N. Its history stays readable" |
| `channel_removed_at()` | "A rotation removed you from this channel at epoch N" |
| `missing_key()` | "Messages here can't be read yet — this channel's key for epoch N is missing" |
| `progress.failed && progress.errors > 0` | "Couldn't reach the community's relays" + **Retry** |
| `unreadable(channel) > 0` | "N messages here can't be read yet — no key we hold opens them" |
| otherwise, no rows | "No messages yet" |
The notice replaces the empty state, and sits as a one-line strip above the rows
when there are rows, so a stale room says it may be stale rather than looking
complete. Only *Retry* is actionable, and it runs the round directly — the
`MIN_ROUND_INTERVAL` gate below only paces automatic rounds.
One of those states is about writing, not reading. A room a rotation removed us
from, a channel it cut, a key we never held, or a stale invite all mean the same
thing at the wire: `channel_secret` is `None`, so a wrap sealed now would be
sealed under a root nobody who rotated reads. `Community::send` therefore refuses
in those cases (§6's deferral, closed), and the panel disables the send button
while the notice says why. A channel that is merely unreachable or partly
unreadable still writes: reading and publishing are separate paths.
Two `CommunityEvent`s carry the same news to any other view: `Failed(id)` (the
last round could not reach the relays) and `Unreadable(id)` (history here that no
held key opens). `Failed` replaces the `Error(..)` toast a failed round used to
raise, because the panel now says it in place.
### 7b. The round scheduler (phase 4) — **landed**
Two constants in `community/src/community.rs`:
- `MIN_ROUND_INTERVAL = 30s``Community::due(channel)` is false while a round
for that channel ran inside the window. The panel's automatic round on open (and
on a channel switch) is what consults it, so opening a channel twice in a breath
asks the relays once. A round for `Older`, a retry, and the catch-up a rekey
adoption triggers all bypass it — only the automatic open is paced.
- `STALE_AFTER = 300s``Community::tick` re-folds, and re-rounds the active
channel, once a channel that has been *synced before* has gone unsynced that
long. A channel with no recorded round is left alone, so a community nobody has
opened costs nothing, and a quiet one asks its relays at most once per five
minutes.
`CommunityRegistry` owns one task for this, armed beside the pump and the signal
consumer in `handle_notifications` and cleared in `reset`, so a signer change
stops it and re-arms it.
### 7c. Not done in phase 4
- **NIP-77 (`client.sync`) catch-up.** Still skipped deliberately. A negentropy
reconciliation is a second way to ask the same question, and getting its
"unsupported"/partial answers right — never reading one as `exhausted` — is its
own piece of work with its own failure modes. The paged walk is the fallback the
plan already specifies, so nothing here blocks on it.
- **The fold's control and guestbook planes** keep logging an unopenable wrap
rather than counting it: `Snapshot.unreadable` is per channel, and there is no
honest place yet to render "the community's own metadata is unreadable".
### 8. `community_ui`: reach the older rows
@@ -542,8 +625,10 @@ Two rules survive, both about GPUI rather than about history:
- **Never `set_follow_mode` or force a scroll position inside a scroll-handler
callback** — the list holds its state borrowed while it invokes the handler, so
touching `ListState` there panics. `load_older` is written to avoid it.
- The panel's `Intent::{CatchUp, Older}` calls do not change: the round they
trigger is now subscription-driven, which is invisible to the list.
- The panel's `Intent::{CatchUp, Older}` calls do not change the list: the round
they trigger is subscription-driven, which is invisible to it. Phase 4 added one
gate in front of the `CatchUp` one — `due()` — and it only paces how often the
relays are asked, not what the list does with an answer.
Still deferred: per-channel timeline state (switching back re-reads), and the
`MAX_TIMELINE_ROWS` trim (trimming the oldest rows fights `load_older`, which
@@ -650,19 +735,54 @@ Also landed as the cheap win §3 promised: the older pass is skipped entirely wh
`cargo +nightly fmt -p concord -p community -p community_ui --check`,
`cargo check -p workspace --all-targets`.
### Phase 4 — honest states and polish
### Phase 4 — honest states and polish — **landed**
§7 (empty/unreadable/failed in the panel, using the counts that already exist),
round progress in the UI, the `MIN_ROUND_INTERVAL = 30s` / `STALE_AFTER = 5min`
scheduler, the `removed`/`stranded` rendering phase 3 persisted but left
unpainted, and optional NIP-77 catch-up (`client.sync(filter)` where a relay
supports negentropy; "negentropy unsupported" means "fall back to the paged
walk", never "exhausted").
§7, §7b: the read path counts what it cannot open (`WrapPage`/`Progress`/
`Snapshot` `unreadable`), the community exposes the honest read surface
(`progress`, `unreadable`, `missing_key`, `channel_removed_at`, `due`, beside the
phase-3 `removed_at`/`stranded`), the panel renders a reason and a retry instead
of "No messages yet", the round scheduler paces automatic rounds
(`MIN_ROUND_INTERVAL`) and repairs a stale one (`STALE_AFTER`), and §6's
send-time gap is closed: a removed, cut, keyless or stranded room holds no write
key.
Gate, all green: `cargo test -p concord -p community` (50 + 32),
`cargo clippy -p concord -p community -p community_ui --all-targets`,
`cargo +nightly fmt -p concord -p community -p community_ui --check`,
`cargo check -p workspace --all-targets`.
Five recorded deviations:
- **A one-line strip above the rows, not only an empty state.** §7's finding is
about the empty room, but the stale case — rows on screen, relays unreachable or
history unreadable — is the same lie in a quieter form, and it is the one the
original report is actually about ("not full of messages and latest data"). A
failed round's strip is transient: the next round that succeeds replaces
`progress` and clears it. An `unreadable` count is monotone by design, because a
wrap no held key opens stays unopened.
- **`Failed` replaces the `Error(..)` toast** on a failed round rather than
accompanying it, so the panel is the single place that reports it.
- **`due()` gates the panel's automatic round, not `sync_channel`.** The plan
named the interval but not the seam; keeping the gate on the caller means every
explicit request (retry, load-older, a rekey's catch-up) stays exact.
- **§6's send-time deferral is closed in the same phase.** The plan framed it as a
wire-level gap to decide later, but it is the same lie in the other direction: a
member a rotation excluded could type, press enter, watch the message disappear
into a plane nobody reads, and be told nothing. `channel_secret` refusing is what
makes the notice actionable.
- **Spawned work is tracked, not detached.** `Community::tasks` and
`CommunityPanel::tasks` hold every fold, round bookkeeping step and publish, so
closing a panel or signing out cancels them instead of letting them finish
against a client nobody holds. Each push drops the tasks that already finished.
Phase 4 leaves the client honest about what it can see and what it can write.
What it does not do is make it see more: the rekey writer, and with it adopting a
rotation this client published itself, remains open (see §6).
Each phase leaves the client consistent on its own. Phase 2a was invisible; phase
2b is what makes "open a community" a subscription and a database read; phase 3
is what keeps a rekey from stranding history. Phase 4 (§7) is next: it is what
makes an empty or unreadable room tell the truth.
is what keeps a rekey from stranding history; phase 4 is what makes an empty or
unreadable room tell the truth.
## Phase 1 status (landed)
@@ -729,6 +849,20 @@ outstanding are the ones that need a GPUI harness or two live accounts.
receives nothing.
- The read path: the side-event budget folds an edit/delete/reaction older than
the row window onto its message.
- What cannot be read — **landed in phase 4**: a wrap sealed after the rotation
that retired the key reading it, and a wrap sealed to this plane but bound to
another channel, both read as unreadable rather than dropped
(`community/src/history.rs`); a fold counts a wrap addressed to a held channel
plane that will not open (`community/src/sync.rs`).
- The honest states — **landed in phase 4** structurally: `Snapshot.unreadable`
and `Progress.unreadable` are what the panel's notice and the `Unreadable`
event read, and the `Failed` event is what a failed round emits. The panel's
precedence (stranded → removed → missing key → unreachable → unreadable →
empty) itself needs a `TestAppContext`, which the repo still does not have.
- The scheduler — **landed in phase 4** structurally: `due()` is false inside
`MIN_ROUND_INTERVAL` after a round, `stale()` requires a recorded round older
than `STALE_AFTER`, and `tick` acts only on the active channel. Driving real
time needs the same harness.
- The fold: a new live wrap costs one decrypt, and a fold over a community with
5,000 cached rows does not re-open them. (The skip is in place and structurally
tested by `wrapper_index`; counting decrypts needs a harness.)
@@ -736,13 +870,15 @@ outstanding are the ones that need a GPUI harness or two live accounts.
regression that duplicates a community's history per app run). **Landed in 2a.**
- GPUI (`TestAppContext`): prepending older rows preserves the scroll anchor; a
live message does not scroll a reader who is scrolled up; `has_more == false`
disables the load-older row. (No GPUI test harness exists in the repo yet.)
disables the load-older row; the panel's notice precedence; `due()`/`stale()`
under a driven clock. (No GPUI test harness exists in the repo yet.)
- Manual runs: two accounts, a channel with more than 200 messages, one account
offline long enough to miss a full page, one private channel, one rekey. The
acceptance bar is the reference behaviour: open a channel cold and see history
arrive in pages without touching the scrollbar, reopen it and see one REQ with a
`since` instead of a replay, and see the other account's message appear without
a reload.
offline long enough to miss a full page, one private channel, one rekey, and one
run with a relay stopped so the notice and its retry are visible. The acceptance
bar is the reference behaviour: open a channel cold and see history arrive in
pages without touching the scrollbar, reopen it and see one REQ with a `since`
instead of a replay, see the other account's message appear without a reload,
and see a room we cannot read say so instead of "No messages yet".
## Out of scope
+23 -5
View File
@@ -274,7 +274,10 @@ than writing off a relay that only wanted to authenticate.
reports what it saw: `oldest_ms`/`newest_ms` feed the caller's `ChannelCursor`,
`exhausted` is earned only by a short page *after* history was seen, and an
all-empty answer sets `failed` so a later round re-asks instead of sealing the
channel at "no more history". Page down with `Window::older_than(seen.oldest_ms)`,
channel at "no more history". `unreadable` counts the wraps the page reached that
no held key could open — sealed past the cutoff their key's rotation set, or bound
to another channel — because those are history the reader is missing, not history
that is not there. Page down with `Window::older_than(seen.oldest_ms)`,
open a channel with `Window::opening(cursor)` (wide cold, `newest_ms - 60s` warm),
and read the region between two cursors with `Window::between(..)`. `query_rumors`
is the read path when the group keys are gone; pass `kinds` to budget rows apart
@@ -284,6 +287,18 @@ author and message times without re-opening a wrap it already cached, and
`cache::purge_expired(client, &channel, now)` runs at the top of every round — the
timer is cooperative, so the local store is the artifact that has to forget.
`sync::fold` counts the same thing over the whole store, per channel, in
`Snapshot.unreadable`. `sync_round` sums a round's pages into
`Progress.unreadable`, and `Community` keeps both per channel: `progress(channel)`
is the last completed round, `unreadable(channel)` is the count, and
`missing_key(channel)`, `channel_removed_at(channel)`, `removed_at()` and
`stranded()` are the rest of the honest read surface a panel needs to tell an
empty room from one it cannot read. `Community::due(channel)` is the other half —
whether a round is worth asking for yet (a round for `Older` and an explicit retry
never ask it), and `Community::tick`, driven by `CommunityRegistry` once per
`community::MIN_ROUND_INTERVAL`, re-folds and re-rounds the active channel of a
community whose last round is older than `STALE_AFTER`.
`ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it
as an inline row only when its author passes
`control.roles.is_authorized(&author, &owner, Permissions::MANAGE_METADATA)`.
@@ -717,7 +732,10 @@ client.subscribe(filter).with_id(sub_id).await?;
retired root's Control signer and the publish time that retires it), and each
`ChannelKeyRef.priors` keeps every channel key a rotation stepped off. The read
side derives planes from all of them, and a retired key's `retired_at` is a hard
read cutoff at both `history::page` and `sync::fold`. The residual gaps: the
panel does not yet render the `removed`/`stranded` state `Community::removed_at`
and `Community::stranded` carry (phase 4), and a base removal is not yet enforced
at send time — the composer still has the old root to write under.
read cutoff at both `history::page` and `sync::fold`. The state
`Community::removed_at`/`Community::stranded`/`channel_removed_at` carries is
rendered as a notice **and** enforced at write time: `Community::send` refuses
when `channel_secret` is `None` (a removal, a strand, a channel cut, or a key we
never held), because a wrap sealed under a superseded root would reach nobody who
rotated. The residual gap: this client does not adopt a rotation it published
itself, because there is no rekey writer yet.