1258 lines
39 KiB
Rust
1258 lines
39 KiB
Rust
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|
use std::path::PathBuf;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::Result;
|
|
use concord::cord02::{ControlFold, ImageRef};
|
|
use concord::cord03::{self, ChatMessage, ReplyRef};
|
|
use concord::cord04::AuthorityCitation;
|
|
use concord::cord04::roles::{Permissions, citation_ok};
|
|
use concord::cord06::RekeyScope;
|
|
use concord::derive::{channel_group_key, grant_locator};
|
|
use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, HeldRoot};
|
|
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;
|
|
use crate::history::{self, PageRegistry, Window, WrapPage};
|
|
use crate::rekey::{self, Adoptions};
|
|
use crate::sync::{self, Snapshot};
|
|
|
|
/// Wraps one relay returns for one page request.
|
|
const PAGE_WRAPS: usize = 50;
|
|
/// Pages a catch-up round walks down a channel's history on its own.
|
|
const CATCH_UP_PAGES: usize = 20;
|
|
/// Pages one explicit "load older" fetches from the relays.
|
|
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.
|
|
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)]
|
|
pub enum Intent {
|
|
CatchUp,
|
|
Older { pages: usize },
|
|
}
|
|
|
|
/// What one round saw, for the caller to report and act on.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub struct Progress {
|
|
/// Wraps the round reached that no key this client holds can open.
|
|
pub unreadable: usize,
|
|
/// A relay refused or never answered a page.
|
|
pub failed: bool,
|
|
/// How many relays failed the round.
|
|
pub errors: usize,
|
|
}
|
|
|
|
/// A channel's timeline, folded from the local cache.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Timeline {
|
|
/// Oldest first, ready for a bottom-aligned list.
|
|
pub messages: Vec<ChatMessage>,
|
|
/// The cache holds rows older than `messages`.
|
|
pub has_more: bool,
|
|
}
|
|
|
|
/// One channel's round in flight, coalescing requests that arrive while it runs.
|
|
#[derive(Default)]
|
|
struct Round {
|
|
running: bool,
|
|
/// The next round to run once this one lands.
|
|
queued: Option<Intent>,
|
|
/// Everyone waiting on the outcome.
|
|
waiters: Vec<flume::Sender<Result<Progress, String>>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SubscriptionKey {
|
|
control_pks: BTreeMap<u64, PublicKey>,
|
|
channels: Vec<(ChannelId, Epoch, bool)>,
|
|
roots: Vec<(u64, [u8; 32])>,
|
|
relays: Vec<RelayUrl>,
|
|
}
|
|
|
|
impl SubscriptionKey {
|
|
fn of(state: &CommunityState) -> Self {
|
|
Self {
|
|
control_pks: state.control_pks.clone(),
|
|
channels: state
|
|
.channels
|
|
.iter()
|
|
.map(|channel| (channel.id, channel.epoch, channel.private))
|
|
.collect(),
|
|
roots: state
|
|
.roots()
|
|
.into_iter()
|
|
.map(|root| (root.epoch.0, root.key))
|
|
.collect(),
|
|
relays: state.relays.clone(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn relays(&self) -> &[RelayUrl] {
|
|
&self.relays
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum CommunityEvent {
|
|
Updated(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),
|
|
}
|
|
|
|
pub struct Community {
|
|
state: CommunityState,
|
|
control: ControlFold,
|
|
members: BTreeSet<PublicKey>,
|
|
active: Option<ChannelId>,
|
|
icon: Option<PathBuf>,
|
|
icon_ref: Option<ImageRef>,
|
|
banner: Option<PathBuf>,
|
|
banner_ref: Option<ImageRef>,
|
|
dirty: bool,
|
|
refresh_task: Option<Task<Result<()>>>,
|
|
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 {}
|
|
|
|
impl Community {
|
|
pub fn new(state: CommunityState, pages: PageRegistry) -> Self {
|
|
Self {
|
|
state,
|
|
control: ControlFold::default(),
|
|
members: BTreeSet::new(),
|
|
active: None,
|
|
icon: None,
|
|
icon_ref: None,
|
|
banner: None,
|
|
banner_ref: None,
|
|
dirty: false,
|
|
refresh_task: None,
|
|
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![],
|
|
}
|
|
}
|
|
|
|
pub fn id(&self) -> CommunityId {
|
|
self.state.id
|
|
}
|
|
|
|
pub fn state(&self) -> &CommunityState {
|
|
&self.state
|
|
}
|
|
|
|
pub fn name(&self) -> String {
|
|
if let Some(metadata) = &self.control.community {
|
|
return metadata.name.clone();
|
|
}
|
|
|
|
self.state
|
|
.name
|
|
.clone()
|
|
.unwrap_or_else(|| self.state.id.to_hex())
|
|
}
|
|
|
|
pub fn control(&self) -> &ControlFold {
|
|
&self.control
|
|
}
|
|
|
|
/// The community's icon, once downloaded and decrypted into a cache file.
|
|
pub fn icon(&self) -> Option<PathBuf> {
|
|
self.icon.clone()
|
|
}
|
|
|
|
/// The community's banner, once downloaded and decrypted into a cache file.
|
|
pub fn banner(&self) -> Option<PathBuf> {
|
|
self.banner.clone()
|
|
}
|
|
|
|
/// The channel the sidebar and panel show, defaulting to the first one.
|
|
pub fn active_channel(&self) -> Option<ChannelId> {
|
|
self.active
|
|
.or_else(|| self.state.channels.first().map(|channel| channel.id))
|
|
}
|
|
|
|
/// Mark `channel` as the one the sidebar and panel show.
|
|
pub fn set_active_channel(&mut self, channel: ChannelId, cx: &mut Context<Self>) {
|
|
if self.active == Some(channel) {
|
|
return;
|
|
}
|
|
|
|
self.active = Some(channel);
|
|
cx.emit(CommunityEvent::Channel(self.state.id, channel));
|
|
}
|
|
|
|
pub fn members(&self) -> &BTreeSet<PublicKey> {
|
|
&self.members
|
|
}
|
|
|
|
/// The base epoch a rotation excluded us at: readable history, no writes.
|
|
pub fn removed_at(&self) -> Option<Epoch> {
|
|
self.state.removed_at
|
|
}
|
|
|
|
/// A complete rotation ahead of our epoch predates our join and
|
|
/// carries no blob for us, so the invite landed us on a superseded epoch.
|
|
pub fn stranded(&self) -> bool {
|
|
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).copied()
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
pub fn subscription_key(&self) -> SubscriptionKey {
|
|
SubscriptionKey::of(&self.state)
|
|
}
|
|
|
|
/// 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((self.state.root_epoch, self.state.community_root))
|
|
})
|
|
}
|
|
|
|
/// Every secret the client holds for a channel, newest epoch first.
|
|
fn held_keys(&self, channel: &ChannelId) -> Vec<HeldKey> {
|
|
self.state.held_keys(channel)
|
|
}
|
|
|
|
pub fn sync_channel(
|
|
&mut self,
|
|
channel: &ChannelId,
|
|
intent: Intent,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<Progress>> {
|
|
let channel = *channel;
|
|
let (sender, receiver) = flume::bounded(1);
|
|
|
|
let start = {
|
|
let round = self.rounds.entry(channel).or_default();
|
|
round.waiters.push(sender);
|
|
|
|
let start = !round.running;
|
|
|
|
if !start {
|
|
round.queued = Some(intent);
|
|
}
|
|
|
|
start
|
|
};
|
|
|
|
if start {
|
|
self.start_round(channel, intent, cx);
|
|
}
|
|
|
|
cx.background_spawn(async move {
|
|
let progress = match receiver.recv_async().await {
|
|
Ok(progress) => progress,
|
|
Err(error) => {
|
|
log::warn!("community: channel round cancelled: {error}");
|
|
return Ok(Progress {
|
|
failed: true,
|
|
..Progress::default()
|
|
});
|
|
}
|
|
};
|
|
progress.map_err(anyhow::Error::msg)
|
|
})
|
|
}
|
|
|
|
fn start_round(&mut self, channel: ChannelId, intent: Intent, cx: &mut Context<Self>) {
|
|
let client = NostrRegistry::global(cx).read(cx).client();
|
|
let held = self.held_keys(&channel);
|
|
let relays = self.state.relays.clone();
|
|
let pages = self.pages.clone();
|
|
|
|
let saved = clamped(
|
|
self.state
|
|
.cursors
|
|
.get(&channel)
|
|
.copied()
|
|
.unwrap_or_default(),
|
|
Timestamp::now(),
|
|
);
|
|
|
|
if let Some(round) = self.rounds.get_mut(&channel) {
|
|
round.running = true;
|
|
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
|
|
});
|
|
|
|
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}");
|
|
}
|
|
|
|
Ok(())
|
|
});
|
|
|
|
self.tasks.push(finisher);
|
|
}
|
|
|
|
fn finish_round(
|
|
&mut self,
|
|
channel: ChannelId,
|
|
result: Result<RoundOutcome>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let outcome = match result {
|
|
Ok((progress, cursor)) => {
|
|
self.merge_cursor(channel, cursor, cx);
|
|
Ok(progress)
|
|
}
|
|
Err(error) => Err(error.to_string()),
|
|
};
|
|
|
|
let Some(round) = self.rounds.get_mut(&channel) else {
|
|
return;
|
|
};
|
|
|
|
round.running = false;
|
|
let queued = round.queued.take();
|
|
let waiters = std::mem::take(&mut round.waiters);
|
|
|
|
for waiter in waiters {
|
|
if let Err(error) = waiter.try_send(outcome.clone()) {
|
|
log::warn!("community: a channel round result was not delivered: {error}");
|
|
}
|
|
}
|
|
|
|
if let Some(intent) = queued {
|
|
self.start_round(channel, intent, cx);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
fn missing_authority(&self) -> bool {
|
|
self.state.refounders.is_empty() && self.state.roots().len() > 1
|
|
}
|
|
|
|
pub(crate) fn tick(&mut self, cx: &mut Context<Self>) {
|
|
if self.missing_authority() {
|
|
self.rekey(cx);
|
|
}
|
|
|
|
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
|
|
.state
|
|
.cursors
|
|
.get(&channel)
|
|
.copied()
|
|
.unwrap_or_default();
|
|
|
|
let merged = clamped(held.merge(cursor), Timestamp::now());
|
|
|
|
if merged == held {
|
|
return;
|
|
}
|
|
|
|
self.state.cursors.insert(channel, merged);
|
|
self.persist(cx);
|
|
}
|
|
|
|
/// Write the local state document out.
|
|
fn persist(&mut self, cx: &Context<Self>) {
|
|
let client = NostrRegistry::global(cx).read(cx).client();
|
|
let state = self.state.clone();
|
|
|
|
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}");
|
|
}
|
|
|
|
Ok(())
|
|
});
|
|
|
|
self.tasks.push(task);
|
|
}
|
|
|
|
/// The channel's timeline, folded from the local cache, oldest first.
|
|
pub fn timeline(
|
|
&self,
|
|
channel: &ChannelId,
|
|
before_ms: Option<u64>,
|
|
limit: usize,
|
|
cx: &App,
|
|
) -> Task<Result<Timeline>> {
|
|
let client = NostrRegistry::global(cx).read(cx).client();
|
|
let channel = *channel;
|
|
let owner = self.state.owner;
|
|
let community_id = self.state.id;
|
|
let floors = self.state.floors();
|
|
let roles = self.control.roles.clone();
|
|
|
|
cx.background_spawn(async move {
|
|
let until = before_ms.map(|before_ms| Timestamp::from_secs(before_ms / 1000));
|
|
|
|
let rows = cache::query_rumors(
|
|
&client,
|
|
&channel,
|
|
until,
|
|
limit.saturating_add(1),
|
|
Some(&cord03::ROW_KINDS),
|
|
)
|
|
.await?;
|
|
|
|
let has_more = rows.len() > limit;
|
|
|
|
let sides = cache::query_rumors(
|
|
&client,
|
|
&channel,
|
|
until,
|
|
limit.saturating_mul(SIDE_EVENT_FACTOR),
|
|
Some(&cord03::SIDE_KINDS),
|
|
)
|
|
.await?;
|
|
|
|
let mut rumors = Vec::with_capacity(rows.len() + sides.len());
|
|
|
|
for rumor in rows.iter().take(limit).chain(sides.iter()) {
|
|
match cord03::parse_rumor(rumor) {
|
|
Ok(chat) => rumors.push(chat),
|
|
Err(error) => {
|
|
log::warn!("community: skipping an unreadable cached rumor: {error}")
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut messages =
|
|
cord03::fold(&rumors, Timestamp::now(), |actor, citation, author| {
|
|
citation_ok(&owner, &community_id, actor, citation, &floors)
|
|
&& roles.can_act_on_member(
|
|
actor,
|
|
&owner,
|
|
author,
|
|
Permissions::MANAGE_MESSAGES,
|
|
)
|
|
});
|
|
|
|
messages.reverse();
|
|
|
|
Ok(Timeline { messages, has_more })
|
|
})
|
|
}
|
|
|
|
/// Seal a message to the channel plane, cache it, then publish it to the relays.
|
|
pub fn send(
|
|
&self,
|
|
channel: &ChannelId,
|
|
content: &str,
|
|
reply_to: Option<ReplyRef>,
|
|
cx: &App,
|
|
) -> Option<Task<Result<EventId>>> {
|
|
let (epoch, secret) = self.channel_secret(channel)?;
|
|
|
|
let nostr = NostrRegistry::global(cx);
|
|
let client = nostr.read(cx).client();
|
|
let signer = nostr.read(cx).signer();
|
|
let author = nostr.read(cx).current_user()?;
|
|
|
|
let channel = *channel;
|
|
let relays = self.state.relays.clone();
|
|
let timer = self
|
|
.control
|
|
.community
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.message_expiration);
|
|
let content = content.to_owned();
|
|
|
|
Some(cx.background_spawn(async move {
|
|
let group = channel_group_key(&secret, &channel, epoch)?;
|
|
let at_ms = now_ms()?;
|
|
|
|
let rumor = cord03::build_message(
|
|
author,
|
|
&channel,
|
|
epoch,
|
|
&content,
|
|
reply_to.as_ref(),
|
|
at_ms,
|
|
timer,
|
|
);
|
|
let (wrap, _) = cord03::seal_rumor(&rumor, &group, &signer, false).await?;
|
|
|
|
let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?;
|
|
cache::cache_rumor(&client, &channel, &opened).await?;
|
|
|
|
sync::connect_relays(&client, &relays).await;
|
|
sync::publish_wrap(&client, &wrap, &relays).await;
|
|
|
|
Ok(opened.rumor_id)
|
|
}))
|
|
}
|
|
|
|
/// Adopt plane material the account's list now carries.
|
|
///
|
|
/// The caller re-folds afterwards; this only seeds the new planes.
|
|
pub(crate) fn adopt(&mut self, state: CommunityState) {
|
|
// A fold already in flight would write its pre-adoption state back.
|
|
self.refresh_task = None;
|
|
self.dirty = false;
|
|
self.state = state;
|
|
}
|
|
|
|
/// Adopt whatever the rekey watch has delivered, then re-page what it moved.
|
|
pub fn rekey(&mut self, cx: &mut Context<Self>) {
|
|
if self.rekey_task.is_some() {
|
|
self.rekey_dirty = true;
|
|
return;
|
|
}
|
|
|
|
let nostr = NostrRegistry::global(cx);
|
|
let Some(me) = nostr.read(cx).current_user() else {
|
|
return;
|
|
};
|
|
|
|
let client = nostr.read(cx).client();
|
|
let signer = nostr.read(cx).signer();
|
|
let state = self.state.clone();
|
|
let roles = self.control.roles.clone();
|
|
|
|
let task =
|
|
cx.background_spawn(
|
|
async move { rekey::adopt(&client, &state, &roles, &signer, me).await },
|
|
);
|
|
|
|
self.rekey_task = Some(cx.spawn(async move |this, cx| {
|
|
let result = task.await;
|
|
this.update(cx, |this, cx| this.apply_rekey(result, cx))?;
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Rotate one scope to its next epoch, cutting off `excluded`.
|
|
pub fn rotate(
|
|
&mut self,
|
|
scope: RekeyScope,
|
|
recipients: &[PublicKey],
|
|
excluded: &[PublicKey],
|
|
cx: &mut Context<Self>,
|
|
) -> Option<Task<Result<Epoch>>> {
|
|
let nostr = NostrRegistry::global(cx);
|
|
let me = nostr.read(cx).current_user()?;
|
|
|
|
let client = nostr.read(cx).client();
|
|
let signer = nostr.read(cx).signer();
|
|
|
|
if self.state.removed_at.is_some() || self.state.stranded || self.state.banned.contains(&me)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let rewrite = rekey::Rewrite {
|
|
scope,
|
|
recipients: recipients.to_vec(),
|
|
excluded: excluded.to_vec(),
|
|
citation: self.citation(&me),
|
|
};
|
|
|
|
if !rewrite.authorized(&self.control.roles, &self.state.owner, &me) {
|
|
return None;
|
|
}
|
|
|
|
let state = self.state.clone();
|
|
let roles = self.control.roles.clone();
|
|
|
|
Some(cx.spawn(async move |this, cx| {
|
|
let epoch = rekey::rotate(
|
|
&client,
|
|
&state,
|
|
&roles,
|
|
&signer,
|
|
me,
|
|
&rewrite,
|
|
Timestamp::now(),
|
|
)
|
|
.await?;
|
|
|
|
let adoptions = rekey::adopt(&client, &state, &roles, &signer, me).await?;
|
|
|
|
this.update(cx, |this, cx| {
|
|
if !adoptions.is_empty() {
|
|
this.merge_adoptions(adoptions, cx);
|
|
}
|
|
})?;
|
|
|
|
Ok(epoch)
|
|
}))
|
|
}
|
|
|
|
/// The rank this client cites when it acts.
|
|
fn citation(&self, me: &PublicKey) -> Option<AuthorityCitation> {
|
|
let entity = grant_locator(&self.state.id, &me.to_bytes());
|
|
|
|
self.state
|
|
.heads
|
|
.iter()
|
|
.find(|head| head.entity == entity)
|
|
.map(|head| AuthorityCitation {
|
|
entity: head.entity,
|
|
version: head.version,
|
|
hash: head.self_hash,
|
|
})
|
|
}
|
|
|
|
fn apply_rekey(&mut self, result: Result<Adoptions>, cx: &mut Context<Self>) {
|
|
self.rekey_task = None;
|
|
|
|
match result {
|
|
Ok(adoptions) if !adoptions.is_empty() => self.merge_adoptions(adoptions, cx),
|
|
Ok(_) => {}
|
|
Err(error) => cx.emit(CommunityEvent::Error(error.to_string())),
|
|
}
|
|
|
|
if self.rekey_dirty {
|
|
self.rekey_dirty = false;
|
|
self.rekey(cx);
|
|
}
|
|
}
|
|
|
|
/// Fold an adoption into the held state, persist it, and re-page what moved.
|
|
fn merge_adoptions(&mut self, adoptions: Adoptions, cx: &mut Context<Self>) {
|
|
let mut touched: Vec<ChannelId> = Vec::new();
|
|
|
|
if let Some(base) = adoptions.base {
|
|
let mut held = Vec::with_capacity(base.stepped.len() + self.state.held_roots.len());
|
|
|
|
for key in base.stepped {
|
|
let known = self
|
|
.state
|
|
.held_roots
|
|
.iter()
|
|
.chain(held.iter())
|
|
.any(|root| root.epoch == key.epoch && root.key == key.key);
|
|
|
|
if known {
|
|
continue;
|
|
}
|
|
|
|
held.push(HeldRoot {
|
|
epoch: key.epoch,
|
|
key: key.key,
|
|
control_pk: self.state.control_pks.get(&key.epoch.0).copied(),
|
|
retired_at: key.retired_at,
|
|
});
|
|
}
|
|
|
|
for root in &self.state.held_roots {
|
|
if held
|
|
.iter()
|
|
.any(|kept| kept.epoch == root.epoch && kept.key == root.key)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
held.push(*root);
|
|
}
|
|
|
|
self.state.held_roots = held;
|
|
|
|
if let Some(control_pk) = base.control_pk {
|
|
self.state.control_pks.insert(base.epoch.0, control_pk);
|
|
}
|
|
|
|
// The rotation is authoritative about the new epoch's signing root
|
|
self.state.control_root = base.control_root;
|
|
self.state.community_root = base.key;
|
|
self.state.root_epoch = base.epoch;
|
|
self.state.removed_at = None;
|
|
self.state.stranded = false;
|
|
|
|
// Every channel's plane moved with the root.
|
|
touched.extend(self.state.channels.iter().map(|channel| channel.id));
|
|
}
|
|
|
|
for channel in adoptions.channels {
|
|
let Some(held) = self
|
|
.state
|
|
.channels
|
|
.iter_mut()
|
|
.find(|held| held.id == channel.channel)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
// `stepped` carries the key held before the walk plus every epoch it
|
|
// passed through, each with the cutoff its superseding rotation set.
|
|
for key in channel.stepped {
|
|
if !held.priors.iter().any(|prior| prior.epoch == key.epoch) {
|
|
held.priors.push(key);
|
|
}
|
|
}
|
|
|
|
held.key = Some(channel.key);
|
|
held.epoch = channel.epoch;
|
|
held.private = true;
|
|
touched.push(channel.channel);
|
|
}
|
|
|
|
for (channel, epoch) in adoptions.cuts {
|
|
self.state.channels.retain(|held| held.id != channel);
|
|
self.state.channel_cuts.insert(channel, epoch);
|
|
self.state.cursors.remove(&channel);
|
|
}
|
|
|
|
// A rotation that delivered us no key still names the npub that minted the epoch.
|
|
let learned = adoptions
|
|
.refounders
|
|
.into_iter()
|
|
.filter(|refounder| self.state.refounders.insert(*refounder))
|
|
.count();
|
|
|
|
if let Some(epoch) = adoptions.removed_at {
|
|
self.state.removed_at = Some(epoch);
|
|
}
|
|
|
|
if adoptions.stranded {
|
|
self.state.stranded = true;
|
|
}
|
|
|
|
for channel in &touched {
|
|
if let Some(cursor) = self.state.cursors.get_mut(channel) {
|
|
cursor.exhausted = false;
|
|
}
|
|
}
|
|
|
|
self.persist(cx);
|
|
cx.notify();
|
|
cx.emit(CommunityEvent::Updated(self.state.id));
|
|
|
|
if learned > 0 {
|
|
self.refresh(cx);
|
|
}
|
|
|
|
let Some(channel) = self
|
|
.active
|
|
.or_else(|| self.state.channels.first().map(|channel| channel.id))
|
|
else {
|
|
return;
|
|
};
|
|
|
|
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 catch-up after a rekey failed: {error}");
|
|
}
|
|
|
|
Ok(())
|
|
});
|
|
|
|
self.tasks.push(task);
|
|
}
|
|
|
|
/// Rebuilds the community from the wraps in the local database.
|
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
|
if self.refresh_task.is_some() {
|
|
self.dirty = true;
|
|
return;
|
|
}
|
|
|
|
let nostr = NostrRegistry::global(cx);
|
|
let client = nostr.read(cx).client();
|
|
|
|
let state = self.state.clone();
|
|
let folded = cx.background_spawn(async move { sync::fold(&client, &state).await });
|
|
|
|
self.refresh_task = Some(cx.spawn(async move |this, cx| {
|
|
let result = folded.await;
|
|
this.update(cx, |this, cx| this.apply(result, cx))?;
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
fn apply(&mut self, result: Result<Option<Snapshot>>, cx: &mut Context<Self>) {
|
|
self.refresh_task = None;
|
|
|
|
match result {
|
|
Ok(Some(snapshot)) => {
|
|
let mut state = snapshot.state;
|
|
state.cursors = std::mem::take(&mut self.state.cursors);
|
|
self.state = state;
|
|
self.control = snapshot.control;
|
|
self.members = snapshot.members;
|
|
self.load_images(cx);
|
|
|
|
// A fold reads every plane, so its count is the truth for every
|
|
// channel rather than a sample of the region one round read.
|
|
// Replacing it is what lets the count fall again once a key is
|
|
// adopted; a partial round only ever raises it.
|
|
let reported = !snapshot.unreadable.is_empty();
|
|
self.unreadable = snapshot.unreadable;
|
|
|
|
if reported {
|
|
cx.emit(CommunityEvent::Unreadable(self.state.id));
|
|
}
|
|
|
|
cx.emit(CommunityEvent::Updated(self.state.id));
|
|
cx.notify();
|
|
}
|
|
Ok(None) => {}
|
|
Err(error) => cx.emit(CommunityEvent::Error(error.to_string())),
|
|
}
|
|
|
|
if self.dirty {
|
|
self.dirty = false;
|
|
self.refresh(cx);
|
|
}
|
|
}
|
|
|
|
/// Resolve the folded icon and banner into local files.
|
|
fn load_images(&mut self, cx: &mut Context<Self>) {
|
|
let (icon, banner) = match self.control.community.as_ref() {
|
|
Some(metadata) => (metadata.icon.clone(), metadata.banner.clone()),
|
|
None => (None, None),
|
|
};
|
|
|
|
self.load_icon(icon, cx);
|
|
self.load_banner(banner, cx);
|
|
}
|
|
|
|
fn load_icon(&mut self, icon: Option<ImageRef>, cx: &mut Context<Self>) {
|
|
if self.icon_ref == icon {
|
|
return;
|
|
}
|
|
|
|
self.icon_ref = icon.clone();
|
|
self.icon = None;
|
|
|
|
let Some(icon) = icon else {
|
|
return;
|
|
};
|
|
|
|
self.icon_task = Some(cx.spawn(async move |this, cx| {
|
|
match sync::resolve_image(&icon, cx).await {
|
|
Ok(path) => {
|
|
this.update(cx, |this, cx| {
|
|
this.icon = Some(path);
|
|
cx.notify();
|
|
})?;
|
|
}
|
|
Err(error) => log::warn!("community icon: {error}"),
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
fn load_banner(&mut self, banner: Option<ImageRef>, cx: &mut Context<Self>) {
|
|
if self.banner_ref == banner {
|
|
return;
|
|
}
|
|
|
|
self.banner_ref = banner.clone();
|
|
self.banner = None;
|
|
|
|
let Some(banner) = banner else {
|
|
return;
|
|
};
|
|
|
|
self.banner_task = Some(cx.spawn(async move |this, cx| {
|
|
match sync::resolve_image(&banner, cx).await {
|
|
Ok(path) => {
|
|
this.update(cx, |this, cx| {
|
|
this.banner = Some(path);
|
|
cx.notify();
|
|
})?;
|
|
}
|
|
Err(error) => log::warn!("community banner: {error}"),
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
}
|
|
|
|
/// One round's progress and the cursor material it earned.
|
|
type RoundOutcome = (Progress, ChannelCursor);
|
|
|
|
/// A cursor as a filter bound may use it.
|
|
fn clamped(cursor: ChannelCursor, now: Timestamp) -> ChannelCursor {
|
|
ChannelCursor {
|
|
newest: cursor.newest.map(|newest| newest.min(now)),
|
|
..cursor
|
|
}
|
|
}
|
|
|
|
/// The wall clock in epoch milliseconds: a rumor's `ms` tag carries the part of
|
|
/// the second the message was written in, and the fold orders rows by it.
|
|
fn now_ms() -> Result<u64> {
|
|
let elapsed = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_err(|error| anyhow::anyhow!("the system clock is before the Unix epoch: {error}"))?;
|
|
|
|
u64::try_from(elapsed.as_millis())
|
|
.map_err(|error| anyhow::anyhow!("the system clock is out of range: {error}"))
|
|
}
|
|
|
|
/// Read a channel's history from the community's relays, in three passes.
|
|
async fn sync_round(
|
|
client: &Client,
|
|
pages: &PageRegistry,
|
|
channel: &ChannelId,
|
|
held: &[HeldKey],
|
|
relays: &[RelayUrl],
|
|
saved: ChannelCursor,
|
|
intent: Intent,
|
|
) -> Result<RoundOutcome> {
|
|
let mut progress = Progress::default();
|
|
let mut round = ChannelCursor::default();
|
|
|
|
// Expired rows drop at fold time otherwise, never from disk.
|
|
let purged = cache::purge_expired(client, channel, Timestamp::now()).await?;
|
|
|
|
if purged > 0 {
|
|
log::debug!(
|
|
"community: purged {purged} expired rumor(s) from {}",
|
|
channel.to_hex()
|
|
);
|
|
}
|
|
|
|
let newest = match intent {
|
|
Intent::CatchUp => {
|
|
let page = history::page(
|
|
client,
|
|
pages,
|
|
channel,
|
|
held,
|
|
relays,
|
|
Window::opening(saved),
|
|
1,
|
|
PAGE_WRAPS,
|
|
)
|
|
.await?;
|
|
absorb(&mut progress, &page);
|
|
page
|
|
}
|
|
Intent::Older { .. } => WrapPage::default(),
|
|
};
|
|
|
|
let bridge = match (intent, newest.oldest, saved.newest) {
|
|
(Intent::CatchUp, Some(oldest), Some(saved_newest)) if oldest > saved_newest => {
|
|
let page = history::page(
|
|
client,
|
|
pages,
|
|
channel,
|
|
held,
|
|
relays,
|
|
Window::between(saved_newest, oldest),
|
|
CATCH_UP_PAGES,
|
|
PAGE_WRAPS,
|
|
)
|
|
.await?;
|
|
absorb(&mut progress, &page);
|
|
page
|
|
}
|
|
// Nothing to bridge, so the newest region is already complete.
|
|
_ => WrapPage {
|
|
exhausted: true,
|
|
..WrapPage::default()
|
|
},
|
|
};
|
|
|
|
let resume = match intent {
|
|
Intent::CatchUp => saved.oldest.or(newest.oldest),
|
|
Intent::Older { .. } => saved.oldest,
|
|
};
|
|
|
|
let budget = match intent {
|
|
Intent::CatchUp => CATCH_UP_PAGES,
|
|
Intent::Older { pages } => pages,
|
|
};
|
|
|
|
// A channel already swept to the bottom has nothing older to ask for.
|
|
let older = match (saved.exhausted, resume) {
|
|
(true, _) => WrapPage {
|
|
exhausted: true,
|
|
..WrapPage::default()
|
|
},
|
|
(false, Some(until)) => {
|
|
let page = history::page(
|
|
client,
|
|
pages,
|
|
channel,
|
|
held,
|
|
relays,
|
|
Window::older_than(until),
|
|
budget,
|
|
PAGE_WRAPS,
|
|
)
|
|
.await?;
|
|
absorb(&mut progress, &page);
|
|
page
|
|
}
|
|
(false, None) => WrapPage::default(),
|
|
};
|
|
|
|
if intent == Intent::CatchUp {
|
|
let complete = !newest.failed && bridge.exhausted;
|
|
let top = newest
|
|
.newest
|
|
.unwrap_or_default()
|
|
.max(bridge.newest.unwrap_or_default());
|
|
|
|
if complete && !top.is_zero() {
|
|
round.newest = Some(top);
|
|
}
|
|
}
|
|
|
|
round.oldest = older.oldest.or(newest.oldest);
|
|
round.exhausted = older.exhausted;
|
|
|
|
Ok((progress, round))
|
|
}
|
|
|
|
fn absorb(progress: &mut Progress, page: &WrapPage) {
|
|
progress.unreadable += page.unreadable;
|
|
progress.failed |= page.failed;
|
|
progress.errors += page.errors;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A stamp ahead of the local clock must never become a cursor.
|
|
///
|
|
/// A durable `newest` past `now` bounds every later REQ below a region that
|
|
/// has not happened yet, so the channel receives nothing at all — not live,
|
|
/// not by round — while its older history keeps working, which is exactly
|
|
/// what a peer with a fast clock (or a hostile stamp) would cause.
|
|
#[test]
|
|
fn a_future_stamp_cannot_push_a_cursor_past_now() {
|
|
let now = Timestamp::now();
|
|
let held = ChannelCursor {
|
|
newest: Some(now - Duration::from_secs(30)),
|
|
oldest: Some(now - Duration::from_secs(90)),
|
|
exhausted: false,
|
|
};
|
|
let round = ChannelCursor {
|
|
newest: Some(now + Duration::from_secs(86_400)),
|
|
oldest: None,
|
|
exhausted: true,
|
|
};
|
|
|
|
let merged = clamped(held.merge(round), now);
|
|
|
|
assert_eq!(merged.newest, Some(now), "the frontier stops at the clock");
|
|
assert_eq!(merged.oldest, Some(now - Duration::from_secs(90)));
|
|
assert!(merged.exhausted, "the round's other findings still land");
|
|
|
|
// A cursor already stored past `now` heals instead of staying deaf.
|
|
let poisoned = ChannelCursor {
|
|
newest: Some(now + Duration::from_secs(86_400)),
|
|
..ChannelCursor::default()
|
|
};
|
|
|
|
assert_eq!(clamped(poisoned, now).newest, Some(now));
|
|
}
|
|
|
|
/// 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(),
|
|
refounders: BTreeSet::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, Epoch(0));
|
|
assert_eq!(community(cut).channel_secret(&channel), None);
|
|
}
|
|
}
|