update
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use concord::cord02::{ControlFold, ImageRef};
|
use concord::cord02::{ControlFold, ImageRef};
|
||||||
@@ -10,6 +11,7 @@ use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, Held
|
|||||||
use concord::{ChannelId, CommunityId, Epoch};
|
use concord::{ChannelId, CommunityId, Epoch};
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, Task};
|
use gpui::{App, AppContext, Context, EventEmitter, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
|
|
||||||
use crate::cache;
|
use crate::cache;
|
||||||
@@ -25,9 +27,12 @@ const CATCH_UP_PAGES: usize = 20;
|
|||||||
pub const LOAD_OLDER_PAGES: usize = 6;
|
pub const LOAD_OLDER_PAGES: usize = 6;
|
||||||
/// Rows one timeline read returns before the caller asks for more.
|
/// Rows one timeline read returns before the caller asks for more.
|
||||||
pub const TIMELINE_PAGE: usize = 100;
|
pub const TIMELINE_PAGE: usize = 100;
|
||||||
/// Side events read per row, so a reaction flood cannot displace the rows it
|
/// Side events read per row, so a reaction flood cannot displace the rows it decorates.
|
||||||
/// decorates.
|
|
||||||
const SIDE_EVENT_FACTOR: usize = 4;
|
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.
|
/// Which direction a sync round reads.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -41,6 +46,7 @@ pub enum Intent {
|
|||||||
pub struct Progress {
|
pub struct Progress {
|
||||||
pub fetched: usize,
|
pub fetched: usize,
|
||||||
pub opened: usize,
|
pub opened: usize,
|
||||||
|
pub unreadable: usize,
|
||||||
pub exhausted: bool,
|
pub exhausted: bool,
|
||||||
pub failed: bool,
|
pub failed: bool,
|
||||||
pub errors: usize,
|
pub errors: usize,
|
||||||
@@ -102,6 +108,10 @@ pub enum CommunityEvent {
|
|||||||
Open(CommunityId),
|
Open(CommunityId),
|
||||||
Close(CommunityId),
|
Close(CommunityId),
|
||||||
Channel(CommunityId, ChannelId),
|
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),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +129,17 @@ pub struct Community {
|
|||||||
icon_task: Option<Task<Result<()>>>,
|
icon_task: Option<Task<Result<()>>>,
|
||||||
banner_task: Option<Task<Result<()>>>,
|
banner_task: Option<Task<Result<()>>>,
|
||||||
rounds: HashMap<ChannelId, Round>,
|
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,
|
pages: PageRegistry,
|
||||||
rekey_task: Option<Task<Result<()>>>,
|
rekey_task: Option<Task<Result<()>>>,
|
||||||
rekey_dirty: bool,
|
rekey_dirty: bool,
|
||||||
|
/// Spawned folds, round bookkeeping and publishes, cancelled on drop
|
||||||
|
tasks: SmallVec<[Task<Result<()>>; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<CommunityEvent> for Community {}
|
impl EventEmitter<CommunityEvent> for Community {}
|
||||||
@@ -142,9 +160,13 @@ impl Community {
|
|||||||
icon_task: None,
|
icon_task: None,
|
||||||
banner_task: None,
|
banner_task: None,
|
||||||
rounds: HashMap::new(),
|
rounds: HashMap::new(),
|
||||||
|
progress: HashMap::new(),
|
||||||
|
unreadable: BTreeMap::new(),
|
||||||
|
last_round: HashMap::new(),
|
||||||
pages,
|
pages,
|
||||||
rekey_task: None,
|
rekey_task: None,
|
||||||
rekey_dirty: false,
|
rekey_dirty: false,
|
||||||
|
tasks: smallvec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +234,44 @@ impl Community {
|
|||||||
self.state.stranded
|
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] {
|
pub fn channels(&self) -> &[ChannelKeyRef] {
|
||||||
&self.state.channels
|
&self.state.channels
|
||||||
}
|
}
|
||||||
@@ -222,12 +282,20 @@ impl Community {
|
|||||||
|
|
||||||
/// A public channel derives its write plane from the community root.
|
/// A public channel derives its write plane from the community root.
|
||||||
fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])> {
|
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
|
let held = self
|
||||||
.state
|
.state
|
||||||
.channels
|
.channels
|
||||||
.iter()
|
.iter()
|
||||||
.find(|held| held.id == *channel)?;
|
.find(|held| held.id == *channel)?;
|
||||||
|
|
||||||
|
if self.state.channel_cut(channel, held.epoch) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
held.current()
|
held.current()
|
||||||
.or_else(|| (!held.private).then_some((held.epoch, self.state.community_root)))
|
.or_else(|| (!held.private).then_some((held.epoch, self.state.community_root)))
|
||||||
}
|
}
|
||||||
@@ -296,18 +364,23 @@ impl Community {
|
|||||||
round.queued = None;
|
round.queued = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.last_round.insert(channel, Instant::now());
|
||||||
|
|
||||||
let round = cx.background_spawn(async move {
|
let round = cx.background_spawn(async move {
|
||||||
sync_round(&client, &pages, &channel, &held, &relays, saved, intent).await
|
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;
|
let result = round.await;
|
||||||
|
|
||||||
if let Err(error) = this.update(cx, |this, cx| this.finish_round(channel, result, cx)) {
|
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}");
|
log::warn!("community: a channel round outlived its community: {error}");
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.detach();
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(finisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finish_round(
|
fn finish_round(
|
||||||
@@ -342,20 +415,56 @@ impl Community {
|
|||||||
self.start_round(channel, intent, cx);
|
self.start_round(channel, intent, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(progress) = &outcome
|
if let Ok(progress) = &outcome {
|
||||||
&& progress.failed
|
self.progress.insert(channel, *progress);
|
||||||
&& progress.errors > 0
|
self.record_unreadable(channel, progress.unreadable);
|
||||||
&& progress.fetched == 0
|
|
||||||
{
|
if progress.failed && progress.errors > 0 {
|
||||||
cx.emit(CommunityEvent::Error(format!(
|
cx.emit(CommunityEvent::Failed(self.state.id));
|
||||||
"could not reach {} of the community's relays",
|
}
|
||||||
progress.errors
|
|
||||||
)));
|
if progress.unreadable > 0 {
|
||||||
|
cx.emit(CommunityEvent::Unreadable(self.state.id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.emit(CommunityEvent::Updated(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.
|
/// Fold a round's cursor findings in, monotonically, and persist them.
|
||||||
fn merge_cursor(&mut self, channel: ChannelId, cursor: ChannelCursor, cx: &mut Context<Self>) {
|
fn merge_cursor(&mut self, channel: ChannelId, cursor: ChannelCursor, cx: &mut Context<Self>) {
|
||||||
let held = self
|
let held = self
|
||||||
@@ -375,16 +484,19 @@ impl Community {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write the local state document out.
|
/// 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 client = NostrRegistry::global(cx).read(cx).client();
|
||||||
let state = self.state.clone();
|
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 {
|
if let Err(error) = cache::save_state(&client, &state).await {
|
||||||
log::warn!("community: failed to persist the local state: {error}");
|
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.
|
/// 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);
|
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 {
|
if let Err(error) = catch_up.await {
|
||||||
log::warn!("community: the catch-up after a rekey failed: {error}");
|
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.
|
/// Rebuilds the community from the wraps in the local database.
|
||||||
@@ -686,6 +801,17 @@ impl Community {
|
|||||||
self.control = snapshot.control;
|
self.control = snapshot.control;
|
||||||
self.members = snapshot.members;
|
self.members = snapshot.members;
|
||||||
self.load_images(cx);
|
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.emit(CommunityEvent::Updated(self.state.id));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
@@ -886,7 +1012,77 @@ async fn sync_round(
|
|||||||
fn absorb(progress: &mut Progress, page: &WrapPage) {
|
fn absorb(progress: &mut Progress, page: &WrapPage) {
|
||||||
progress.fetched += page.raw;
|
progress.fetched += page.raw;
|
||||||
progress.opened += page.opened.len();
|
progress.opened += page.opened.len();
|
||||||
|
progress.unreadable += page.unreadable;
|
||||||
progress.exhausted |= page.exhausted;
|
progress.exhausted |= page.exhausted;
|
||||||
progress.failed |= page.failed;
|
progress.failed |= page.failed;
|
||||||
progress.errors += page.errors;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
use std::sync::{Arc, Mutex, MutexGuard};
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Result, bail};
|
||||||
use concord::cord01::KIND_WRAP_EPHEMERAL;
|
use concord::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
|
||||||
use concord::cord03::{self, ChatRumor};
|
use concord::cord03::{self, ChatRumor};
|
||||||
use concord::derive::channel_group_key;
|
use concord::derive::channel_group_key;
|
||||||
use concord::state::{ChannelCursor, HeldKey};
|
use concord::state::{ChannelCursor, HeldKey};
|
||||||
@@ -137,6 +137,8 @@ impl PageRegistry {
|
|||||||
pub struct WrapPage {
|
pub struct WrapPage {
|
||||||
pub opened: Vec<ChatRumor>,
|
pub opened: Vec<ChatRumor>,
|
||||||
pub raw: usize,
|
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 newest_ms: Option<u64>,
|
||||||
pub oldest_ms: Option<u64>,
|
pub oldest_ms: Option<u64>,
|
||||||
pub exhausted: bool,
|
pub exhausted: bool,
|
||||||
@@ -210,15 +212,8 @@ pub async fn page(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// A retired key reads only what was sealed before its rotation published.
|
let Ok((stream, rumor)) = read_under(&wrap, held, group, channel) else {
|
||||||
if held
|
walk.unreadable += 1;
|
||||||
.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 {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,6 +226,23 @@ pub async fn page(
|
|||||||
Ok(walk.finish(opened))
|
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.
|
/// The one filter a page is asked for.
|
||||||
fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter {
|
fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter {
|
||||||
let mut filter = Filter::new()
|
let mut filter = Filter::new()
|
||||||
@@ -369,6 +381,8 @@ struct Walk {
|
|||||||
oldest_ms: Option<u64>,
|
oldest_ms: Option<u64>,
|
||||||
raw: usize,
|
raw: usize,
|
||||||
errors: usize,
|
errors: usize,
|
||||||
|
/// Wraps the caller could not read under any held key.
|
||||||
|
unreadable: usize,
|
||||||
/// A short page ended the walk.
|
/// A short page ended the walk.
|
||||||
bottom: bool,
|
bottom: bool,
|
||||||
}
|
}
|
||||||
@@ -395,6 +409,7 @@ impl Walk {
|
|||||||
oldest_ms: None,
|
oldest_ms: None,
|
||||||
raw: 0,
|
raw: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
|
unreadable: 0,
|
||||||
bottom: false,
|
bottom: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,6 +473,7 @@ impl Walk {
|
|||||||
WrapPage {
|
WrapPage {
|
||||||
opened,
|
opened,
|
||||||
raw: self.raw,
|
raw: self.raw,
|
||||||
|
unreadable: self.unreadable,
|
||||||
newest_ms: self.newest_ms,
|
newest_ms: self.newest_ms,
|
||||||
oldest_ms: self.oldest_ms,
|
oldest_ms: self.oldest_ms,
|
||||||
exhausted: swept && self.raw > 0,
|
exhausted: swept && self.raw > 0,
|
||||||
@@ -586,6 +602,48 @@ mod tests {
|
|||||||
assert_eq!(page.raw, 3);
|
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]
|
#[test]
|
||||||
fn an_empty_answer_never_seals_the_channel() {
|
fn an_empty_answer_never_seals_the_channel() {
|
||||||
let database: BTreeSet<Event> = BTreeSet::new();
|
let database: BTreeSet<Event> = BTreeSet::new();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use anyhow::Result;
|
|||||||
pub use concord::cord02::CommunityMetadata;
|
pub use concord::cord02::CommunityMetadata;
|
||||||
pub use concord::cord03::{ChatMessage, ReplyRef};
|
pub use concord::cord03::{ChatMessage, ReplyRef};
|
||||||
use concord::state::CommunityState;
|
use concord::state::CommunityState;
|
||||||
pub use concord::{ChannelId, CommunityId};
|
pub use concord::{ChannelId, CommunityId, Epoch};
|
||||||
use futures::future::{Either, select};
|
use futures::future::{Either, select};
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -152,6 +152,8 @@ pub struct CommunityRegistry {
|
|||||||
notification_listener: Option<Task<Result<()>>>,
|
notification_listener: Option<Task<Result<()>>>,
|
||||||
/// Signal consumer task (cancelled on signer change)
|
/// Signal consumer task (cancelled on signer change)
|
||||||
signal_consumer: Option<Task<Result<()>>>,
|
signal_consumer: Option<Task<Result<()>>>,
|
||||||
|
/// The round scheduler (cancelled on signer change)
|
||||||
|
scheduler: Option<Task<Result<()>>>,
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +205,7 @@ impl CommunityRegistry {
|
|||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
notification_listener: None,
|
notification_listener: None,
|
||||||
signal_consumer: None,
|
signal_consumer: None,
|
||||||
|
scheduler: None,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,6 +275,7 @@ impl CommunityRegistry {
|
|||||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
self.notification_listener = None;
|
self.notification_listener = None;
|
||||||
self.signal_consumer = None;
|
self.signal_consumer = None;
|
||||||
|
self.scheduler = None;
|
||||||
self.tasks.clear();
|
self.tasks.clear();
|
||||||
self.observers.clear();
|
self.observers.clear();
|
||||||
self.pages.clear();
|
self.pages.clear();
|
||||||
@@ -424,6 +428,13 @@ impl CommunityRegistry {
|
|||||||
community.update(cx, |community, cx| community.rekey(cx));
|
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.
|
/// Re-subscribe every community whose held planes moved.
|
||||||
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
|
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -491,6 +502,7 @@ impl CommunityRegistry {
|
|||||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||||
self.notification_listener = None;
|
self.notification_listener = None;
|
||||||
self.signal_consumer = None;
|
self.signal_consumer = None;
|
||||||
|
self.scheduler = None;
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
@@ -558,6 +570,22 @@ impl CommunityRegistry {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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(())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ pub struct Snapshot {
|
|||||||
pub state: CommunityState,
|
pub state: CommunityState,
|
||||||
pub control: ControlFold,
|
pub control: ControlFold,
|
||||||
pub members: BTreeSet<PublicKey>,
|
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>(
|
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 editions = Vec::new();
|
||||||
let mut observed: BTreeMap<PublicKey, u64> = BTreeMap::new();
|
let mut observed: BTreeMap<PublicKey, u64> = BTreeMap::new();
|
||||||
let mut guestbook_rumors = Vec::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();
|
let mut cached: BTreeMap<ChannelId, BTreeMap<EventId, Observed>> = BTreeMap::new();
|
||||||
|
|
||||||
for plane in &planes {
|
for plane in &planes {
|
||||||
@@ -544,19 +547,23 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// A retired key reads only what was sealed before its rotation published.
|
|
||||||
if !plane.accepts(wrap) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match plane.kind {
|
match plane.kind {
|
||||||
PlaneKind::Control(_) => {
|
PlaneKind::Control(_) => {
|
||||||
|
// A retired root reads only what was sealed before its rotation.
|
||||||
|
if !plane.accepts(wrap) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true)
|
if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true)
|
||||||
{
|
{
|
||||||
editions.push(edition);
|
editions.push(edition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlaneKind::Guestbook => {
|
PlaneKind::Guestbook => {
|
||||||
|
if !plane.accepts(wrap) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok((_, rumor)) = cord02::guestbook::open(wrap, &plane.group) {
|
if let Ok((_, rumor)) = cord02::guestbook::open(wrap, &plane.group) {
|
||||||
observe(&mut observed, rumor.author, rumor.at_ms);
|
observe(&mut observed, rumor.author, rumor.at_ms);
|
||||||
guestbook_rumors.push(rumor);
|
guestbook_rumors.push(rumor);
|
||||||
@@ -568,11 +575,18 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok((opened, rumor)) =
|
let opened = if plane.accepts(wrap) {
|
||||||
concord::cord03::open(wrap, &plane.group, &channel, epoch)
|
concord::cord03::open(wrap, &plane.group, &channel, epoch).ok()
|
||||||
{
|
} else {
|
||||||
cache::cache_rumor(client, &channel, &opened).await?;
|
None
|
||||||
observe(&mut observed, rumor.author, rumor.at_ms);
|
};
|
||||||
|
|
||||||
|
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,
|
state,
|
||||||
control,
|
control,
|
||||||
members,
|
members,
|
||||||
|
unreadable,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,4 +1152,43 @@ mod tests {
|
|||||||
assert!(retired.accepts(&event_at(1_000_000)));
|
assert!(retired.accepts(&event_at(1_000_000)));
|
||||||
assert!(!retired.accepts(&event_at(1_001_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));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+206
-39
@@ -1,9 +1,10 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use community::{
|
use community::{
|
||||||
ChannelId, ChatMessage, Community, CommunityEvent, Intent, LOAD_OLDER_PAGES, TIMELINE_PAGE,
|
ChannelId, ChatMessage, Community, CommunityEvent, Epoch, Intent, LOAD_OLDER_PAGES,
|
||||||
Timeline,
|
TIMELINE_PAGE, Timeline,
|
||||||
};
|
};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -20,13 +21,66 @@ use ui::dock::{Panel, PanelEvent};
|
|||||||
use ui::input::{InputEvent, Textarea, TextareaState};
|
use ui::input::{InputEvent, Textarea, TextareaState};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
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;
|
mod message;
|
||||||
|
|
||||||
/// How near the top row a scroll has to come before the panel splices older history in.
|
/// How near the top row a scroll has to come before the panel splices older history in.
|
||||||
const LOAD_OLDER_THRESHOLD: usize = 20;
|
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(
|
pub fn init(
|
||||||
community: Entity<Community>,
|
community: Entity<Community>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
@@ -53,6 +107,8 @@ pub struct CommunityPanel {
|
|||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
/// Message input state
|
/// Message input state
|
||||||
input: Entity<TextareaState>,
|
input: Entity<TextareaState>,
|
||||||
|
/// Spawned reads and publishes, cancelled when the panel closes
|
||||||
|
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
}
|
}
|
||||||
@@ -89,21 +145,24 @@ impl CommunityPanel {
|
|||||||
subscriptions.push(cx.subscribe_in(
|
subscriptions.push(cx.subscribe_in(
|
||||||
&community,
|
&community,
|
||||||
window,
|
window,
|
||||||
|_this, _community, event, window, cx| match event {
|
|_this, _community, event, window, cx| {
|
||||||
// The fold holds the community, and a round caches rows nothing
|
match event {
|
||||||
// has read yet, so re-read once either is released.
|
CommunityEvent::Updated(_)
|
||||||
CommunityEvent::Updated(_) => {
|
| CommunityEvent::Unreadable(_)
|
||||||
cx.defer_in(window, |this, window, cx| this.reload(window, cx));
|
| 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(..) => {
|
CommunityEvent::Channel(..) => {
|
||||||
cx.defer_in(window, |this, window, cx| this.load(window, cx));
|
cx.defer_in(window, |this, window, cx| this.load(window, cx));
|
||||||
}
|
}
|
||||||
CommunityEvent::Error(error) => {
|
CommunityEvent::Error(error) => {
|
||||||
window
|
window.push_notification(
|
||||||
.push_notification(Notification::error(error.clone()).autohide(false), cx);
|
Notification::error(error.clone()).autohide(false),
|
||||||
}
|
cx,
|
||||||
CommunityEvent::Open(_) | CommunityEvent::Close(_) => {}
|
);
|
||||||
|
}
|
||||||
|
CommunityEvent::Open(_) | CommunityEvent::Close(_) => {}
|
||||||
|
};
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -117,6 +176,7 @@ impl CommunityPanel {
|
|||||||
loading: false,
|
loading: false,
|
||||||
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
|
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
|
||||||
input,
|
input,
|
||||||
|
tasks: smallvec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,10 +255,58 @@ impl CommunityPanel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.reload(window, cx);
|
self.reload(window, cx);
|
||||||
self.round(channel, Intent::CatchUp, 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(
|
fn round(
|
||||||
&mut self,
|
&mut self,
|
||||||
channel: ChannelId,
|
channel: ChannelId,
|
||||||
@@ -211,8 +319,9 @@ impl CommunityPanel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.loading = true;
|
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;
|
let result = round.await;
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
@@ -227,8 +336,18 @@ impl CommunityPanel {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
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.
|
/// Read the newest page and fold it into what is on screen.
|
||||||
@@ -241,7 +360,7 @@ impl CommunityPanel {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||||
match timeline.await {
|
match timeline.await {
|
||||||
Ok(timeline) => this.update(cx, |this, cx| this.apply(channel, timeline, cx))?,
|
Ok(timeline) => this.update(cx, |this, cx| this.apply(channel, timeline, cx))?,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -255,8 +374,9 @@ impl CommunityPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
});
|
||||||
.detach();
|
|
||||||
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splice the page of history above the oldest row on screen.
|
/// Splice the page of history above the oldest row on screen.
|
||||||
@@ -283,7 +403,7 @@ impl CommunityPanel {
|
|||||||
|
|
||||||
self.loading = true;
|
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 {
|
let timeline = match page.await {
|
||||||
Ok(timeline) => timeline,
|
Ok(timeline) => timeline,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -356,8 +476,9 @@ impl CommunityPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
});
|
||||||
.detach();
|
|
||||||
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fold a freshly read window into the rows on screen.
|
/// Fold a freshly read window into the rows on screen.
|
||||||
@@ -446,6 +567,14 @@ impl CommunityPanel {
|
|||||||
return;
|
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| {
|
let Ok(send) = self.community.read_with(cx, |community, cx| {
|
||||||
community.send(&channel, &content, None, cx)
|
community.send(&channel, &content, None, cx)
|
||||||
}) else {
|
}) else {
|
||||||
@@ -461,7 +590,7 @@ impl CommunityPanel {
|
|||||||
input.set_value("", window, cx);
|
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 {
|
match send.await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
|
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
|
||||||
@@ -477,8 +606,34 @@ impl CommunityPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
});
|
||||||
.detach();
|
|
||||||
|
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))),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The row at index 0: the affordance that pages older history in.
|
/// 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 {
|
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let writable = self.notice(cx).is_none_or(|notice| notice.writable());
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -537,6 +694,7 @@ impl CommunityPanel {
|
|||||||
.tooltip("Send")
|
.tooltip("Send")
|
||||||
.ghost()
|
.ghost()
|
||||||
.large()
|
.large()
|
||||||
|
.disabled(!writable)
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
this.send(window, cx);
|
this.send(window, cx);
|
||||||
})),
|
})),
|
||||||
@@ -591,17 +749,26 @@ impl Render for CommunityPanel {
|
|||||||
.min_h_0()
|
.min_h_0()
|
||||||
.relative()
|
.relative()
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
|
let notice = self.notice(cx);
|
||||||
|
|
||||||
if self.rows.is_empty() {
|
if self.rows.is_empty() {
|
||||||
this.child(
|
this.child(
|
||||||
h_flex()
|
v_flex().size_full().justify_center().child(match notice {
|
||||||
.size_full()
|
Some(notice) => self.render_notice(notice, cx),
|
||||||
.justify_center()
|
None => h_flex()
|
||||||
.text_sm()
|
.size_full()
|
||||||
.text_color(cx.theme().text_placeholder)
|
.justify_center()
|
||||||
.child("No messages yet"),
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.child("No messages yet")
|
||||||
|
.into_any_element(),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
this.child(
|
this.when_some(notice, |this, notice| {
|
||||||
|
this.child(self.render_notice(notice, cx))
|
||||||
|
})
|
||||||
|
.child(
|
||||||
list(
|
list(
|
||||||
self.list_state.clone(),
|
self.list_state.clone(),
|
||||||
cx.processor(move |this, ix, window, cx| {
|
cx.processor(move |this, ix, window, cx| {
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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,
|
back from the database,
|
||||||
adopts one epoch at a
|
adopts one epoch at a
|
||||||
time)
|
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
|
## 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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
|
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.
|
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
|
Still deferred, and named here so it is not mistaken for landed: a rotation this
|
||||||
not yet render `Community::removed_at()` / `Community::stranded()` (phase 4), and
|
client published itself is not adopted locally (no rekey writer exists yet, so the
|
||||||
a base removal is not enforced at send time — `channel_secret` still hands the
|
watch only ever adopts another member's).
|
||||||
composer the retired root.
|
|
||||||
|
|
||||||
### 7. Honest states (phase 4)
|
### 7. Honest states (phase 4) — **landed**
|
||||||
|
|
||||||
- `Progress` already carries `fetched`, `opened`, `exhausted`, `failed`, `errors`;
|
"No messages yet" is a claim, and finding 8 is that the client made it in three
|
||||||
the fold reports what it could not open; `Snapshot` carries those counts.
|
situations it could not tell apart. The rule is that a room only says it is empty
|
||||||
- `CommunityEvent` gains "history exists that we cannot read" and "the last round
|
when it *knows* it is empty; otherwise it says what is actually wrong.
|
||||||
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
|
What is carried out of the read path:
|
||||||
retry, instead of "No messages yet" (finding 8).
|
|
||||||
|
- `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
|
### 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
|
- **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
|
callback** — the list holds its state borrowed while it invokes the handler, so
|
||||||
touching `ListState` there panics. `load_older` is written to avoid it.
|
touching `ListState` there panics. `load_older` is written to avoid it.
|
||||||
- The panel's `Intent::{CatchUp, Older}` calls do not change: the round they
|
- The panel's `Intent::{CatchUp, Older}` calls do not change the list: the round
|
||||||
trigger is now subscription-driven, which is invisible to the list.
|
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
|
Still deferred: per-channel timeline state (switching back re-reads), and the
|
||||||
`MAX_TIMELINE_ROWS` trim (trimming the oldest rows fights `load_older`, which
|
`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 +nightly fmt -p concord -p community -p community_ui --check`,
|
||||||
`cargo check -p workspace --all-targets`.
|
`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),
|
§7, §7b: the read path counts what it cannot open (`WrapPage`/`Progress`/
|
||||||
round progress in the UI, the `MIN_ROUND_INTERVAL = 30s` / `STALE_AFTER = 5min`
|
`Snapshot` `unreadable`), the community exposes the honest read surface
|
||||||
scheduler, the `removed`/`stranded` rendering phase 3 persisted but left
|
(`progress`, `unreadable`, `missing_key`, `channel_removed_at`, `due`, beside the
|
||||||
unpainted, and optional NIP-77 catch-up (`client.sync(filter)` where a relay
|
phase-3 `removed_at`/`stranded`), the panel renders a reason and a retry instead
|
||||||
supports negentropy; "negentropy unsupported" means "fall back to the paged
|
of "No messages yet", the round scheduler paces automatic rounds
|
||||||
walk", never "exhausted").
|
(`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
|
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
|
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
|
is what keeps a rekey from stranding history; phase 4 is what makes an empty or
|
||||||
makes an empty or unreadable room tell the truth.
|
unreadable room tell the truth.
|
||||||
|
|
||||||
## Phase 1 status (landed)
|
## Phase 1 status (landed)
|
||||||
|
|
||||||
@@ -729,6 +849,20 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
|||||||
receives nothing.
|
receives nothing.
|
||||||
- The read path: the side-event budget folds an edit/delete/reaction older than
|
- The read path: the side-event budget folds an edit/delete/reaction older than
|
||||||
the row window onto its message.
|
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
|
- 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
|
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.)
|
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.**
|
regression that duplicates a community's history per app run). **Landed in 2a.**
|
||||||
- GPUI (`TestAppContext`): prepending older rows preserves the scroll anchor; a
|
- GPUI (`TestAppContext`): prepending older rows preserves the scroll anchor; a
|
||||||
live message does not scroll a reader who is scrolled up; `has_more == false`
|
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
|
- 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
|
offline long enough to miss a full page, one private channel, one rekey, and one
|
||||||
acceptance bar is the reference behaviour: open a channel cold and see history
|
run with a relay stopped so the notice and its retry are visible. The acceptance
|
||||||
arrive in pages without touching the scrollbar, reopen it and see one REQ with a
|
bar is the reference behaviour: open a channel cold and see history arrive in
|
||||||
`since` instead of a replay, and see the other account's message appear without
|
pages without touching the scrollbar, reopen it and see one REQ with a `since`
|
||||||
a reload.
|
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
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
+23
-5
@@ -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`,
|
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
|
`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
|
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),
|
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`
|
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
|
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
|
`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.
|
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
|
`ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it
|
||||||
as an inline row only when its author passes
|
as an inline row only when its author passes
|
||||||
`control.roles.is_authorized(&author, &owner, Permissions::MANAGE_METADATA)`.
|
`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
|
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
|
`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
|
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
|
read cutoff at both `history::page` and `sync::fold`. The state
|
||||||
panel does not yet render the `removed`/`stranded` state `Community::removed_at`
|
`Community::removed_at`/`Community::stranded`/`channel_removed_at` carries is
|
||||||
and `Community::stranded` carry (phase 4), and a base removal is not yet enforced
|
rendered as a notice **and** enforced at write time: `Community::send` refuses
|
||||||
at send time — the composer still has the old root to write under.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user