use nostr_sdk::prelude::{Client, EventBuilder, Filter, Kind, NostrSigner, PublicKey, Tag, Timestamp};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::stored_event::event_kind;
pub const COMMUNITY_LIST_D_TAG: &str = "vector/communities";
const LOCAL_LIST_KEY: &str = "community_list_json";
const LIST_PUBLISHED_AT_KEY: &str = "community_list_published_at";
const FETCH_TIMEOUT_SECS: u64 = 20;
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Debug)]
pub struct CommunityListEntry {
pub community_id: String,
pub seed: super::invite::CommunityInvite,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current: Option<super::invite::CommunityInvite>,
pub added_at: u64,
}
impl CommunityListEntry {
pub fn current(&self) -> &super::invite::CommunityInvite {
self.current.as_ref().unwrap_or(&self.seed)
}
pub fn seed_epoch(&self) -> u64 {
self.seed.server_root_epoch
}
pub fn current_epoch(&self) -> u64 {
self.current().server_root_epoch
}
pub fn relays(&self) -> &[String] {
&self.current().relays
}
}
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Debug)]
pub struct CommunityRemoval {
pub community_id: String,
pub removed_at: u64,
}
#[derive(Clone, Serialize, Deserialize, Default, Debug, PartialEq, Eq)]
pub struct CommunityList {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<CommunityListEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tombstones: Vec<CommunityRemoval>,
}
fn canonical(inv: &super::invite::CommunityInvite) -> String {
serde_json::to_string(inv).unwrap_or_default()
}
fn strip_icon(inv: &mut super::invite::CommunityInvite) {
inv.icon = None;
}
fn current_supersedes(a: &super::invite::CommunityInvite, b: &super::invite::CommunityInvite) -> bool {
use std::cmp::Ordering::*;
match a.server_root_epoch.cmp(&b.server_root_epoch) {
Greater => true,
Less => false,
Equal => canonical(a) < canonical(b),
}
}
fn seed_supersedes(a: &super::invite::CommunityInvite, b: &super::invite::CommunityInvite) -> bool {
use std::cmp::Ordering::*;
match a.server_root_epoch.cmp(&b.server_root_epoch) {
Less => true,
Greater => false,
Equal => canonical(a) < canonical(b),
}
}
impl CommunityList {
pub fn from_json(s: &str) -> Self {
let mut list: Self = serde_json::from_str(s).unwrap_or_default();
for e in &mut list.entries {
strip_icon(&mut e.seed);
match e.current.as_mut() {
Some(c) => strip_icon(c),
None => e.current = Some(e.seed.clone()),
}
}
list
}
pub fn to_json(&self) -> String {
serde_json::to_string(&self.slimmed()).unwrap_or_else(|_| "{}".to_string())
}
fn slimmed(&self) -> CommunityList {
let entries = self
.entries
.iter()
.map(|e| {
let mut e = e.clone();
if e.current.as_ref() == Some(&e.seed) {
e.current = None;
}
e
})
.collect();
CommunityList { entries, tombstones: self.tombstones.clone() }
}
pub fn contains(&self, community_id: &str) -> bool {
self.entries.iter().any(|e| e.community_id == community_id)
}
pub fn tombstone_suppresses_at(&self, community_id: &str, invite_created_at_secs: u64) -> bool {
let invite_ms = invite_created_at_secs.saturating_mul(1000);
self.tombstones
.iter()
.any(|t| t.community_id == community_id && t.removed_at >= invite_ms)
}
pub fn upsert(&mut self, mut entry: CommunityListEntry) {
strip_icon(&mut entry.seed);
if let Some(c) = entry.current.as_mut() {
strip_icon(c);
}
self.tombstones.retain(|t| !(t.community_id == entry.community_id && t.removed_at <= entry.added_at));
if let Some(cur) = self.entries.iter_mut().find(|e| e.community_id == entry.community_id) {
let take_current = current_supersedes(entry.current(), cur.current());
let take_seed = seed_supersedes(&entry.seed, &cur.seed);
if take_current {
cur.current = Some(entry.current().clone());
}
if take_seed {
cur.seed = entry.seed;
}
cur.added_at = cur.added_at.max(entry.added_at);
} else {
self.entries.push(entry);
}
}
pub fn remove(&mut self, community_id: &str, removed_at: u64) {
self.entries.retain(|e| e.community_id != community_id);
match self.tombstones.iter_mut().find(|t| t.community_id == community_id) {
Some(t) => t.removed_at = t.removed_at.max(removed_at),
None => self.tombstones.push(CommunityRemoval { community_id: community_id.to_string(), removed_at }),
}
}
pub fn refresh_current(&mut self, community_id: &str, mut current: super::invite::CommunityInvite) -> bool {
strip_icon(&mut current); if let Some(e) = self.entries.iter_mut().find(|e| e.community_id == community_id) {
if current_supersedes(¤t, e.current()) {
e.current = Some(current);
return true;
}
}
false
}
pub fn is_ahead_of(&self, relay: &CommunityList) -> bool {
let empty = CommunityList::default();
relay.merge(self) != relay.merge(&empty)
}
pub fn merge(&self, other: &CommunityList) -> CommunityList {
let mut adds: HashMap<String, CommunityListEntry> = HashMap::new();
for e in self.entries.iter().chain(other.entries.iter()) {
match adds.get_mut(&e.community_id) {
Some(cur) => {
if current_supersedes(e.current(), cur.current()) {
cur.current = Some(e.current().clone());
}
if seed_supersedes(&e.seed, &cur.seed) {
cur.seed = e.seed.clone();
}
cur.added_at = cur.added_at.max(e.added_at);
}
None => { adds.insert(e.community_id.clone(), e.clone()); }
}
}
let mut rms: HashMap<String, u64> = HashMap::new();
for t in self.tombstones.iter().chain(other.tombstones.iter()) {
let slot = rms.entry(t.community_id.clone()).or_insert(0);
*slot = (*slot).max(t.removed_at);
}
let mut entries: Vec<CommunityListEntry> = Vec::new();
let mut tombstones: Vec<CommunityRemoval> = Vec::new();
for (cid, e) in adds {
let removed_at = rms.get(&cid).copied().unwrap_or(0);
if e.added_at >= removed_at {
entries.push(e); } else {
tombstones.push(CommunityRemoval { community_id: cid, removed_at });
}
}
for (cid, removed_at) in rms {
if !entries.iter().any(|e| e.community_id == cid)
&& !tombstones.iter().any(|t| t.community_id == cid)
{
tombstones.push(CommunityRemoval { community_id: cid, removed_at });
}
}
entries.sort_by(|a, b| a.community_id.cmp(&b.community_id));
tombstones.sort_by(|a, b| a.community_id.cmp(&b.community_id));
CommunityList { entries, tombstones }
}
}
impl CommunityListEntry {
pub fn from_community(community: &super::Community, added_at_ms: u64) -> Self {
let mut bundle = super::invite::build_invite(community);
strip_icon(&mut bundle); CommunityListEntry {
community_id: community.id.to_hex(),
current: Some(bundle.clone()),
seed: bundle,
added_at: added_at_ms,
}
}
}
pub fn load_local_list() -> CommunityList {
crate::db::settings::get_sql_setting(LOCAL_LIST_KEY.to_string())
.ok()
.flatten()
.map(|s| CommunityList::from_json(&s))
.unwrap_or_default()
}
pub fn save_local_list(list: &CommunityList) -> Result<(), String> {
crate::db::settings::set_sql_setting(LOCAL_LIST_KEY.to_string(), list.to_json())
}
pub fn our_last_community_publish() -> u64 {
crate::db::settings::get_sql_setting(LIST_PUBLISHED_AT_KEY.to_string())
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
fn stamp_published_now() {
let _ = crate::db::settings::set_sql_setting(
LIST_PUBLISHED_AT_KEY.to_string(),
Timestamp::now().as_secs().to_string(),
);
}
pub async fn fetch_community_list(
client: &Client,
my_pubkey: PublicKey,
session: crate::state::SessionGuard,
) -> Result<CommunityList, String> {
let filter = Filter::new()
.author(my_pubkey)
.kind(Kind::Custom(event_kind::APPLICATION_SPECIFIC))
.identifier(COMMUNITY_LIST_D_TAG)
.limit(1);
let events = client
.fetch_events(filter, std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
.await
.map_err(|e| format!("fetch community list (kind 30078): {}", e))?;
if !session.is_valid() {
return Ok(load_local_list());
}
let our_last_publish: u64 = crate::db::settings::get_sql_setting(LIST_PUBLISHED_AT_KEY.to_string())
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let latest = events.into_iter().max_by_key(|e| e.created_at);
let list = match latest {
Some(ev) if ev.created_at.as_secs() < our_last_publish => {
crate::log_debug!(
"[CommunityList] relay copy (created_at {}) predates our publish ({}) — keeping local",
ev.created_at.as_secs(), our_last_publish,
);
load_local_list()
}
Some(ev) => decrypt_list_event(client, &my_pubkey, &ev).await,
None => {
crate::log_debug!("[CommunityList] no list on relays — using local mirror");
load_local_list()
}
};
Ok(list)
}
async fn decrypt_list_event(client: &Client, my_pk: &PublicKey, event: &nostr_sdk::prelude::Event) -> CommunityList {
if event.content.is_empty() {
return CommunityList::default();
}
let signer = match client.signer().await {
Ok(s) => s,
Err(e) => {
crate::log_warn!("[CommunityList] signer unavailable for decrypt: {}", e);
return CommunityList::default();
}
};
match signer.nip44_decrypt(my_pk, &event.content).await {
Ok(plaintext) => CommunityList::from_json(&plaintext),
Err(e) => {
crate::log_warn!("[CommunityList] decrypt failed: {}", e);
CommunityList::default()
}
}
}
pub async fn publish_community_list(
client: &Client,
session: crate::state::SessionGuard,
) -> Result<(), String> {
let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
let relay = fetch_community_list(client, my_pk, session.clone()).await.unwrap_or_default();
if !session.is_valid() {
return Ok(());
}
let merged = load_local_list().merge(&relay);
save_local_list(&merged)?;
let signer = client.signer().await.map_err(|e| format!("Signer unavailable: {}", e))?;
let content = signer
.nip44_encrypt(&my_pk, &merged.to_json())
.await
.map_err(|e| format!("nip44 encrypt community list: {}", e))?;
let builder = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), content)
.tag(Tag::identifier(COMMUNITY_LIST_D_TAG));
client
.send_event_builder(builder)
.await
.map_err(|e| format!("Failed to publish community list (kind 30078): {}", e))?;
crate::log_info!(
"[CommunityList] Published encrypted list: {} membership(s), {} tombstone(s)",
merged.entries.len(), merged.tombstones.len(),
);
Ok(())
}
pub async fn ingest_remote_list_event(
client: &Client,
my_pk: &PublicKey,
event: &nostr_sdk::prelude::Event,
session: crate::state::SessionGuard,
) -> Result<CommunityList, String> {
let incoming = decrypt_list_event(client, my_pk, event).await;
if !session.is_valid() {
return Ok(load_local_list());
}
let merged = load_local_list().merge(&incoming);
save_local_list(&merged)?;
Ok(merged)
}
static REPUBLISH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub fn republish_community_list_debounced() {
use std::sync::atomic::Ordering;
stamp_published_now();
let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
let session = crate::state::SessionGuard::capture();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
if !session.is_valid() { return; }
let client = match crate::state::nostr_client() {
Some(c) => c,
None => return,
};
if let Err(e) = publish_community_list(&client, session.clone()).await {
crate::log_warn!("[CommunityList] Republish failed: {} (retrying in 5s)", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
if !session.is_valid() { return; }
if let Err(e) = publish_community_list(&client, session).await {
crate::log_warn!("[CommunityList] Republish retry failed: {}", e);
}
}
});
}
pub fn backfill_from_db() {
let ids = match crate::db::community::list_community_ids() {
Ok(ids) => ids,
Err(e) => {
crate::log_warn!("[CommunityList] backfill: list_community_ids failed: {}", e);
return;
}
};
let mut list = load_local_list();
let mut changed = false;
for id in ids {
let hex = id.to_hex();
if list.contains(&hex) {
continue; }
if list.tombstones.iter().any(|t| t.community_id == hex) {
continue;
}
if let Ok(Some(community)) = crate::db::community::load_community(&id) {
list.upsert(CommunityListEntry::from_community(&community, now_ms()));
changed = true;
}
}
if changed {
if let Err(e) = save_local_list(&list) {
crate::log_warn!("[CommunityList] backfill save failed: {}", e);
}
}
}
pub fn membership_added_at(community_id: &str) -> Option<u64> {
load_local_list()
.entries
.iter()
.find(|e| e.community_id == community_id)
.map(|e| e.added_at)
}
pub fn add_membership(community: &super::Community) {
let mut list = load_local_list();
list.upsert(CommunityListEntry::from_community(community, now_ms()));
if let Err(e) = save_local_list(&list) {
crate::log_warn!("[CommunityList] save after add failed: {}", e);
return;
}
republish_community_list_debounced();
}
pub fn remove_membership(community_id: &str) {
let mut list = load_local_list();
list.remove(community_id, now_ms());
if let Err(e) = save_local_list(&list) {
crate::log_warn!("[CommunityList] save after remove failed: {}", e);
return;
}
republish_community_list_debounced();
}
pub fn tombstone_local_only(community_id: &str) {
let mut list = load_local_list();
list.remove(community_id, now_ms());
if let Err(e) = save_local_list(&list) {
crate::log_warn!("[CommunityList] local-only tombstone save failed: {}", e);
}
}
pub fn tombstone_suppresses(community_id: &str, invite_created_at_secs: u64) -> bool {
load_local_list().tombstone_suppresses_at(community_id, invite_created_at_secs)
}
pub fn refresh_membership_current(community: &super::Community) {
let cid = community.id.to_hex();
let mut list = load_local_list();
if !list.contains(&cid) {
return;
}
let snapshot = super::invite::build_invite(community);
if !list.refresh_current(&cid, snapshot) {
return; }
if let Err(e) = save_local_list(&list) {
crate::log_warn!("[CommunityList] save after refresh failed: {}", e);
return;
}
republish_community_list_debounced();
}
pub enum RehydrateOutcome {
Rehydrated(super::Community),
AlreadyHeld(super::Community),
Removed,
}
pub async fn rehydrate_community_from_seed<T: super::transport::Transport + ?Sized>(
transport: &T,
entry: &CommunityListEntry,
session: crate::state::SessionGuard,
) -> Result<RehydrateOutcome, String> {
if entry.community_id.len() != 64 || !entry.community_id.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!("invalid community id in list entry: {}", entry.community_id));
}
let id = super::CommunityId(crate::simd::hex::hex_to_bytes_32(&entry.community_id));
if let Some(existing) = crate::db::community::load_community(&id)? {
return Ok(RehydrateOutcome::AlreadyHeld(existing));
}
let community = super::service::accept_invite(entry.current())?;
if !session.is_valid() {
return Err("account changed during rehydrate".to_string());
}
match super::service::catch_up_server_root(transport, &community).await {
Ok(c) if c.removed => return Ok(RehydrateOutcome::Removed),
Ok(_) => {}
Err(e) => {
if session.is_valid() {
let _ = crate::db::community::delete_community_retain_keys(&entry.community_id);
}
return Err(e);
}
}
let community = crate::db::community::load_community(&id)?.unwrap_or(community);
let _ = super::service::fetch_and_apply_control(transport, &community).await;
let community = crate::db::community::load_community(&id)?.unwrap_or(community);
for ch in &community.channels {
let _ = super::service::catch_up_channel_rekeys(transport, &community, &ch.id).await;
}
if !session.is_valid() {
return Err("account changed during rehydrate".to_string());
}
let community = crate::db::community::load_community(&id)?.unwrap_or(community);
Ok(RehydrateOutcome::Rehydrated(community))
}
pub async fn backfill_history_from_seed<T: super::transport::Transport + ?Sized>(
transport: &T,
entry: &CommunityListEntry,
session: crate::state::SessionGuard,
) -> Result<bool, String> {
if entry.current().server_root_epoch <= entry.seed.server_root_epoch {
return Ok(false);
}
let id = super::CommunityId(crate::simd::hex::hex_to_bytes_32(&entry.community_id));
if crate::db::community::load_community(&id)?.is_none() {
return Ok(false);
}
let seed_view = super::invite::accept_invite(&entry.seed)?;
if !session.is_valid() {
return Ok(false);
}
let _ = crate::db::community::store_epoch_key(
&entry.community_id, crate::community::SERVER_ROOT_SCOPE_HEX,
seed_view.server_root_epoch.0, seed_view.server_root_key.as_bytes(),
);
for ch in &seed_view.channels {
let _ = crate::db::community::store_epoch_key(
&entry.community_id, &ch.id.to_hex(), ch.epoch.0, ch.key.as_bytes(),
);
}
let _ = super::service::catch_up_server_root(transport, &seed_view).await;
if !session.is_valid() {
return Ok(false);
}
for ch in &seed_view.channels {
if !session.is_valid() {
return Ok(false);
}
let _ = super::service::catch_up_channel_rekeys(transport, &seed_view, &ch.id).await;
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn bundle(cid: &str, epoch: u64, root: &str) -> crate::community::invite::CommunityInvite {
crate::community::invite::CommunityInvite {
community_id: cid.into(),
name: "T".into(),
server_root_key: root.into(),
server_root_epoch: epoch,
relays: vec!["r1".into()],
channels: vec![],
owner_attestation: None,
icon: None,
}
}
fn entry(cid: &str, seed_epoch: u64, current_epoch: u64, added_at: u64) -> CommunityListEntry {
CommunityListEntry {
community_id: cid.into(),
seed: bundle(cid, seed_epoch, &format!("seed{seed_epoch}")),
current: Some(bundle(cid, current_epoch, &format!("cur{current_epoch}"))),
added_at,
}
}
#[test]
fn merge_keeps_freshest_current_root_and_earliest_seed() {
let a = CommunityList { entries: vec![entry("X", 5, 8, 100)], tombstones: vec![] };
let b = CommunityList { entries: vec![entry("X", 5, 11, 100)], tombstones: vec![] };
let m = a.merge(&b);
assert_eq!(m.entries.len(), 1);
assert_eq!(m.entries[0].current_epoch(), 11, "freshest current_root wins");
assert_eq!(m.entries[0].seed_epoch(), 5, "seed stays the earliest/join root");
}
#[test]
fn remove_newer_than_add_buries_membership() {
let a = CommunityList { entries: vec![entry("X", 5, 8, 100)], tombstones: vec![] };
let mut b = CommunityList::default();
b.remove("X", 200);
let m = a.merge(&b);
assert!(!m.contains("X"), "a removal newer than the add buries the community");
assert!(m.tombstones.iter().any(|t| t.community_id == "X"));
}
#[test]
fn decline_tombstone_suppresses_old_invite_not_newer() {
let list = CommunityList { entries: vec![], tombstones: vec![CommunityRemoval {
community_id: "X".into(), removed_at: 5_000_000,
}] };
assert!(list.tombstone_suppresses_at("X", 5000), "same-time re-delivery suppressed");
assert!(list.tombstone_suppresses_at("X", 4999), "older invite suppressed");
assert!(!list.tombstone_suppresses_at("X", 5001), "newer invite resurfaces");
assert!(!list.tombstone_suppresses_at("Y", 1), "other community unaffected");
}
#[test]
fn add_newer_than_removal_resurrects_on_rejoin() {
let mut a = CommunityList::default();
a.remove("X", 100);
let b = CommunityList { entries: vec![entry("X", 5, 9, 200)], tombstones: vec![] };
let m = a.merge(&b);
assert!(m.contains("X"), "a re-join newer than the tombstone resurrects the community");
assert!(!m.tombstones.iter().any(|t| t.community_id == "X"), "stale tombstone dropped");
}
#[test]
fn merge_is_deterministic_and_order_independent() {
let a = CommunityList { entries: vec![entry("B", 1, 3, 50), entry("A", 2, 4, 60)], tombstones: vec![] };
let b = CommunityList { entries: vec![entry("A", 2, 5, 60)], tombstones: vec![] };
assert_eq!(a.merge(&b), b.merge(&a), "merge is commutative");
assert_eq!(a.merge(&b).entries[0].community_id, "A", "entries sorted deterministically");
}
#[test]
fn upsert_resurrects_over_old_tombstone_keeps_stable_seed() {
let mut list = CommunityList::default();
list.remove("X", 100);
list.upsert(entry("X", 5, 5, 200)); assert!(list.contains("X"));
assert!(!list.tombstones.iter().any(|t| t.community_id == "X"));
list.refresh_current("X", bundle("X", 12, "cur12"));
let e = list.entries.iter().find(|e| e.community_id == "X").unwrap();
assert_eq!(e.current_epoch(), 12);
assert_eq!(e.seed_epoch(), 5, "seed root stays the join root through refreshes");
}
#[test]
fn round_trips_through_json() {
let list = CommunityList { entries: vec![entry("X", 5, 9, 100)], tombstones: vec![CommunityRemoval { community_id: "Y".into(), removed_at: 50 }] };
assert_eq!(CommunityList::from_json(&list.to_json()), list);
assert_eq!(CommunityList::from_json("garbage"), CommunityList::default());
}
#[test]
fn merge_is_deterministic_on_equal_epoch_ties() {
let mut a_entry = entry("X", 0, 0, 100);
a_entry.seed.server_root_key = "bbbb".into();
a_entry.current = Some(bundle("X", 0, "rootB"));
let mut b_entry = entry("X", 0, 0, 100);
b_entry.seed.server_root_key = "aaaa".into();
b_entry.current = Some(bundle("X", 0, "rootA"));
let a = CommunityList { entries: vec![a_entry], tombstones: vec![] };
let b = CommunityList { entries: vec![b_entry], tombstones: vec![] };
assert_eq!(a.merge(&b), b.merge(&a), "equal-epoch merge must be commutative");
let m = a.merge(&b);
assert_eq!(m.entries[0].seed.server_root_key, "aaaa", "tie → lowest seed root wins");
assert_eq!(m.entries[0].current().server_root_key, "rootA", "tie → lowest current root wins");
}
#[test]
fn merge_converges_on_same_epoch_same_root_rename() {
let mut a = entry("X", 0, 0, 100);
a.current = Some({ let mut b = bundle("X", 0, "root"); b.name = "Bbb".into(); b });
let mut b = entry("X", 0, 0, 100);
b.current = Some({ let mut x = bundle("X", 0, "root"); x.name = "Aaa".into(); x });
let la = CommunityList { entries: vec![a], tombstones: vec![] };
let lb = CommunityList { entries: vec![b], tombstones: vec![] };
assert_eq!(la.merge(&lb), lb.merge(&la), "same-epoch+root rename must converge (commutative)");
assert_eq!(la.merge(&lb).entries[0].current().name, "Aaa", "tie → lowest canonical wins deterministically");
}
#[test]
fn refresh_current_agrees_with_merge_no_flap() {
let mut list = CommunityList { entries: vec![entry("X", 0, 0, 100)], tombstones: vec![] };
assert!(list.refresh_current("X", bundle("X", 1, "cur1")));
let mut lower = bundle("X", 1, "cur1"); lower.name = "zzz_loses".into();
assert!(!list.refresh_current("X", lower), "a same-epoch snapshot that loses the merge order is not installed");
}
#[test]
fn is_ahead_of_only_when_local_has_more() {
let relay = CommunityList { entries: vec![entry("A", 0, 0, 100)], tombstones: vec![] };
let same = CommunityList { entries: vec![entry("A", 0, 0, 100)], tombstones: vec![] };
assert!(!same.is_ahead_of(&relay), "in-sync local must not trigger a boot publish");
let more = CommunityList { entries: vec![entry("A", 0, 0, 100), entry("B", 0, 0, 100)], tombstones: vec![] };
assert!(more.is_ahead_of(&relay), "a local-only membership must trigger a publish");
let mut tomb = same.clone();
tomb.remove("A", 200);
assert!(tomb.is_ahead_of(&relay), "a local-only removal must trigger a publish");
assert!(!relay.is_ahead_of(&more), "when the relay knows more, boot must not write");
}
#[test]
fn entry_seed_carries_channel_keys_and_survives_json() {
let community = super::super::Community::create("HQ", "general", vec!["wss://r1".into()]);
let want_key = crate::simd::hex::bytes_to_hex_32(community.channels[0].key.as_bytes());
let e = CommunityListEntry::from_community(&community, 1234);
assert_eq!(e.seed.channels.len(), 1, "seed carries the channel");
assert_eq!(e.seed.channels[0].key, want_key, "seed carries the channel KEY, not just the id");
let list = CommunityList { entries: vec![e.clone()], tombstones: vec![] };
let round = CommunityList::from_json(&list.to_json());
assert_eq!(round.entries[0].seed.channels[0].key, want_key);
}
#[test]
fn merge_keeps_the_earliest_seed_bundle_with_its_channel_keys() {
let mut a_entry = entry("X", 2, 2, 100);
a_entry.seed.channels = vec![crate::community::invite::InviteChannel {
id: "chan".into(), key: "earlykey".into(), epoch: 2, name: "general".into(),
}];
let mut b_entry = entry("X", 5, 9, 100);
b_entry.seed.channels = vec![crate::community::invite::InviteChannel {
id: "chan".into(), key: "latekey".into(), epoch: 5, name: "general".into(),
}];
let a = CommunityList { entries: vec![a_entry], tombstones: vec![] };
let b = CommunityList { entries: vec![b_entry], tombstones: vec![] };
let m = a.merge(&b);
assert_eq!(m.entries[0].seed_epoch(), 2, "earliest seed bundle wins");
assert_eq!(m.entries[0].seed.channels[0].key, "earlykey", "earliest bundle's channel keys kept");
assert_eq!(m.entries[0].current_epoch(), 9, "but the freshest current snapshot still wins");
}
#[test]
fn refresh_current_updates_snapshot_on_rekey_and_rename() {
let mut list = CommunityList { entries: vec![entry("X", 0, 0, 100)], tombstones: vec![] };
assert!(list.refresh_current("X", bundle("X", 1, "epoch1root")));
assert_eq!(list.entries[0].current_epoch(), 1);
assert_eq!(list.entries[0].current().server_root_key, "epoch1root");
let mut renamed = bundle("X", 1, "epoch1root");
renamed.name = "Renamed".into();
assert!(list.refresh_current("X", renamed));
assert_eq!(list.entries[0].current().name, "Renamed");
let same = { let mut b = bundle("X", 1, "epoch1root"); b.name = "Renamed".into(); b };
assert!(!list.refresh_current("X", same));
assert!(!list.refresh_current("X", bundle("X", 0, "oldroot")));
assert_eq!(list.entries[0].current_epoch(), 1, "a stale lower-epoch snapshot is rejected");
}
}