feat: add community ui (#52)

Reviewed-on: reya/coop#52
This commit is contained in:
2026-09-23 01:12:51 +00:00
parent 6ff1eeddbb
commit 1032ac3751
80 changed files with 12888 additions and 3143 deletions
+8 -15
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
use instant::Duration;
@@ -35,7 +35,7 @@ fn uses_managed_updates() -> bool {
}
/// Initialize the auto-update system.
pub fn init(window: &mut Window, cx: &mut App) {
pub fn init(cx: &mut App) {
if uses_managed_updates() {
log::info!(
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
@@ -60,10 +60,7 @@ pub fn init(window: &mut Window, cx: &mut App) {
return;
};
AutoUpdater::set_global(
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
cx,
);
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(version, filter, cx)), cx);
}
struct GlobalAutoUpdater(Entity<AutoUpdater>);
@@ -103,21 +100,17 @@ impl AutoUpdater {
cx.set_global(GlobalAutoUpdater(state));
}
fn new(
window: &mut Window,
version: Version,
filter: AssetFilter,
cx: &mut Context<Self>,
) -> Self {
fn new(version: Version, filter: AssetFilter, cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
let engine = Arc::new(UpdateEngine::new(source, config));
// Schedule an auto-check after a 2-minute delay
cx.defer_in(window, |_this, _window, cx| {
cx.spawn(async move |this, cx| {
cx.defer(move |cx| {
cx.spawn(async move |cx| {
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
this.update(cx, |this, cx| this.check(cx)).ok();
entity.update(cx, |this, cx| this.check(cx)).ok();
})
.detach();
});
+25 -7
View File
@@ -26,8 +26,8 @@ pub use state::FileAttachment;
/// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
pub fn init(window: &mut Window, cx: &mut App) {
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx);
pub fn init(cx: &mut App) {
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
}
struct GlobalChatRegistry(Entity<ChatRegistry>);
@@ -150,7 +150,8 @@ impl ChatRegistry {
}
/// Create a new chat registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx);
let (tx, rx) = flume::unbounded::<Signal>();
let mut subscriptions = smallvec![];
@@ -167,9 +168,12 @@ impl ChatRegistry {
}),
);
// Run at the end of the current cycle
cx.defer_in(window, |this, _window, cx| {
this.get_rooms(cx);
cx.defer(move |cx| {
entity
.update(cx, |this, cx| {
this.get_rooms(cx);
})
.ok();
});
Self {
@@ -221,7 +225,21 @@ impl ChatRegistry {
};
match *message {
RelayMessage::Event { event, .. } => {
RelayMessage::Event {
subscription_id,
event,
..
} => {
let chat_sub = subscription_id.as_str() != sub_id1.as_str();
let device_sub = subscription_id.as_str() != sub_id2.as_str();
// Concord wraps are also kind 1059.
//
// Only the two gift wrap subscriptions carry NIP-59 wraps for this account.
if event.kind == Kind::GiftWrap && chat_sub && device_sub {
continue;
}
// Prune the dedup set before it grows unbounded
if processed_events.len() >= MAX_PROCESSED {
processed_events.clear();
+14 -5
View File
@@ -289,12 +289,21 @@ impl Room {
}
}
/// Gets the display image for the room
pub fn display_image(&self, cx: &App) -> SharedString {
if !self.is_group() {
self.display_member(cx).avatar()
/// Gets the display picture for the room, if it has one
pub fn display_image(&self, cx: &App) -> Option<SharedString> {
if self.is_group() {
None
} else {
SharedString::from("brand/group.png")
self.display_member(cx).avatar()
}
}
/// A stable seed for the room's generated avatar
pub fn display_image_seed(&self, cx: &App) -> SharedString {
if self.is_group() {
SharedString::from(self.id.to_string())
} else {
self.display_member(cx).avatar_seed()
}
}
+5 -3
View File
@@ -1203,6 +1203,7 @@ impl ChatPanel {
if show_author {
this.child(
Avatar::new(author.avatar())
.seed(author.avatar_seed())
.flex_shrink_0()
.relative()
.dropdown_menu(move |this, _window, _cx| {
@@ -1470,7 +1471,7 @@ impl ChatPanel {
h_flex()
.gap_1()
.font_semibold()
.child(Avatar::new(avatar).small())
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
.child(name.clone()),
),
)
@@ -1978,11 +1979,12 @@ impl Panel for ChatPanel {
self.room
.read_with(cx, |this, cx| {
let label = this.display_name(cx);
let url = this.display_image(cx);
let picture = this.display_image(cx);
let seed = this.display_image_seed(cx);
h_flex()
.gap_1p5()
.child(Avatar::new(url).xsmall())
.child(Avatar::new(picture).seed(seed).xsmall())
.child(label)
.into_any_element()
})
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "community"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
concord = { path = "../concord" }
state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
flume.workspace = true
futures.workspace = true
log.workspace = true
serde_json.workspace = true
smallvec.workspace = true
smol.workspace = true
[dev-dependencies]
nostr-memory.workspace = true
+331
View File
@@ -0,0 +1,331 @@
use std::collections::BTreeMap;
use std::sync::LazyLock;
use anyhow::{Result, anyhow};
use concord::cord01::OpenedStream;
use concord::state::{CommunityState, STATE_PREFIX, state_identifier};
use concord::{ChannelId, CommunityId, cord03};
use nostr_sdk::prelude::*;
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(|| {
Keys::new(SecretKey::from_slice(&[0x43; 32]).expect("a fixed 32-byte scalar is a valid key"))
});
const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
const MARK_VALUE: &str = "concord";
const WRAP_TAG: &str = "e";
const KIND_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_K;
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
pub async fn cache_rumor(
client: &Client,
channel: &ChannelId,
opened: &OpenedStream,
) -> Result<bool> {
let at = Timestamp::from_secs(opened.at_ms / 1000);
if cord03::expiration_of(&opened.rumor)?
.is_some_and(|expiration| expiration <= Timestamp::now())
{
return Ok(false);
}
let tags = vec![
Tag::identifier(opened.rumor_id),
Tag::custom(KIND_TAG.as_str(), [opened.rumor.kind.to_string()]),
Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]),
Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]),
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
Tag::public_key(opened.author),
];
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
.tags(tags)
.custom_created_at(at)
.finalize_async(&*LOCAL_KEYS)
.await?;
client.database().save_event(&event).await?;
Ok(true)
}
pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result<usize> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
let mut expired = Vec::new();
for event in client.database().query(filter).await? {
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
continue;
};
let Ok(Some(expiration)) = cord03::expiration_of(&rumor) else {
continue;
};
if expiration <= now {
expired.push(event.id);
}
}
let purged = expired.len();
if purged > 0 {
client.database().delete(Filter::new().ids(expired)).await?;
}
Ok(purged)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Observed {
pub author: PublicKey,
pub at_ms: u64,
}
/// The cached rumors of `channel`, keyed by the wrap they were opened from.
pub async fn wrapper_index(
client: &Client,
channel: &ChannelId,
) -> Result<BTreeMap<EventId, Observed>> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
let mut index = BTreeMap::new();
for event in client.database().query(filter).await? {
let (Some(wrapper_id), Some(author)) = (
event.tags.event_ids().next(),
event.tags.public_keys().next(),
) else {
continue;
};
index.insert(
wrapper_id,
Observed {
author,
at_ms: event.created_at.as_secs().saturating_mul(1000),
},
);
}
Ok(index)
}
/// Cached rumors for `channel`, newest first, deduplicated by rumor id.
pub async fn query_rumors(
client: &Client,
channel: &ChannelId,
until: Option<Timestamp>,
limit: usize,
kinds: Option<&[u16]>,
) -> Result<Vec<UnsignedEvent>> {
let mut filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
if let Some(kinds) = kinds {
filter = filter.custom_tags(KIND_TAG, kinds.iter().map(u16::to_string));
}
if let Some(until) = until {
filter = filter.until(until);
}
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
for event in client.database().query(filter).await? {
let Some(rumor_id) = event.tags.identifier() else {
continue;
};
match newest.get(&rumor_id) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(rumor_id, event);
}
}
}
let mut events: Vec<Event> = newest.into_values().collect();
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
events.truncate(limit);
let mut rumors = Vec::with_capacity(events.len());
for event in events {
let rumor = UnsignedEvent::from_json(event.content)
.map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?;
rumors.push(rumor);
}
Ok(rumors)
}
pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(state.identifier())])
.finalize_async(&*LOCAL_KEYS)
.await?;
client.database().save_event(&event).await?;
Ok(())
}
pub async fn load_state(client: &Client, id: &CommunityId) -> Result<Option<CommunityState>> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(state_identifier(id))
.limit(1);
match client.database().query(filter).await?.into_iter().next() {
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
None => Ok(None),
}
}
/// The newest state document per community carried in the local database.
pub async fn load_states(client: &Client) -> Result<Vec<CommunityState>> {
let filter = Filter::new().kind(Kind::ApplicationSpecificData);
let mut newest: BTreeMap<CommunityId, Event> = BTreeMap::new();
for event in client.database().query(filter).await? {
let Some(id) = state_document_of(&event) else {
continue;
};
match newest.get(&id) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(id, event);
}
}
}
let mut states = Vec::with_capacity(newest.len());
for event in newest.into_values() {
match serde_json::from_str::<CommunityState>(&event.content) {
Ok(state) => states.push(state),
Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id),
}
}
Ok(states)
}
fn state_document_of(event: &Event) -> Option<CommunityId> {
let identifier = event.tags.identifier()?;
let hex = identifier.strip_prefix(STATE_PREFIX)?;
hex.parse().ok()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use concord::Epoch;
use nostr_memory::MemoryDatabase;
use super::*;
fn client() -> Client {
ClientBuilder::default()
.database(MemoryDatabase::unbounded())
.build()
}
#[test]
fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
smol::block_on(async {
let client = client();
let state = CommunityState {
id: CommunityId::from_bytes([0x42; 32]),
name: Some("Anime and Manga".to_owned()),
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::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: 7,
};
save_state(&client, &state).await.expect("saves");
// A cached rumor is also an application-specific document, but not a
// state document, so the prefix keeps it out of the state scan.
let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}")
.tags([Tag::identifier("deadbeef")])
.finalize(&*LOCAL_KEYS)
.expect("builds");
client.database().save_event(&other).await.expect("saves");
let loaded = load_states(&client).await.expect("loads");
assert_eq!(loaded, vec![state]);
});
}
#[test]
fn caching_the_same_rumor_twice_leaves_one_row() {
smol::block_on(async {
let client = client();
let channel = ChannelId::from_bytes([0x9c; 32]);
let keys = Keys::generate();
let group = concord::derive::channel_group_key(&[0x07; 32], &channel, Epoch(0))
.expect("a group key");
let rumor = concord::cord03::build_message(
keys.public_key(),
&channel,
Epoch(0),
"twice",
None,
1_700_000_000_000,
None,
);
let (wrap, _) = concord::cord03::seal_rumor(&rumor, &group, &keys, false)
.await
.expect("seals");
let (opened, _) =
concord::cord03::open(&wrap, &group, &channel, Epoch(0)).expect("opens");
assert!(
cache_rumor(&client, &channel, &opened)
.await
.expect("caches")
);
assert!(
cache_rumor(&client, &channel, &opened)
.await
.expect("caches")
);
let cached = query_rumors(&client, &channel, None, 10, None)
.await
.expect("reads");
assert_eq!(cached.len(), 1);
});
}
}
File diff suppressed because it is too large Load Diff
+764
View File
@@ -0,0 +1,764 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use concord::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
use concord::cord03::{self, ChatRumor};
use concord::derive::channel_group_key;
use concord::state::{ChannelCursor, HeldKey};
use concord::{ChannelId, GroupKey};
use futures::future::{Either, select};
use nostr_sdk::prelude::*;
use crate::cache::cache_rumor;
use crate::sync::connect_relays;
/// How long one relay is given to answer one page of history.
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
/// How far below a cursor a warm window reaches back.
pub const CURSOR_OVERLAP: Duration = Duration::from_secs(60);
/// The region of history to read.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Window {
pub until: Option<Timestamp>,
pub since: Option<Timestamp>,
}
impl Window {
/// The newest wraps, with no older bound.
pub fn newest() -> Self {
Self::default()
}
/// The wraps strictly older than `oldest`.
pub fn older_than(oldest: Timestamp) -> Self {
Self {
until: Some(oldest - 1u64),
since: None,
}
}
/// The region between `since` and `oldest`, both inclusive.
pub fn between(since: Timestamp, oldest: Timestamp) -> Self {
Self {
until: Some(oldest - 1u64),
since: Some(since),
}
}
/// The window a channel is opened with.
pub fn opening(cursor: ChannelCursor) -> Self {
match cursor.newest {
Some(newest) => Self {
since: Some(newest - CURSOR_OVERLAP),
until: None,
},
None => Self::default(),
}
}
}
/// What a relay said about one page subscription.
#[derive(Debug, Clone)]
pub enum Settled {
Replayed,
Refused(String),
}
/// One relay's verdict on one page subscription.
#[derive(Debug, Clone)]
pub struct PageReport {
pub relay: RelayUrl,
pub outcome: Settled,
}
/// The page subscriptions in flight, by subscription id.
pub struct PageRegistry {
pages: Arc<Mutex<HashMap<SubscriptionId, flume::Sender<PageReport>>>>,
}
impl Default for PageRegistry {
fn default() -> Self {
Self {
pages: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl Clone for PageRegistry {
fn clone(&self) -> Self {
Self {
pages: Arc::clone(&self.pages),
}
}
}
impl PageRegistry {
pub fn register(&self, id: SubscriptionId, sender: flume::Sender<PageReport>) {
self.lock().insert(id, sender);
}
pub fn unregister(&self, id: &SubscriptionId) {
self.lock().remove(id);
}
/// Forget every page still in flight, because its round is gone.
pub fn clear(&self) {
self.lock().clear();
}
/// Hand a relay's verdict to the page that owns `id`, when it is still waiting.
pub fn deliver(&self, id: &SubscriptionId, relay: RelayUrl, outcome: Settled) {
let sender = self.lock().get(id).cloned();
let Some(sender) = sender else {
return;
};
if let Err(error) = sender.try_send(PageReport { relay, outcome }) {
log::debug!("community: a page report was not delivered: {error}");
}
}
fn lock(&self) -> MutexGuard<'_, HashMap<SubscriptionId, flume::Sender<PageReport>>> {
match self.pages.lock() {
Ok(pages) => pages,
Err(poisoned) => poisoned.into_inner(),
}
}
}
/// What one paged fetch saw.
#[derive(Debug, Clone, Default)]
pub struct WrapPage {
pub opened: Vec<ChatRumor>,
pub raw: usize,
/// Wraps that reached us under a held plane but that no held key could open.
pub unreadable: usize,
pub newest: Option<Timestamp>,
pub oldest: Option<Timestamp>,
pub exhausted: bool,
pub failed: bool,
pub errors: usize,
}
/// Walks a channel's history back over the community's own relays.
#[allow(clippy::too_many_arguments)]
pub async fn page(
client: &Client,
pages: &PageRegistry,
channel: &ChannelId,
held: &[HeldKey],
relays: &[RelayUrl],
window: Window,
max_pages: usize,
limit: usize,
) -> Result<WrapPage> {
let planes: Vec<(HeldKey, GroupKey)> = held
.iter()
.map(|key| Ok((*key, channel_group_key(&key.key, channel, key.epoch)?)))
.collect::<Result<Vec<_>>>()?;
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
if authors.is_empty() || relays.is_empty() || limit == 0 {
return Ok(WrapPage {
failed: true,
..WrapPage::default()
});
}
// A REQ can only target a relay the pool already knows about.
connect_relays(client, relays).await;
let mut walk = Walk::new(relays, window);
let mut opened = Vec::new();
for _ in 0..max_pages {
if walk.is_done() {
break;
}
let filter = wrap_filter(&authors, walk.region(), limit);
let asked: Vec<(usize, RelayUrl)> = walk
.live()
.map(|index| (index, walk.url(index).clone()))
.collect();
let answers = ask_page(client, pages, &asked, &filter).await;
for (index, url) in &asked {
match answers.get(url) {
Some(Settled::Replayed) => {}
Some(Settled::Refused(reason)) => {
log::warn!("community: relay {url} refused a history page: {reason}");
walk.reject(*index);
}
None => {
log::warn!("community: relay {url} did not finish a history page");
walk.reject(*index);
}
}
}
let answered = client.database().query(filter).await?;
for wrap in walk.accept(answered, limit) {
let Some((held, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
else {
continue;
};
let Ok((stream, rumor)) = read_under(&wrap, held, group, channel) else {
walk.unreadable += 1;
continue;
};
if cache_rumor(client, channel, &stream).await? {
opened.push(rumor);
}
}
}
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 > retired)
{
bail!("sealed after the key that reads it was retired");
}
Ok(cord03::open(wrap, group, channel, held.epoch)?)
}
/// The one filter a page is asked for.
fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter {
let mut filter = Filter::new()
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
.authors(authors.iter().copied())
.limit(limit);
if let Some(until) = window.until {
filter = filter.until(until);
}
if let Some(since) = window.since {
filter = filter.since(since);
}
filter
}
/// One page's verdicts, by relay.
type PageAnswers = BTreeMap<RelayUrl, Settled>;
async fn ask_page(
client: &Client,
pages: &PageRegistry,
asked: &[(usize, RelayUrl)],
filter: &Filter,
) -> PageAnswers {
let mut answers = PageAnswers::new();
let distinct: BTreeSet<RelayUrl> = asked.iter().map(|(_, url)| url.clone()).collect();
let mut targets: Vec<(RelayUrl, Vec<Filter>)> = Vec::with_capacity(distinct.len());
for url in &distinct {
match client.relay(url).await {
Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])),
Ok(None) => {
log::warn!("community: relay {url} is not in the pool for a history page");
answers.insert(
url.clone(),
Settled::Refused("not in the relay pool".to_owned()),
);
}
Err(error) => {
log::warn!("community: relay {url} could not be looked up: {error}");
answers.insert(url.clone(), Settled::Refused(error.to_string()));
}
}
}
if targets.is_empty() {
return answers;
}
let id = history_subscription();
let (sender, receiver) = flume::bounded(distinct.len());
pages.register(id.clone(), sender);
let options = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(PAGE_TIMEOUT));
match client
.subscribe(ReqTarget::manual(targets))
.with_id(id.clone())
.close_on(options)
.await
{
Ok(output) => {
for (url, reason) in output.failed {
answers.insert(url, Settled::Refused(reason));
}
let deadline = Instant::now() + PAGE_TIMEOUT;
while answers.len() < distinct.len() {
let remaining = deadline.saturating_duration_since(Instant::now());
let Some(report) = within(remaining, receiver.recv_async()).await else {
break;
};
match report {
Ok(report) => {
answers.insert(report.relay, report.outcome);
}
Err(error) => {
log::warn!("community: a history page's reports were lost: {error}");
break;
}
}
}
}
Err(error) => {
log::warn!("community: a history page REQ was refused: {error}");
}
}
pages.unregister(&id);
answers
}
pub(crate) fn auth_required(reason: &str) -> bool {
matches!(
MachineReadablePrefix::parse(reason),
Some(MachineReadablePrefix::AuthRequired)
)
}
fn history_subscription() -> SubscriptionId {
static NEXT: AtomicU64 = AtomicU64::new(0);
SubscriptionId::new(format!("history-{}", NEXT.fetch_add(1, Ordering::Relaxed)))
}
async fn within<F>(limit: Duration, future: F) -> Option<F::Output>
where
F: Future,
{
let future = std::pin::pin!(future);
let deadline = std::pin::pin!(smol::Timer::after(limit));
match select(future, deadline).await {
Either::Left((output, _)) => Some(output),
Either::Right(_) => None,
}
}
/// One pass over a channel's history, page by page.
#[derive(Debug)]
struct Walk {
relays: Vec<Walker>,
since: Option<Timestamp>,
/// The inclusive upper bound of the next page.
cursor: Option<Timestamp>,
seen: BTreeSet<EventId>,
newest: Option<Timestamp>,
oldest: Option<Timestamp>,
raw: usize,
errors: usize,
/// Wraps the caller could not read under any held key.
unreadable: usize,
/// A short page ended the walk.
bottom: bool,
}
/// One relay's standing in a walk.
#[derive(Debug)]
struct Walker {
url: RelayUrl,
dead: bool,
}
impl Walk {
fn new(relays: &[RelayUrl], window: Window) -> Self {
Self {
relays: relays
.iter()
.cloned()
.map(|url| Walker { url, dead: false })
.collect(),
since: window.since,
cursor: window.until,
seen: BTreeSet::new(),
newest: None,
oldest: None,
raw: 0,
errors: 0,
unreadable: 0,
bottom: false,
}
}
fn is_done(&self) -> bool {
self.bottom || self.relays.iter().all(|walker| walker.dead)
}
/// The region the next page asks for.
fn region(&self) -> Window {
Window {
until: self.cursor,
since: self.since,
}
}
fn live(&self) -> impl Iterator<Item = usize> + '_ {
(0..self.relays.len()).filter(|&index| !self.relays[index].dead)
}
fn url(&self, index: usize) -> &RelayUrl {
&self.relays[index].url
}
fn reject(&mut self, index: usize) {
self.relays[index].dead = true;
self.errors += 1;
}
fn accept(&mut self, page: BTreeSet<Event>, limit: usize) -> Vec<Event> {
if page.len() < limit {
self.bottom = true;
}
let mut oldest: Option<Timestamp> = None;
let mut events = Vec::with_capacity(page.len());
for event in page {
let at = event.created_at;
self.newest = Some(self.newest.map_or(at, |newest| newest.max(at)));
oldest = Some(oldest.map_or(at, |oldest| oldest.min(at)));
if self.seen.insert(event.id) {
self.raw += 1;
events.push(event);
}
}
// The walk's floor, which the round resumes the older pass from.
if let Some(oldest) = oldest {
self.oldest = Some(self.oldest.map_or(oldest, |held| held.min(oldest)));
}
match oldest {
Some(oldest) if !oldest.is_zero() => self.cursor = Some(oldest - 1u64),
Some(_) => self.bottom = true,
None => {}
}
events
}
fn finish(self, opened: Vec<ChatRumor>) -> WrapPage {
let swept = self.bottom && self.errors == 0;
WrapPage {
opened,
raw: self.raw,
unreadable: self.unreadable,
newest: self.newest,
oldest: self.oldest,
exhausted: swept && self.raw > 0,
failed: self.errors > 0 || (self.bottom && self.raw == 0),
errors: self.errors,
}
}
}
#[cfg(test)]
mod tests {
use std::cmp::Reverse;
use concord::Epoch;
use concord::cord03::{build_message, seal_rumor};
use concord::derive::channel_group_key;
use super::*;
const SECRET: [u8; 32] = [0x07u8; 32];
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
fn serve_page(database: &BTreeSet<Event>, window: Window, limit: usize) -> BTreeSet<Event> {
let mut events: Vec<Event> = database
.iter()
.filter(|event| {
window.until.is_none_or(|until| event.created_at <= until)
&& window.since.is_none_or(|since| event.created_at >= since)
})
.cloned()
.collect();
events.sort_by_key(|event| Reverse(event.created_at));
events.truncate(limit);
events.into_iter().collect()
}
fn relay_url(host: &str) -> RelayUrl {
RelayUrl::parse(&format!("wss://{host}.example.com")).expect("parses")
}
#[test]
fn a_walk_pages_back_across_a_rekey() {
let channel = ChannelId::from_bytes([0x9cu8; 32]);
let author = Keys::generate();
let held = [
HeldKey {
epoch: Epoch(0),
key: SECRET,
retired_at: None,
},
HeldKey {
epoch: Epoch(1),
key: NEXT_SECRET,
retired_at: None,
},
];
let planes: Vec<(HeldKey, GroupKey)> = held
.iter()
.map(|key| {
(
*key,
channel_group_key(&key.key, &channel, key.epoch).expect("derives"),
)
})
.collect();
// Three messages a second apart: a page boundary falls between each.
let base = 1_700_000_000_000;
let mut relay: BTreeSet<Event> = BTreeSet::new();
for (content, secret, epoch, at_ms) in [
("before the rekey", &SECRET, Epoch(0), base),
("still before", &SECRET, Epoch(0), base + 1_000),
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
] {
let group = channel_group_key(secret, &channel, epoch).expect("derives");
let rumor = build_message(
author.public_key(),
&channel,
epoch,
content,
None,
at_ms,
None,
);
relay.insert(
smol::block_on(seal_rumor(&rumor, &group, &author, false))
.expect("seals")
.0,
);
}
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
let mut found = Vec::new();
let mut pages = 0;
while !walk.is_done() && pages < 10 {
pages += 1;
let page = serve_page(&relay, walk.region(), 2);
for wrap in walk.accept(page, 2) {
let Some((held, group)) =
planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
else {
continue;
};
let (_, rumor) = cord03::open(&wrap, group, &channel, held.epoch).expect("opens");
found.push(rumor);
}
}
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
assert_eq!(
contents,
["after the rekey", "still before", "before the rekey"]
);
let page = walk.finish(Vec::new());
assert!(page.exhausted);
assert!(!page.failed);
assert_eq!(page.raw, 3);
assert_eq!(page.newest, Some(Timestamp::from_secs(1_700_000_002)));
assert_eq!(
page.oldest,
Some(Timestamp::from_secs(1_700_000_000)),
"the walk reports its floor, which the older pass resumes below"
);
}
/// 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(Timestamp::from_secs(1_000)),
};
let wrap_at = |channel: &ChannelId, at_ms: u64| {
let rumor = build_message(
author.public_key(),
channel,
Epoch(0),
"sealed",
None,
at_ms,
None,
);
smol::block_on(seal_rumor(&rumor, &group, &author, false))
.expect("seals")
.0
};
// Sealed before the rotation superseded this key, so it still reads.
let before = wrap_at(&channel, 999_000);
assert!(read_under(&before, &held, &group, &channel).is_ok());
// Sealed after the cutoff the rotation set on that key.
let after = wrap_at(&channel, 1_001_000);
assert!(read_under(&after, &held, &group, &channel).is_err());
// Sealed to this plane but bound to another channel.
let misbound = wrap_at(&other, 999_000);
assert!(read_under(&misbound, &held, &group, &channel).is_err());
}
#[test]
fn an_empty_answer_never_seals_the_channel() {
let database: BTreeSet<Event> = BTreeSet::new();
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
let page = serve_page(&database, walk.region(), 50);
assert!(walk.accept(page, 50).is_empty());
let page = walk.finish(Vec::new());
assert!(page.failed);
assert!(!page.exhausted);
assert_eq!(page.raw, 0);
assert_eq!(page.oldest, None);
}
#[test]
fn a_silent_relay_blocks_the_bottom() {
let database: BTreeSet<Event> = BTreeSet::new();
let mut walk = Walk::new(
&[relay_url("history"), relay_url("archive")],
Window::newest(),
);
// One relay answered the empty page; the other never answered at all, so
// its share of the region was never read and the walk must not seal.
walk.reject(1);
let page = serve_page(&database, walk.region(), 50);
assert!(walk.accept(page, 50).is_empty());
let page = walk.finish(Vec::new());
assert!(page.failed);
assert!(!page.exhausted);
assert_eq!(page.errors, 1);
}
#[test]
fn a_page_boundary_is_exclusive() {
let database = BTreeSet::from([
event_at(Timestamp::from_secs(1_700_000_000)),
event_at(Timestamp::from_secs(1_700_000_001)),
]);
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
let first = walk.accept(serve_page(&database, walk.region(), 1), 1);
let second = walk.accept(serve_page(&database, walk.region(), 1), 1);
assert_eq!(first.len(), 1);
assert_eq!(second.len(), 1);
assert_ne!(first[0].id, second[0].id);
let oldest = second
.iter()
.map(|event| event.created_at)
.min()
.expect("one wrap");
assert_eq!(oldest, Timestamp::from_secs(1_700_000_000));
}
fn event_at(at: Timestamp) -> Event {
let keys = Keys::generate();
EventBuilder::new(Kind::TextNote, "page")
.custom_created_at(at)
.finalize(&keys)
.expect("signs")
}
/// A cold channel asks wide; a warm one resumes at the overlap above its
/// cursor, and `older_than` never includes the boundary event itself.
#[test]
fn a_cold_window_is_open_and_a_warm_one_resumes_at_the_overlap() {
assert_eq!(Window::opening(ChannelCursor::default()), Window::default());
let warm = Window::opening(ChannelCursor {
newest: Some(Timestamp::from_secs(2_000_000)),
oldest: Some(Timestamp::from_secs(1_000)),
exhausted: false,
});
assert_eq!(
warm,
Window {
since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP),
until: None,
}
);
assert_eq!(
Window::older_than(Timestamp::from_secs(1_000)),
Window {
since: None,
until: Some(Timestamp::from_secs(999)),
}
);
}
/// A page whose round has moved on is simply a report nobody reads.
#[test]
fn a_page_that_moved_on_receives_nothing() {
let pages = PageRegistry::default();
let id = SubscriptionId::new("concord-history-9");
let (sender, receiver) = flume::bounded(1);
pages.register(id.clone(), sender);
pages.unregister(&id);
pages.deliver(&id, relay_url("history"), Settled::Replayed);
assert!(receiver.try_recv().is_err());
}
}
+850
View File
@@ -0,0 +1,850 @@
use std::collections::{BTreeSet, HashMap};
use std::time::{Duration, Instant};
use anyhow::Result;
pub use concord::cord02::CommunityMetadata;
pub use concord::cord03::{ChatMessage, ReplyRef};
use concord::state::CommunityState;
pub use concord::{ChannelId, CommunityId, Epoch};
use futures::future::{Either, select};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use crate::history::{PageRegistry, Settled, auth_required};
use crate::rekey::WatchRegistry;
pub mod cache;
mod community;
pub mod history;
mod rekey;
mod sync;
pub use community::*;
pub use sync::*;
/// How long a burst of relay notifications is collected before it is folded.
const PUMP_WINDOW: Duration = Duration::from_millis(200);
/// How long a community's relays may deliver nothing before
/// its standing subscription is torn down and re-issued.
const LIVE_ROTATE: Duration = Duration::from_secs(90);
pub fn init(cx: &mut App) {
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
}
struct GlobalCommunityRegistry(Entity<CommunityRegistry>);
impl Global for GlobalCommunityRegistry {}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Signal {
Event(CommunityId),
List,
Rekey(CommunityId),
}
/// Which standing subscription an id belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Route {
List,
Community(CommunityId),
}
fn route_of(id: &SubscriptionId) -> Option<Route> {
if sync::is_list_subscription(id) {
return Some(Route::List);
}
sync::community_of(id).map(Route::Community)
}
/// What a window of relay notifications saw, waiting to be folded once.
#[derive(Default)]
struct Batch {
list: bool,
communities: BTreeSet<CommunityId>,
rekeys: BTreeSet<CommunityId>,
}
/// Whether the pump should keep listening.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Flow {
Continue,
Stop,
}
/// Fold one notification into the window, or settle a page it belongs to.
fn route(
notification: ClientNotification,
pages: &PageRegistry,
watches: &WatchRegistry,
batch: &mut Batch,
) -> Flow {
match notification {
ClientNotification::Event {
subscription_id, ..
} => match route_of(&subscription_id) {
Some(Route::List) => batch.list = true,
Some(Route::Community(id)) => {
batch.communities.insert(id);
}
None => {
if let Some(id) = watches.community_of(&subscription_id) {
batch.rekeys.insert(id);
}
}
},
ClientNotification::Message { relay_url, message } => match *message {
RelayMessage::EndOfStoredEvents(id) => {
pages.deliver(&id, relay_url, Settled::Replayed);
}
RelayMessage::Closed {
subscription_id,
message,
} if !auth_required(&message) => {
pages.deliver(
&subscription_id,
relay_url,
Settled::Refused(message.into_owned()),
);
}
_ => {}
},
ClientNotification::Shutdown => return Flow::Stop,
}
Flow::Continue
}
/// Hand the window's signals to the foreground consumer, one per community.
async fn flush(tx: &flume::Sender<Signal>, batch: &mut Batch) -> Result<()> {
for id in std::mem::take(&mut batch.communities) {
tx.send_async(Signal::Event(id)).await?;
}
for id in std::mem::take(&mut batch.rekeys) {
tx.send_async(Signal::Rekey(id)).await?;
}
if std::mem::take(&mut batch.list) {
tx.send_async(Signal::List).await?;
}
Ok(())
}
impl EventEmitter<CommunityEvent> for CommunityRegistry {}
pub struct CommunityRegistry {
communities: Vec<Entity<Community>>,
index: HashMap<CommunityId, Entity<Community>>,
/// The plane set each community was last subscribed with
synced: HashMap<CommunityId, SubscriptionKey>,
/// When a relay last delivered something for a community,
/// which is the only evidence the standing subscription is alive.
last_event: HashMap<CommunityId, Instant>,
/// One observer per tracked community, dropped on reset
observers: HashMap<CommunityId, Subscription>,
signal_tx: flume::Sender<Signal>,
signal_rx: flume::Receiver<Signal>,
/// The page subscriptions in flight, shared with the notification pump.
pages: PageRegistry,
/// The rekey watch subscriptions, resolved to their community.
watches: WatchRegistry,
tasks: SmallVec<[Task<Result<()>>; 2]>,
/// Notification listener task (cancelled on signer change)
notification_listener: Option<Task<Result<()>>>,
/// Signal consumer task (cancelled on signer change)
signal_consumer: Option<Task<Result<()>>>,
/// The round scheduler (cancelled on signer change)
scheduler: Option<Task<Result<()>>>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl CommunityRegistry {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalCommunityRegistry>().0.clone()
}
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalCommunityRegistry(state));
}
fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx);
let (tx, rx) = flume::bounded::<Signal>(256);
let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
if event.signer_changed() {
this.reset(cx);
this.handle_notifications(cx);
this.subscribe_list(cx);
this.load(cx);
}
}));
cx.defer(move |cx| {
entity
.update(cx, |this, cx| {
this.handle_notifications(cx);
if nostr.read(cx).current_user().is_some() {
this.subscribe_list(cx);
this.load(cx);
}
})
.ok();
});
Self {
communities: Vec::new(),
index: HashMap::new(),
synced: HashMap::new(),
last_event: HashMap::new(),
observers: HashMap::new(),
signal_tx: tx,
signal_rx: rx,
pages: PageRegistry::default(),
watches: WatchRegistry::default(),
tasks: smallvec![],
notification_listener: None,
signal_consumer: None,
scheduler: None,
_subscriptions: subscriptions,
}
}
pub fn communities(&self) -> &[Entity<Community>] {
&self.communities
}
pub fn community(&self, id: &CommunityId) -> Option<Entity<Community>> {
self.index.get(id).cloned()
}
/// Ask the workspace to open a community's panel.
pub fn emit_community(
&mut self,
community: &Entity<Community>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = community.read(cx).id();
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(CommunityEvent::Open(id));
});
}
/// Ask the workspace to close a community's panel.
pub fn emit_close(&mut self, id: CommunityId, window: &mut Window, cx: &mut Context<Self>) {
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(CommunityEvent::Close(id));
});
}
/// Create a community owned by the current account and begin tracking it.
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
if current_user.is_none() {
cx.emit(CommunityEvent::Error(
"cannot create a community without an account".to_owned(),
));
return;
}
let signer = nostr.read(cx).signer();
let client = nostr.read(cx).client();
let task =
cx.background_spawn(async move { sync::create(&client, &signer, &metadata).await });
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(_state) => this.update(cx, |this, cx| this.load(cx))?,
Err(error) => {
this.update(cx, |_this, cx| {
cx.emit(CommunityEvent::Error(error.to_string()));
})?;
}
}
Ok(())
}));
}
/// Forget the current account and cancel everything in flight.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.notification_listener = None;
self.signal_consumer = None;
self.scheduler = None;
self.tasks.clear();
self.observers.clear();
self.pages.clear();
self.watches.clear();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let ids: Vec<CommunityId> = self.index.keys().copied().collect();
for id in ids {
let client = client.clone();
let subscription = sync::subscription_id(&id);
let rekey = rekey::subscription_id(&id);
self.tasks.push(cx.background_spawn(async move {
client.unsubscribe(&subscription).await?;
client.unsubscribe(&rekey).await?;
Ok(())
}));
}
self.communities.clear();
self.index.clear();
self.synced.clear();
self.last_event.clear();
cx.notify();
}
/// Subscribe to the account's community list.
fn subscribe_list(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let client = nostr.read(cx).client();
self.tasks.push(cx.spawn(async move |this, cx| {
let self_pk = signer.get_public_key_async().await?;
if let Err(error) = sync::subscribe_list(&client, self_pk).await {
this.update(cx, |_this, cx| {
cx.emit(CommunityEvent::Error(error.to_string()));
})?;
}
Ok(())
}));
}
/// Discover the account's communities in the local database.
fn load(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let client = nostr.read(cx).client();
let task = cx.background_spawn(async move {
let self_pk = signer.get_public_key_async().await?;
sync::load(&client, &signer, self_pk).await
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(states) => {
this.update(cx, |this, cx| this.track(states, cx))?;
}
Err(error) => {
this.update(cx, |_this, cx| {
cx.emit(CommunityEvent::Error(error.to_string()));
})?;
}
}
Ok(())
}));
}
/// Replace the tracked communities with a freshly loaded set.
///
/// A community that survives the reload keeps its entity, so an open panel
/// and a browsing sidebar stay pointed at a live community.
fn track(&mut self, states: Vec<CommunityState>, cx: &mut Context<Self>) {
let mut communities = Vec::with_capacity(states.len());
for state in states {
let id = state.id;
let community = match self.index.remove(&id) {
Some(community) => {
// The list can carry plane material the store does not.
if community.read(cx).state() != &state {
community.update(cx, |community, _cx| community.adopt(state));
}
community
}
None => {
let community = cx.new(|_| Community::new(state, self.pages.clone()));
self.observers.insert(
id,
cx.observe(&community, |this, _community, cx| {
this.sync_subscriptions(cx);
cx.notify();
}),
);
community
}
};
communities.push((id, community));
}
// Whatever the index still holds is no longer in the list.
let dropped: Vec<CommunityId> = self.index.keys().copied().collect();
for id in dropped {
self.observers.remove(&id);
self.synced.remove(&id);
self.last_event.remove(&id);
}
self.communities = communities
.iter()
.map(|(_, community)| community.clone())
.collect();
self.index = communities.into_iter().collect();
self.sync_subscriptions(cx);
// A backlog already in the database produces no notification, so fold it once.
for community in self.communities.clone() {
community.update(cx, |community, cx| community.refresh(cx));
}
cx.notify();
}
fn refresh(&mut self, id: CommunityId, cx: &mut Context<Self>) {
let Some(community) = self.index.get(&id).cloned() else {
return;
};
community.update(cx, |community, cx| community.refresh(cx));
}
/// Adopt whatever a community's rekey watch has delivered.
fn rekey(&mut self, id: CommunityId, cx: &mut Context<Self>) {
let Some(community) = self.index.get(&id).cloned() else {
return;
};
community.update(cx, |community, cx| community.rekey(cx));
}
/// One scheduler pass: every community repairs itself if it has gone stale,
/// and any whose relays have gone quiet is re-subscribed.
fn tick(&mut self, cx: &mut Context<Self>) {
for community in self.communities.clone() {
community.update(cx, |community, cx| community.tick(cx));
}
self.rotate_quiet(cx);
}
/// Re-issue the standing subscription of every community that has been quiet for `LIVE_ROTATE`
fn rotate_quiet(&mut self, cx: &mut Context<Self>) {
let now = Instant::now();
let ids: Vec<CommunityId> = self.index.keys().copied().collect();
let mut rotated = false;
for id in ids {
let quiet = self
.last_event
.get(&id)
.is_none_or(|at| now.duration_since(*at) >= LIVE_ROTATE);
if !quiet {
continue;
}
self.synced.remove(&id);
rotated = true;
}
if rotated {
self.sync_subscriptions(cx);
}
}
/// Re-subscribe every community whose held planes moved.
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
for community in self.communities.clone() {
let client = nostr.read(cx).client();
let (id, key, state) = {
let community = community.read(cx);
(
community.id(),
community.subscription_key(),
community.state().clone(),
)
};
if self.synced.get(&id) == Some(&key) {
continue;
}
let planes = match sync::planes(&state) {
Ok(planes) => planes,
Err(error) => {
cx.emit(CommunityEvent::Error(error.to_string()));
continue;
}
};
let subscription = sync::subscription_id(&id);
let filter = sync::live_filter(&planes, sync::live_window(&state, Timestamp::now()));
let relays = key.relays().to_vec();
// A fresh REQ counts as evidence of life for this community.
self.last_event.insert(id, Instant::now());
// The rekey watch is a second standing REQ over the same relays.
let watch = match rekey::watches(&state) {
Ok(watches) => rekey::watch_filter(&watches),
Err(error) => {
cx.emit(CommunityEvent::Error(error.to_string()));
continue;
}
};
let rekey_subscription = rekey::subscription_id(&id);
self.watches.register(rekey_subscription.clone(), id);
self.synced.insert(id, key);
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(error) = subscribe(&client, &subscription, &relays, filter).await {
this.update(cx, |_this, cx| {
cx.emit(CommunityEvent::Error(error.to_string()));
})?;
}
if let Err(error) = subscribe(&client, &rekey_subscription, &relays, watch).await {
this.update(cx, |_this, cx| {
cx.emit(CommunityEvent::Error(error.to_string()));
})?;
}
Ok(())
}));
}
}
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
self.notification_listener = None;
self.signal_consumer = None;
self.scheduler = None;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone();
let pages = self.pages.clone();
let watches = self.watches.clone();
let executor = cx.background_executor().clone();
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut batch = Batch::default();
'outer: loop {
match notifications.next().await {
Some(notification) => {
if route(notification, &pages, &watches, &mut batch) == Flow::Stop {
flush(&tx, &mut batch).await?;
break 'outer;
}
}
None => break 'outer,
}
let deadline = Instant::now() + PUMP_WINDOW;
loop {
let now = Instant::now();
if now >= deadline {
break;
}
let timer = executor.timer(deadline - now);
let next = notifications.next();
futures::pin_mut!(timer);
futures::pin_mut!(next);
match select(next, timer).await {
Either::Left((Some(notification), _)) => {
if route(notification, &pages, &watches, &mut batch) == Flow::Stop {
flush(&tx, &mut batch).await?;
break 'outer;
}
}
Either::Left((None, _)) => break 'outer,
Either::Right(_) => break,
}
}
flush(&tx, &mut batch).await?;
}
Ok(())
}));
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
while let Ok(signal) = rx.recv_async().await {
match signal {
Signal::Event(id) => this.update(cx, |this, cx| {
// The only proof a community's subscription is still
// delivering anything.
this.last_event.insert(id, Instant::now());
this.refresh(id, cx);
})?,
Signal::Rekey(id) => this.update(cx, |this, cx| this.rekey(id, cx))?,
Signal::List => this.update(cx, |this, cx| this.load(cx))?,
}
}
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(())
}));
}
}
async fn subscribe(
client: &Client,
id: &SubscriptionId,
relays: &[RelayUrl],
filter: Filter,
) -> Result<()> {
client.unsubscribe(id).await?;
if relays.is_empty() {
log::warn!("community {id}: no relay to subscribe to");
return Ok(());
}
let mut targets: Vec<(RelayUrl, Vec<Filter>)> = Vec::with_capacity(relays.len());
for url in relays {
if let Err(error) = client.add_relay(url).and_connect().await {
log::warn!("community {id}: failed to add relay {url}: {error}");
}
match client.relay(url).await {
Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])),
Ok(None) => log::warn!("community {id}: relay {url} is not in the pool"),
Err(error) => log::warn!("community {id}: relay {url} could not be looked up: {error}"),
}
}
if targets.is_empty() {
log::warn!("community {id}: no relay accepted the standing subscription");
return Ok(());
}
let output = client
.subscribe(ReqTarget::manual(targets))
.with_id(id.clone())
.await?;
if !output.failed.is_empty() {
log::warn!(
"community {id}: {} relay(s) rejected the subscription",
output.failed.len()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn relay() -> RelayUrl {
RelayUrl::parse("wss://relay.example").expect("a url")
}
fn event(subscription_id: SubscriptionId) -> ClientNotification {
let keys = Keys::generate();
let event = EventBuilder::new(Kind::TextNote, "hi")
.finalize(&keys)
.expect("signs");
ClientNotification::Event {
relay_url: relay(),
subscription_id,
event: Box::new(event),
}
}
fn message(message: RelayMessage<'static>) -> ClientNotification {
ClientNotification::Message {
relay_url: relay(),
message: Box::new(message),
}
}
/// A burst within one window folds each community once, and the list once,
/// however many events arrived.
#[test]
fn a_burst_collapses_to_one_signal_per_community() {
let pages = PageRegistry::default();
let watches = WatchRegistry::default();
let community = CommunityId::from_bytes([0x42; 32]);
let mut batch = Batch::default();
let plane = event(sync::subscription_id(&community));
for _ in 0..50 {
assert_eq!(
route(plane.clone(), &pages, &watches, &mut batch),
Flow::Continue
);
}
route(
event(sync::list_subscription_id()),
&pages,
&watches,
&mut batch,
);
// A page's event is folded from the database later, not routed here.
route(
event(SubscriptionId::new("concord-history-7")),
&pages,
&watches,
&mut batch,
);
assert!(batch.list);
assert_eq!(batch.communities, BTreeSet::from([community]));
assert!(batch.rekeys.is_empty());
}
/// A rekey watch's wraps put themselves in the database; the pump's only job
/// is to wake the adoption pass, and it resolves the community by id.
#[test]
fn a_rekey_watch_event_wakes_its_community() {
let pages = PageRegistry::default();
let watches = WatchRegistry::default();
let community = CommunityId::from_bytes([0x42; 32]);
let id = rekey::subscription_id(&community);
watches.register(id.clone(), community);
let mut batch = Batch::default();
route(event(id), &pages, &watches, &mut batch);
assert_eq!(batch.rekeys, BTreeSet::from([community]));
assert!(batch.communities.is_empty());
}
#[test]
fn an_eose_settles_only_the_page_that_owns_the_id() {
let pages = PageRegistry::default();
let mine = SubscriptionId::new("concord-history-1");
let other = SubscriptionId::new("concord-history-2");
let (mine_tx, mine_rx) = flume::bounded(1);
let (other_tx, other_rx) = flume::bounded(1);
pages.register(mine.clone(), mine_tx);
pages.register(other, other_tx);
let mut batch = Batch::default();
let flow = route(
message(RelayMessage::eose(mine)),
&pages,
&WatchRegistry::default(),
&mut batch,
);
assert_eq!(flow, Flow::Continue);
assert!(matches!(
mine_rx.try_recv().expect("a report").outcome,
Settled::Replayed
));
assert!(other_rx.try_recv().is_err());
}
#[test]
fn a_refused_page_settles_its_relay_as_refused() {
let pages = PageRegistry::default();
let id = SubscriptionId::new("concord-history-1");
let (sender, receiver) = flume::bounded(1);
pages.register(id.clone(), sender);
let mut batch = Batch::default();
route(
message(RelayMessage::closed(id, "blocked: not allowed")),
&pages,
&WatchRegistry::default(),
&mut batch,
);
match receiver.try_recv().expect("a report").outcome {
Settled::Refused(reason) => assert!(reason.contains("blocked")),
other => panic!("expected a refusal, got {other:?}"),
}
}
/// The SDK re-issues an `auth-required` REQ under the same id after AUTH, so
/// the page keeps waiting rather than writing the relay off.
#[test]
fn an_auth_required_close_settles_nothing() {
let pages = PageRegistry::default();
let id = SubscriptionId::new("concord-history-1");
let (sender, receiver) = flume::bounded(1);
pages.register(id.clone(), sender);
let mut batch = Batch::default();
route(
message(RelayMessage::closed(
id,
"auth-required: please authenticate",
)),
&pages,
&WatchRegistry::default(),
&mut batch,
);
assert!(receiver.try_recv().is_err());
}
#[test]
fn a_shutdown_stops_the_pump() {
let pages = PageRegistry::default();
let mut batch = Batch::default();
assert_eq!(
route(
ClientNotification::Shutdown,
&pages,
&WatchRegistry::default(),
&mut batch
),
Flow::Stop
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "community_ui"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
community = { path = "../community" }
state = { path = "../state" }
ui = { path = "../ui" }
theme = { path = "../theme" }
common = { path = "../common" }
person = { path = "../person" }
settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
+792
View File
@@ -0,0 +1,792 @@
use std::collections::HashMap;
use std::fmt;
use anyhow::Result;
use community::{
ChannelId, ChatMessage, Community, CommunityEvent, Epoch, Intent, LOAD_OLDER_PAGES,
TIMELINE_PAGE, Timeline,
};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode,
IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString,
Styled, Subscription, Task, WeakEntity, Window, div, list, px,
};
use nostr_sdk::prelude::EventId;
use smallvec::{SmallVec, smallvec};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{InputEvent, Textarea, TextareaState};
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
mod message;
/// How near the top row a scroll has to come before the panel splices older history in.
const LOAD_OLDER_THRESHOLD: usize = 20;
/// A repeat message within this window keeps its run, so it carries no avatar or name.
const RUN_WINDOW_MS: u64 = 300_000;
#[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 {
fn writable(self) -> bool {
matches!(self, Notice::Unreachable | Notice::Unreadable(_))
}
}
pub fn init(
community: Entity<Community>,
window: &mut Window,
cx: &mut App,
) -> Entity<CommunityPanel> {
cx.new(|cx| CommunityPanel::new(community, window, cx))
}
/// Community Panel
pub struct CommunityPanel {
id: SharedString,
focus_handle: FocusHandle,
/// Community
community: WeakEntity<Community>,
/// The selected channel
channel: Option<ChannelId>,
/// The selected channel's timeline (oldest first)
rows: Vec<ChatMessage>,
/// Whether the store holds rows older than `rows`
has_more: bool,
/// A round or a page read is in flight
loading: bool,
/// Message list state
list_state: ListState,
/// Message input state
input: Entity<TextareaState>,
/// Spawned reads and publishes, cancelled when the panel closes
tasks: SmallVec<[Task<Result<()>>; 4]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl CommunityPanel {
pub fn new(community: Entity<Community>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let (id, name, channel) = {
let community = community.read(cx);
(
SharedString::from(format!("community-{}", community.id().to_hex())),
community.name(),
community.active_channel(),
)
};
let input = cx.new(|cx| {
TextareaState::new(window, cx)
.placeholder(format!("Message {name}"))
.auto_grow(1, 20)
.clean_on_escape()
});
let mut subscriptions = smallvec![];
subscriptions.push(
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.send(window, cx);
}
}),
);
subscriptions.push(cx.subscribe_in(
&community,
window,
|_this, _community, event, window, cx| {
match event {
CommunityEvent::Updated(_)
| CommunityEvent::Unreadable(_)
| CommunityEvent::Failed(_) => {
cx.defer_in(window, |this, window, cx| this.reload(window, cx));
}
CommunityEvent::Channel(..) => {
cx.defer_in(window, |this, window, cx| this.load(window, cx));
}
CommunityEvent::Error(error) => {
window.push_notification(
Notification::error(error.clone()).autohide(false),
cx,
);
}
CommunityEvent::Open(_) | CommunityEvent::Close(_) => {}
};
},
));
let panel = Self {
id,
focus_handle: cx.focus_handle(),
community: community.downgrade(),
channel,
rows: Vec::new(),
has_more: false,
loading: false,
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
input,
tasks: smallvec![],
_subscriptions: subscriptions,
};
panel.list_state.set_follow_mode(FollowMode::Tail);
panel.list_state.set_scroll_handler(cx.listener(
|this, event: &ListScrollEvent, window, cx| {
if event.visible_range.start <= LOAD_OLDER_THRESHOLD {
this.load_older(window, cx);
}
},
));
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
panel
}
/// The channel to show, following the community's selection.
fn resolve_channel(&mut self, cx: &App) -> Option<ChannelId> {
let channel = self
.community
.read_with(cx, |community, _cx| community.active_channel())
.ok()
.flatten();
if channel != self.channel {
self.channel = channel;
self.rows.clear();
self.has_more = false;
self.loading = false;
self.list_state.reset(1);
}
channel
}
/// The list's item count: the load-older row, then every message row.
fn item_count(&self) -> usize {
self.rows.len() + 1
}
/// A timeline read, `before_ms` exclusive, or `None` for the newest rows.
fn read(
&self,
channel: ChannelId,
before_ms: Option<u64>,
cx: &App,
) -> Option<Task<Result<Timeline>>> {
self.community
.read_with(cx, |community, cx| {
community.timeline(&channel, before_ms, TIMELINE_PAGE, cx)
})
.ok()
}
/// A channel round, or `None` once the community is gone.
fn sync(
&self,
channel: ChannelId,
intent: Intent,
cx: &mut App,
) -> Option<Task<Result<community::Progress>>> {
self.community
.update(cx, |community, cx| {
community.sync_channel(&channel, intent, cx)
})
.ok()
}
/// Paint the selected channel's cache, then catch it up from the relays.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(channel) = self.resolve_channel(cx) else {
return;
};
self.reload(window, cx);
// Opening a channel twice in a breath asks the relays once.
if self.due(channel, cx) {
self.round(channel, Intent::CatchUp, window, cx);
}
}
/// Whether the community would actually round `channel`, or just serve it.
fn due(&self, channel: ChannelId, cx: &App) -> bool {
self.community
.read_with(cx, |community, _cx| community.due(&channel))
.unwrap_or(true)
}
/// Why the room is not showing messages, when it is not simply empty.
fn notice(&self, cx: &App) -> Option<Notice> {
let channel = self.channel?;
self.community
.read_with(cx, |community, _cx| {
if community.stranded() {
return Some(Notice::Stranded);
}
if let Some(epoch) = community.removed_at() {
return Some(Notice::Removed(epoch));
}
if let Some(epoch) = community.channel_removed_at(&channel) {
return Some(Notice::ChannelRemoved(epoch));
}
if let Some(epoch) = community.missing_key(&channel) {
return Some(Notice::MissingKey(epoch));
}
if community
.progress(&channel)
.is_some_and(|progress| progress.failed && progress.errors > 0)
{
return Some(Notice::Unreachable);
}
let unreadable = community.unreadable(&channel);
(unreadable > 0).then_some(Notice::Unreadable(unreadable))
})
.ok()
.flatten()
}
/// Run a round for `channel`, its completion re-reads the timeline.
fn round(
&mut self,
channel: ChannelId,
intent: Intent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(round) = self.sync(channel, intent, cx) else {
return;
};
self.loading = true;
cx.notify();
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
let result = round.await;
this.update_in(cx, |this, window, cx| {
this.loading = false;
if let Err(error) = result {
window.push_notification(
Notification::error(error.to_string()).autohide(false),
cx,
);
}
})?;
Ok(())
});
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.
fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(channel) = self.resolve_channel(cx) else {
return;
};
let Some(timeline) = self.read(channel, None, cx) else {
return;
};
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
match timeline.await {
Ok(timeline) => this.update(cx, |this, cx| this.apply(channel, timeline, cx))?,
Err(error) => {
this.update_in(cx, |_this, window, cx| {
window.push_notification(
Notification::error(error.to_string()).autohide(false),
cx,
);
})?;
}
}
Ok(())
});
self.tasks.push(task);
}
/// Splice the page of history above the oldest row on screen.
fn load_older(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.loading || !self.has_more {
return;
}
let Some(channel) = self.channel else {
return;
};
let Some(before_ms) = self
.rows
.first()
.map(|message| message.at_ms.saturating_sub(1))
else {
return;
};
let Some(page) = self.read(channel, Some(before_ms), cx) else {
return;
};
self.loading = true;
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
let timeline = match page.await {
Ok(timeline) => timeline,
Err(error) => {
this.update_in(cx, |this, window, cx| {
this.loading = false;
window.push_notification(
Notification::error(error.to_string()).autohide(false),
cx,
);
})?;
return Ok(());
}
};
let swept = this.update(cx, |this, cx| {
this.prepend(channel, timeline, cx);
!this.has_more
})?;
if !swept {
this.update(cx, |this, _cx| this.loading = false)?;
return Ok(());
}
let round = this.update(cx, |this, cx| {
this.sync(
channel,
Intent::Older {
pages: LOAD_OLDER_PAGES,
},
cx,
)
})?;
let Some(round) = round else {
this.update(cx, |this, _cx| this.loading = false)?;
return Ok(());
};
match round.await {
Ok(_) => {
let page =
this.update(cx, |this, cx| this.read(channel, Some(before_ms), cx))?;
if let Some(page) = page {
match page.await {
Ok(timeline) => {
this.update(cx, |this, cx| this.prepend(channel, timeline, cx))?
}
Err(error) => this.update_in(cx, |_this, window, cx| {
window.push_notification(
Notification::error(error.to_string()).autohide(false),
cx,
);
})?,
}
}
this.update(cx, |this, _cx| this.loading = false)?;
}
Err(error) => {
this.update_in(cx, |this, window, cx| {
this.loading = false;
window.push_notification(
Notification::error(error.to_string()).autohide(false),
cx,
);
})?;
}
}
Ok(())
});
self.tasks.push(task);
}
/// Fold a freshly read window into the rows on screen.
fn apply(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context<Self>) {
if self.channel != Some(channel) {
return;
}
let Timeline { messages, has_more } = timeline;
let connected = self
.rows
.last()
.is_some_and(|last| messages.iter().any(|message| message.id == last.id));
if !connected {
self.rows = messages;
self.has_more = has_more;
self.list_state.reset(self.item_count());
cx.notify();
return;
}
self.merge(messages);
cx.notify();
}
/// Splice older rows in above what is on screen.
fn prepend(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context<Self>) {
if self.channel != Some(channel) {
return;
}
self.has_more = timeline.has_more;
self.merge(timeline.messages);
cx.notify();
}
/// Fold read rows into what is on screen, keeping the list in time order.
fn merge(&mut self, messages: Vec<ChatMessage>) {
let mut shown: HashMap<EventId, usize> = self
.rows
.iter()
.enumerate()
.map(|(ix, message)| (message.id, ix))
.collect();
let mut fresh = Vec::new();
for message in messages {
match shown.get(&message.id).copied() {
Some(ix) => self.rows[ix] = message,
None => fresh.push(message),
}
}
for message in fresh {
if shown.contains_key(&message.id) {
continue;
}
let at = self
.rows
.partition_point(|row| (row.at_ms, row.id) <= (message.at_ms, message.id));
shown.insert(message.id, at);
self.rows.insert(at, message);
self.list_state.splice(at + 1..at + 1, 1);
}
}
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let content = self.input.read(cx).value().trim().to_owned();
if content.is_empty() {
window.push_notification("Cannot send an empty message", cx);
return;
}
let Some(channel) = self.resolve_channel(cx) else {
return;
};
if let Some(notice) = self.notice(cx).filter(|notice| !notice.writable()) {
window.push_notification(Notification::error(notice.to_string()).autohide(false), cx);
return;
}
let Ok(send) = self.community.read_with(cx, |community, cx| {
community.send(&channel, &content, None, cx)
}) else {
return;
};
let Some(send) = send else {
window.push_notification(Notification::error("Failed to send the message"), cx);
return;
};
self.input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
match send.await {
Ok(_) => {
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
}
Err(error) => {
this.update_in(cx, |_this, window, cx| {
window.push_notification(
Notification::error(error.to_string()).autohide(false),
cx,
);
})?;
}
}
Ok(())
});
self.tasks.push(task);
}
/// The honest reason this room has nothing to show, with a way out of it.
fn render_notice(&self, notice: Notice, cx: &mut Context<Self>) -> AnyElement {
h_flex()
.w_full()
.justify_center()
.items_center()
.gap_2()
.px_3()
.py_2()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child(notice.to_string())
.when(notice == Notice::Unreachable, |this| {
this.child(
Button::new("retry-round")
.label("Retry")
.ghost()
.small()
.loading(self.loading)
.on_click(cx.listener(|this, _event, window, cx| this.retry(window, cx))),
)
})
.into_any_element()
}
/// The row at index 0: the affordance that pages older history in.
fn render_older(&self, cx: &mut Context<Self>) -> AnyElement {
if !self.has_more {
return div().into_any_element();
}
h_flex()
.w_full()
.justify_center()
.py_2()
.child(
Button::new("load-older")
.label(if self.loading {
"Loading earlier messages…"
} else {
"Load earlier messages"
})
.ghost()
.small()
.loading(self.loading)
.on_click(cx.listener(|this, _event, window, cx| this.load_older(window, cx))),
)
.into_any_element()
}
/// Whether the row at `index` opens a run from one author.
fn opens_run(&self, index: usize) -> bool {
let Some(current) = self.rows.get(index) else {
return true;
};
let Some(previous) = index.checked_sub(1).and_then(|index| self.rows.get(index)) else {
return true;
};
current.author != previous.author
|| current.at_ms.saturating_sub(previous.at_ms) > RUN_WINDOW_MS
}
fn render_message(
&mut self,
ix: usize,
_window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
if ix == 0 {
return self.render_older(cx);
}
let Some(message) = self.rows.get(ix - 1) else {
return div().into_any_element();
};
let show_author = self.opens_run(ix - 1);
message::render(ix, message, show_author, cx)
}
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
let writable = self.notice(cx).is_none_or(|notice| notice.writable());
h_flex()
.flex_shrink_0()
.w_full()
.p_2()
.gap_1()
.items_end()
.child(Textarea::new(&self.input).appearance(false).flex_1())
.child(
Button::new("send")
.icon(IconName::PaperPlaneFill)
.tooltip("Send")
.ghost()
.large()
.disabled(!writable)
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
})),
)
}
}
impl Panel for CommunityPanel {
fn panel_id(&self) -> SharedString {
self.id.clone()
}
fn title(&self, cx: &App) -> AnyElement {
self.community
.read_with(cx, |community, _cx| {
let seed = community.id().to_hex();
let avatar = match community.icon() {
Some(path) => Avatar::from_source(path).seed(seed).xsmall(),
None => Avatar::new(None).seed(seed).xsmall(),
};
h_flex()
.gap_1p5()
.child(avatar)
.child(SharedString::from(community.name()))
.into_any_element()
})
.unwrap_or_else(|_| div().child("Unknown").into_any_element())
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for CommunityPanel {}
impl Focusable for CommunityPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for CommunityPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.min_w_0()
.child(
v_flex()
.flex_1()
.min_h_0()
.relative()
.map(|this| {
let notice = self.notice(cx);
if self.rows.is_empty() {
this.child(
v_flex().size_full().justify_center().child(match notice {
Some(notice) => self.render_notice(notice, cx),
None => h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child("No messages yet")
.into_any_element(),
}),
)
} else {
this.when_some(notice, |this, notice| {
this.child(self.render_notice(notice, cx))
})
.child(
list(
self.list_state.clone(),
cx.processor(move |this, ix, window, cx| {
this.render_message(ix, window, cx)
}),
)
.size_full(),
)
}
})
.child(Scrollbar::vertical(&self.list_state)),
)
.child(self.render_composer(cx))
}
}
+107
View File
@@ -0,0 +1,107 @@
use std::collections::BTreeMap;
use common::TimestampExt;
use community::ChatMessage;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, px,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::{StyledExt, h_flex, v_flex};
pub(crate) fn render(ix: usize, message: &ChatMessage, show_author: bool, cx: &App) -> AnyElement {
let persons = PersonRegistry::global(cx);
let author = persons.read(cx).get(&message.author, cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
div()
.id(ix)
.w_full()
.py_1()
.px_3()
.hover(|this| this.bg(cx.theme().surface_background))
.child(
h_flex()
.items_start()
.gap_3()
.when(!hide_avatar, |this| {
if show_author {
this.child(
Avatar::new(author.avatar())
.seed(author.avatar_seed())
.flex_shrink_0(),
)
} else {
this.child(div().flex_shrink_0().w(px(32.)))
}
})
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.when(show_author, |this| {
this.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child(div().font_semibold().child(author.name()))
.child(
Timestamp::from_secs(message.at_ms / 1000).to_human_time(),
)
.when(message.edited_at.is_some(), |this| {
this.child(div().child("(edited)"))
}),
)
})
.child(content(message, cx))
.when(!message.reactions.is_empty(), |this| {
this.child(reactions(message, cx))
}),
),
)
.into_any_element()
}
fn content(message: &ChatMessage, cx: &App) -> AnyElement {
if message.deleted {
return div()
.text_color(cx.theme().text_danger)
.child("Message deleted")
.into_any_element();
}
div()
.child(SharedString::from(&message.content))
.into_any_element()
}
fn reactions(message: &ChatMessage, cx: &App) -> AnyElement {
let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
for emoji in message.reactions.values() {
*grouped.entry(emoji.as_str()).or_default() += 1;
}
h_flex()
.mt_1()
.gap_1()
.children(grouped.into_iter().map(|(emoji, count)| {
h_flex()
.gap_1()
.py_0p5()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border)
.text_xs()
.child(SharedString::from(emoji))
.child(SharedString::from(count.to_string()))
}))
.into_any_element()
}
+1 -1
View File
@@ -13,11 +13,11 @@ sha2.workspace = true
chacha20.workspace = true
hmac.workspace = true
data-encoding.workspace = true
base64.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
[dev-dependencies]
nostr-memory.workspace = true
smol.workspace = true
+45 -22
View File
@@ -3,8 +3,8 @@ use std::fmt;
use data_encoding::BASE64;
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
use nostr_sdk::prelude::{
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp,
UnsignedEvent,
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent,
FinalizeEventAsync, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
};
use crate::derive::GroupKey;
@@ -17,8 +17,8 @@ pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
const TAG_MS: &str = "ms";
const TAG_CHANNEL: &str = "channel";
const TAG_EPOCH: &str = "epoch";
pub(crate) const TAG_CHANNEL: &str = "channel";
pub(crate) const TAG_EPOCH: &str = "epoch";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SealForm {
@@ -198,32 +198,52 @@ pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u
}
/// A member's own document (the Community List, the Invite List): NIP-44 to self.
pub fn seal_to_self(keys: &Keys, plaintext: &[u8]) -> Result<String, StreamError> {
seal_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Encrypt(error.to_string()))?,
plaintext,
)
pub async fn seal_to_self<S>(signer: &S, plaintext: &str) -> Result<String, StreamError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
check_plaintext_cap(plaintext.len())?;
let address = signer
.get_public_key_async()
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))?;
signer
.nip44_encrypt_async(&address, plaintext)
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))
}
pub fn open_to_self(keys: &Keys, content: &str) -> Result<Vec<u8>, StreamError> {
open_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Decrypt(error.to_string()))?,
content,
)
pub async fn open_to_self<S>(signer: &S, content: &str) -> Result<String, StreamError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let address = signer
.get_public_key_async()
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
signer
.nip44_decrypt_async(&address, content)
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))
}
pub fn build_seal(
pub async fn build_seal<S>(
rumor: &UnsignedEvent,
form: SealForm,
group: &GroupKey,
author: &Keys,
) -> Result<Event, StreamError> {
author: &S,
) -> Result<Event, StreamError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = seal_content(rumor, form, group)?;
EventBuilder::new(Kind::Custom(form.kind()), content)
.custom_created_at(rumor.created_at)
.finalize(author)
.finalize_async(author)
.await
.map_err(|error| StreamError::Sign(error.to_string()))
}
@@ -400,7 +420,10 @@ fn check_plaintext_cap(len: usize) -> Result<(), StreamError> {
Ok(())
}
fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> {
pub(crate) fn unique_tag(
rumor: &UnsignedEvent,
name: &'static str,
) -> Result<Option<String>, StreamError> {
let mut found: Option<String> = None;
for tag in rumor.tags.iter() {
@@ -450,7 +473,7 @@ mod tests {
}
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
build_seal(rumor, form, &group(0), author).expect("seals")
smol::block_on(build_seal(rumor, form, &group(0), author)).expect("seals")
}
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
+47 -115
View File
@@ -1,18 +1,16 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use crate::cord01::{
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
wrap_seal,
};
use crate::cord04::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal,
};
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
pub use crate::cords::rumor::RumorError as GuestbookError;
use crate::cords::rumor::{optional_citation, pubkey, required, value};
use crate::{GroupKey, decode_hex_32};
pub const KIND_JOIN_LEAVE: u16 = 3306;
@@ -29,41 +27,6 @@ const TAG_CONTENT: &str = "content";
const CONTENT_JOIN: &str = "join";
const CONTENT_LEAVE: &str = "leave";
#[derive(Debug)]
pub enum GuestbookError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
}
impl fmt::Display for GuestbookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GuestbookError::Stream(error) => write!(f, "stream: {error}"),
GuestbookError::NotEncryptedSealed => {
write!(f, "guestbook rumor must ride an encrypted seal")
}
GuestbookError::UnknownKind(kind) => {
write!(f, "not a guestbook rumor kind: {kind}")
}
GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"),
GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"),
GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"),
}
}
}
impl std::error::Error for GuestbookError {}
impl From<StreamError> for GuestbookError {
fn from(error: StreamError) -> Self {
GuestbookError::Stream(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestbookEntry {
Join {
@@ -183,18 +146,21 @@ pub fn build_snapshot_chunks(
.collect()
}
pub fn seal_rumor(
pub async fn seal_rumor<S>(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
) -> Result<(Event, Keys), GuestbookError> {
author: &S,
) -> Result<(Event, Keys), GuestbookError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let kind = rumor.kind.as_u16();
if !is_guestbook_kind(kind) {
return Err(GuestbookError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
}
@@ -221,10 +187,15 @@ pub fn open(
Ok((opened, rumor))
}
/// Coalesce the guestbook flat: one final state per npub, the latest entry
/// winning by millisecond time, ties broken by the lower rumor id.
///
/// `snapshot_authorities` are the npubs whose refounding is known to have minted an epoch this client reads.
/// A snapshot chunk is honored only from one of them, and an empty set honors no snapshot at all.
pub fn coalesce(
rumors: &[GuestbookRumor],
now_ms: u64,
snapshot_authority: Option<&PublicKey>,
snapshot_authorities: &BTreeSet<PublicKey>,
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
) -> BTreeMap<PublicKey, MemberState> {
let mut states: BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)> = BTreeMap::new();
@@ -284,7 +255,7 @@ pub fn coalesce(
at_ms,
..
} => {
if snapshot_authority != Some(refounder) {
if !snapshot_authorities.contains(refounder) {
continue;
}
@@ -466,73 +437,21 @@ fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), Guestboo
Ok((snapshot_id, (index, total)))
}
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, GuestbookError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(GuestbookError::BadTag(TAG_CITATION))
}
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
canonical_decimal(raw)
.and_then(|value| u32::try_from(value).ok())
.ok_or(GuestbookError::BadTag(TAG_SNAP))
}
fn required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], GuestbookError> {
tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name))
}
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
pubkey(value(required(rumor, name)?, name)?, name)
}
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, GuestbookError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(GuestbookError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> {
fields
.get(1)
.map(String::as_str)
.ok_or(GuestbookError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cord01::build_rumor_secs;
use crate::cord01::{StreamError, build_rumor_secs};
use crate::cord04::TAG_CITATION;
use crate::derive::guestbook_group_key;
use crate::{CommunityId, Epoch};
@@ -543,6 +462,11 @@ mod tests {
CommunityId::from_bytes([0x11u8; 32])
}
/// The refounders a fold is told about: a snapshot seeds members on theirs alone.
fn refounders(keys: &[&Keys]) -> BTreeSet<PublicKey> {
keys.iter().map(|keys| keys.public_key()).collect()
}
fn group() -> GroupKey {
guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives")
}
@@ -556,7 +480,9 @@ mod tests {
}
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
let wrap = seal_rumor(rumor, &group(), author).expect("seals").0;
let wrap = smol::block_on(seal_rumor(rumor, &group(), author))
.expect("seals")
.0;
open(&wrap, &group()).expect("opens").1
}
@@ -617,7 +543,7 @@ mod tests {
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
};
let states = coalesce(&rumors, AT + 8_000, Some(&carol.public_key()), can_kick);
let states = coalesce(&rumors, AT + 8_000, &refounders(&[&carol]), can_kick);
assert_eq!(
states.get(&alice.public_key()),
@@ -646,7 +572,7 @@ mod tests {
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
assert_eq!(
coalesce(&reversed, AT + 8_000, Some(&carol.public_key()), can_kick),
coalesce(&reversed, AT + 8_000, &refounders(&[&carol]), can_kick),
states,
"arrival order cannot change the fold"
);
@@ -733,7 +659,7 @@ mod tests {
),
];
let states = coalesce(&rumors, AT + 1_000, None, can_kick);
let states = coalesce(&rumors, AT + 1_000, &BTreeSet::new(), can_kick);
assert_eq!(
states.get(&kicked.public_key()),
@@ -770,21 +696,21 @@ mod tests {
)
.remove(0);
for authority in [None, Some(refounder.public_key())] {
for authority in [BTreeSet::new(), refounders(&[&refounder])] {
let states = coalesce(
&[
publish(&by_refounder, &refounder),
publish(&by_impostor, &impostor),
],
AT + 1_000,
authority.as_ref(),
&authority,
|_, _, _| true,
);
assert_eq!(
states.contains_key(&seeded.public_key()),
authority.is_some(),
"only the epoch's refounder seeds, and there is no owner fallback"
!authority.is_empty(),
"only a known refounder seeds, and there is no owner fallback"
);
assert!(
!states.contains_key(&smuggled.public_key()),
@@ -808,11 +734,11 @@ mod tests {
&member,
);
assert!(
coalesce(&[future], AT, None, |_, _, _| true).is_empty(),
coalesce(&[future], AT, &BTreeSet::new(), |_, _, _| true).is_empty(),
"an entry more than an hour ahead is dropped"
);
assert_eq!(
coalesce(&[horizon], AT, None, |_, _, _| true).len(),
coalesce(&[horizon], AT, &BTreeSet::new(), |_, _, _| true).len(),
1,
"the horizon itself is skew, not forgery"
);
@@ -826,7 +752,9 @@ mod tests {
);
assert!(matches!(
open(
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0,
&smol::block_on(seal_rumor(&bad_ms, &group(), &member))
.expect("seals")
.0,
&group()
),
Err(GuestbookError::Stream(StreamError::BadMs))
@@ -835,7 +763,9 @@ mod tests {
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
assert!(matches!(
open(
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0,
&smol::block_on(seal_rumor(&bad_verb, &group(), &member))
.expect("seals")
.0,
&group()
),
Err(GuestbookError::BadTag(TAG_CONTENT))
@@ -854,7 +784,7 @@ mod tests {
);
assert!(matches!(
open(
&seal_rumor(&ambiguous, &group(), &moderator)
&smol::block_on(seal_rumor(&ambiguous, &group(), &moderator))
.expect("seals")
.0,
&group()
@@ -876,7 +806,9 @@ mod tests {
);
assert!(matches!(
open(
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0,
&smol::block_on(seal_rumor(&rumor, &group(), &moderator))
.expect("seals")
.0,
&group()
),
Err(GuestbookError::BadTag(TAG_SNAP))
File diff suppressed because it is too large Load Diff
+190 -209
View File
@@ -4,7 +4,9 @@ pub mod list;
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail};
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
use nostr_sdk::prelude::{
AsyncGetPublicKey, AsyncSignEvent, Event, PublicKey, Timestamp, UnsignedEvent,
};
use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
@@ -114,17 +116,24 @@ pub struct CommunityGenesis {
pub wraps: Vec<Event>,
}
pub fn genesis(
owner: &Keys,
pub async fn genesis<S>(
owner: &S,
metadata: &CommunityMetadata,
at_secs: u64,
) -> Result<CommunityGenesis> {
) -> Result<CommunityGenesis>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let metadata_content = encode_metadata(metadata)?;
let owner_salt = random_32()?;
let owner_key = owner
.get_public_key_async()
.await
.map_err(|error| anyhow::anyhow!("signer: {error}"))?;
let identity = CommunityIdentity {
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
owner: owner.public_key(),
community_id: community_id_of(&owner_key.to_bytes(), &owner_salt),
owner: owner_key,
owner_salt,
};
@@ -167,7 +176,7 @@ pub fn genesis(
let mut wraps = Vec::with_capacity(editions.len());
for edition in &editions {
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?);
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs).await?);
}
Ok(CommunityGenesis {
@@ -211,12 +220,15 @@ pub struct Edition<'a> {
}
impl ControlWriter {
pub fn publish(
pub async fn publish<S>(
&self,
keys: &Keys,
keys: &S,
edition: Edition<'_>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let rumor = build_edition(EditionFields {
author: self.author,
subkind: edition.subkind,
@@ -231,20 +243,23 @@ impl ControlWriter {
});
let parsed = parse_edition(&rumor)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs).await?;
Ok((wrap, EntityHead::from(&parsed)))
}
pub fn set_community_metadata(
pub async fn set_community_metadata<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
metadata: &CommunityMetadata,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = encode_metadata(metadata)?;
self.publish(
@@ -258,17 +273,21 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_channel_metadata(
pub async fn set_channel_metadata<S>(
&self,
keys: &Keys,
keys: &S,
channel: &ChannelId,
metadata: &ChannelMetadata,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if metadata.name.len() > MAX_NAME_BYTES {
bail!("channel name exceeds {MAX_NAME_BYTES} bytes");
}
@@ -286,16 +305,20 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_role(
pub async fn set_role<S>(
&self,
keys: &Keys,
keys: &S,
role: &Role,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if role.name.len() > MAX_NAME_BYTES {
bail!("role name exceeds {MAX_NAME_BYTES} bytes");
}
@@ -313,17 +336,21 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_grant(
pub async fn set_grant<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
grant: &Grant,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = grant.to_content()?;
self.publish(
@@ -337,17 +364,21 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_banlist(
pub async fn set_banlist<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
banned: &BTreeSet<PublicKey>,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if banned.len() > MAX_BANLIST {
bail!("banlist exceeds {MAX_BANLIST} entries");
}
@@ -366,19 +397,23 @@ impl ControlWriter {
},
at_secs,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub fn set_registry(
pub async fn set_registry<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
creator: &PublicKey,
links: &[PublicKey],
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let entries: Vec<String> = links
.iter()
.take(MAX_REGISTRY_LINKS)
@@ -397,20 +432,24 @@ impl ControlWriter {
},
at_secs,
)
.await
}
/// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for.
#[allow(clippy::too_many_arguments)]
pub fn set_pin_list(
pub async fn set_pin_list<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
channel: &ChannelId,
content: &str,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
self.publish(
keys,
Edition {
@@ -422,6 +461,7 @@ impl ControlWriter {
},
at_secs,
)
.await
}
}
@@ -708,14 +748,17 @@ fn authorized_head<'a>(
selection.head.map(|index| authorized[index])
}
fn seal_edition(
async fn seal_edition<S>(
edition: &UnsignedEvent,
owner: &Keys,
owner: &S,
read: &GroupKey,
signer: &GroupKey,
at_secs: u64,
) -> Result<Event> {
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
) -> Result<Event>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let seal = build_seal(edition, SealForm::Plaintext, read, owner).await?;
let (wrap, _) = wrap_seal_with(
&seal,
@@ -731,15 +774,14 @@ fn seal_edition(
#[cfg(test)]
mod tests {
use nostr_memory::MemoryDatabase;
use nostr_sdk::prelude::Keys;
use super::*;
use crate::cord03::{self, build_message, seal_rumor};
use crate::cord04::fold;
use crate::cord04::pins;
use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
use crate::derive::{channel_group_key, grant_locator};
use crate::store::{CommunityState, load_state, save_state};
use crate::state::CommunityState;
use crate::{Extra, RoleId};
const AT: u64 = 1_700_000_000;
@@ -768,70 +810,10 @@ mod tests {
}
}
#[test]
fn genesis_reopens_for_a_second_holder() {
let owner = Keys::generate();
let community_metadata = CommunityMetadata {
name: "coop".to_owned(),
relays: vec!["wss://relay.example".to_owned()],
..CommunityMetadata::default()
};
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
assert!(minted.identity.verify(), "identity is self-certifying");
// Only what an invite hands over: the roots, the community id and the owner salt.
let (read, signer) = holder(&minted);
let editions = open_all(&minted.wraps, &read, &signer.pk());
assert_eq!(editions.len(), 2);
let community = &editions[0];
assert_eq!(community.subkind, vsk::COMMUNITY_METADATA);
assert_eq!(community.entity, *minted.identity.community_id.as_bytes());
assert_eq!(community.author, owner.public_key());
assert_eq!((community.version, community.prev), (1, None));
assert_eq!(
serde_json::from_str::<CommunityMetadata>(&community.content)
.expect("parses")
.name,
"coop"
);
let channel = &editions[1];
assert_eq!(channel.subkind, vsk::CHANNEL_METADATA);
assert_eq!(channel.entity, *minted.channel_id.as_bytes());
for edition in &editions {
let folded = fold(&[EditionMeta::from(edition)], 0, None);
assert_eq!(folded.head, Some(0));
assert!(
folded.anchored && !folded.gap,
"genesis anchors at its floor"
);
}
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
smol::block_on(async {
let database = MemoryDatabase::unbounded();
save_state(&database, &state).await.expect("saves");
let loaded = load_state(&database, &minted.identity.community_id)
.await
.expect("loads")
.expect("present");
assert_eq!(loaded.community_root, minted.community_root);
assert_eq!(loaded.control_root, Some(minted.control_root));
assert_eq!(loaded.channels.len(), 1);
assert_eq!(loaded.heads.len(), 2);
});
}
#[test]
fn metadata_and_channel_edits_reach_a_second_client() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
@@ -860,33 +842,31 @@ mod tests {
.get(minted.channel_id.as_bytes())
.expect("head");
let (community_wrap, _) = writer
.set_community_metadata(
&owner,
&community_id,
&CommunityMetadata {
relays: vec!["wss://relay.example".to_owned()],
..metadata("coop two")
},
Some(community_head),
None,
AT + 1,
)
.expect("publishes");
let (channel_wrap, _) = writer
.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "lobby".to_owned(),
private: false,
..ChannelMetadata::default()
},
Some(channel_head),
None,
AT + 2,
)
.expect("publishes");
let (community_wrap, _) = smol::block_on(writer.set_community_metadata(
&owner,
&community_id,
&CommunityMetadata {
relays: vec!["wss://relay.example".to_owned()],
..metadata("coop two")
},
Some(community_head),
None,
AT + 1,
))
.expect("publishes");
let (channel_wrap, _) = smol::block_on(writer.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "lobby".to_owned(),
private: false,
..ChannelMetadata::default()
},
Some(channel_head),
None,
AT + 2,
))
.expect("publishes");
let mut edited = genesis_editions.clone();
edited.extend(open_all(
@@ -938,7 +918,7 @@ mod tests {
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
let owner = Keys::generate();
let member = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
@@ -959,21 +939,20 @@ mod tests {
extra: Extra::default(),
};
let (role_wrap, _) = writer
.publish(
&owner,
Edition {
subkind: vsk::ROLE,
entity: *role_id.as_bytes(),
content: &role.to_content().expect("serializes"),
head: None,
citation: None,
},
AT + 1,
)
.expect("publishes");
let (grant_wrap, _) = writer
.publish(
let (role_wrap, _) = smol::block_on(writer.publish(
&owner,
Edition {
subkind: vsk::ROLE,
entity: *role_id.as_bytes(),
content: &role.to_content().expect("serializes"),
head: None,
citation: None,
},
AT + 1,
))
.expect("publishes");
let (grant_wrap, _) = smol::block_on(
writer.publish(
&owner,
Edition {
subkind: vsk::GRANT,
@@ -990,8 +969,9 @@ mod tests {
citation: None,
},
AT + 2,
)
.expect("publishes");
),
)
.expect("publishes");
let mut base = open_all(&minted.wraps, &read, &signer.pk());
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
@@ -1022,36 +1002,34 @@ mod tests {
};
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
let (uncited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: None,
},
AT + 3,
)
.expect("publishes");
let (cited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: Some(AuthorityCitation {
entity: grant.entity,
version: grant.version,
hash: grant.self_hash,
}),
},
AT + 4,
)
.expect("publishes");
let (uncited, _) = smol::block_on(member_writer.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: None,
},
AT + 3,
))
.expect("publishes");
let (cited, _) = smol::block_on(member_writer.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: Some(AuthorityCitation {
entity: grant.entity,
version: grant.version,
hash: grant.self_hash,
}),
},
AT + 4,
))
.expect("publishes");
// Uncited, the edit claims an authority the member never showed.
let mut forged = base.clone();
@@ -1086,7 +1064,7 @@ mod tests {
#[test]
fn a_pin_list_folds_under_its_coordinate_for_a_second_client() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
@@ -1105,7 +1083,7 @@ mod tests {
AT * 1_000,
None,
);
let (wrap, _) = seal_rumor(&rumor, &group, &author, false).expect("seals");
let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group, &author, false)).expect("seals");
let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH)
.expect("opens")
.0;
@@ -1127,17 +1105,16 @@ mod tests {
read: read.clone(),
signer: signer.clone(),
};
let (pin_wrap, _) = writer
.set_pin_list(
&owner,
&community_id,
&channel,
&content,
None,
None,
AT + 1,
)
.expect("publishes");
let (pin_wrap, _) = smol::block_on(writer.set_pin_list(
&owner,
&community_id,
&channel,
&content,
None,
None,
AT + 1,
))
.expect("publishes");
let mut editions = open_all(&minted.wraps, &read, &signer.pk());
editions.extend(open_all(&[pin_wrap], &read, &signer.pk()));
@@ -1172,7 +1149,7 @@ mod tests {
#[test]
fn the_timer_is_never_guessed_and_the_write_caps_hold() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
@@ -1187,10 +1164,16 @@ mod tests {
&owner_pk,
&community_id,
&open_all(
&[writer
.set_community_metadata(&owner, &community_id, metadata, None, None, AT + 1)
.expect("publishes")
.0],
&[smol::block_on(writer.set_community_metadata(
&owner,
&community_id,
metadata,
None,
None,
AT + 1,
))
.expect("publishes")
.0],
&writer.read,
&writer.signer.pk(),
),
@@ -1226,8 +1209,7 @@ mod tests {
.map(|_| Keys::generate().public_key())
.collect();
assert!(
writer
.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)
smol::block_on(writer.set_banlist(&owner, &community_id, &banned, None, None, AT + 2))
.is_err()
);
@@ -1242,20 +1224,19 @@ mod tests {
assert!(grant.to_content().is_err());
assert!(
writer
.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "x".repeat(MAX_NAME_BYTES + 1),
private: false,
..ChannelMetadata::default()
},
None,
None,
AT + 3,
)
.is_err()
smol::block_on(writer.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "x".repeat(MAX_NAME_BYTES + 1),
private: false,
..ChannelMetadata::default()
},
None,
None,
AT + 3,
))
.is_err()
);
}
}
+38 -96
View File
@@ -1,18 +1,17 @@
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::fmt;
use anyhow::Result;
use nostr_sdk::prelude::*;
use crate::cord01::{
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, TAG_CHANNEL, TAG_EPOCH, build_rumor_ms,
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
wrap_seal,
};
use crate::cord04::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
unique_tag, wrap_seal,
};
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
pub use crate::cords::rumor::RumorError as ChatError;
use crate::cords::rumor::{optional_citation, pubkey, tag, value};
use crate::derive::channel_group_key;
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32};
@@ -26,6 +25,9 @@ pub const KIND_TIMER_NOTICE: u16 = 1740;
pub const KIND_WEBXDC: u16 = 3310;
pub const KIND_TYPING: u16 = 23311;
pub const ROW_KINDS: [u16; 4] = [KIND_MESSAGE, KIND_FILE, KIND_COMMENT, KIND_TIMER_NOTICE];
pub const SIDE_KINDS: [u16; 3] = [KIND_DELETE, KIND_REACTION, KIND_EDIT];
const TAG_QUOTE: &str = "q";
const TAG_TARGET: &str = "e";
const TAG_TARGET_KIND: &str = "k";
@@ -36,42 +38,6 @@ const TAG_TARGET_AUTHOR: &str = "p";
const TAG_EXPIRATION: &str = "expiration";
const TAG_TIMER: &str = "timer";
#[derive(Debug)]
pub enum ChatError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
/// Neither a delete nor a timer notice may be erased by the policy it carries.
ExemptExpiration,
}
impl fmt::Display for ChatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ChatError::Stream(error) => write!(f, "stream: {error}"),
ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"),
ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"),
ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"),
ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"),
ChatError::ExemptExpiration => {
write!(f, "a delete or timer notice must not carry an expiration")
}
}
}
}
impl std::error::Error for ChatError {}
impl From<StreamError> for ChatError {
fn from(error: StreamError) -> Self {
ChatError::Stream(error)
}
}
/// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplyRef {
@@ -163,7 +129,6 @@ pub fn build_message(
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
}
/// `parent` is the immediate parent; a `None` root means the parent is the thread's root.
#[allow(clippy::too_many_arguments)]
pub fn build_comment(
author: PublicKey,
@@ -180,12 +145,14 @@ pub fn build_comment(
tags.push(Tag::custom(TAG_ROOT_KIND, [root.kind.to_string()]));
tags.push(reply_tag(TAG_ROOT, &root.reply));
if let Some(root_author) = root.reply.author {
tags.push(Tag::custom(TAG_ROOT_AUTHOR, [root_author.to_hex()]));
}
tags.push(Tag::custom(TAG_TARGET_KIND, [parent.kind.to_string()]));
tags.push(reply_tag(TAG_TARGET, &parent.reply));
if let Some(parent_author) = parent.reply.author {
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()]));
}
@@ -292,19 +259,22 @@ pub fn build_typing(
}
/// `ephemeral` picks the 21059 wrap, which relays must not store.
pub fn seal_rumor(
pub async fn seal_rumor<S>(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
author: &S,
ephemeral: bool,
) -> Result<(Event, Keys), ChatError> {
) -> Result<(Event, Keys), ChatError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let kind = rumor.kind.as_u16();
if !is_chat_kind(kind) {
return Err(ChatError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
let wrap_kind = if ephemeral {
KIND_WRAP_EPHEMERAL
} else {
@@ -348,6 +318,20 @@ pub fn open(
Ok((opened, chat))
}
/// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch.
pub fn parse_rumor(rumor: &UnsignedEvent) -> Result<ChatRumor, ChatError> {
let channel: ChannelId = unique_tag(rumor, TAG_CHANNEL)?
.ok_or(ChatError::MissingTag(TAG_CHANNEL))?
.parse()
.map_err(|_| ChatError::BadTag(TAG_CHANNEL))?;
let epoch = unique_tag(rumor, TAG_EPOCH)?
.ok_or(ChatError::MissingTag(TAG_EPOCH))
.and_then(|raw| canonical_decimal(&raw).ok_or(ChatError::BadTag(TAG_EPOCH)))?;
typed(rumor, &channel, Epoch(epoch))
}
/// `secret` is the `community_root` for a public channel, its own key for a private one.
pub fn plane_keys(
held: &[(Epoch, [u8; 32])],
@@ -576,16 +560,6 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16
.map_err(|_| ChatError::BadTag(name))
}
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, ChatError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(ChatError::BadTag(TAG_CITATION))
}
pub fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
return Ok(None);
@@ -611,51 +585,16 @@ fn reply_tag(name: &str, reply: &ReplyRef) -> Tag {
)
}
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, ChatError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(ChatError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
fields
.get(1)
.map(String::as_str)
.ok_or(ChatError::BadTag(name))
}
fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> {
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, ChatError> {
let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cord01::StreamError;
const SECRET: [u8; 32] = [0x2du8; 32];
const AT: u64 = 1_700_000_000_417;
@@ -674,7 +613,9 @@ mod tests {
}
fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event {
seal_rumor(rumor, group, author, false).expect("seals").0
smol::block_on(seal_rumor(rumor, group, author, false))
.expect("seals")
.0
}
fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor {
@@ -949,7 +890,8 @@ mod tests {
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
// chat rumor however well-formed it looks.
let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals");
let seal =
smol::block_on(build_seal(&plain, SealForm::Plaintext, &group, &alice)).expect("seals");
let (wrap, _) = wrap_seal(
&seal,
&group,
@@ -971,7 +913,7 @@ mod tests {
AT,
);
assert!(matches!(
seal_rumor(&ghost, &group, &alice, false),
smol::block_on(seal_rumor(&ghost, &group, &alice, false)),
Err(ChatError::UnknownKind(3300))
));
+4 -4
View File
@@ -121,7 +121,7 @@ fn signing_bytes(
bytes
}
pub fn edition_hash(
fn edition_hash(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
@@ -246,14 +246,14 @@ impl From<&ParsedEdition> for EditionMeta {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FoldResult {
struct FoldResult {
pub head: Option<usize>,
pub gap: bool,
pub anchored: bool,
}
/// The highest version whose chain is intact, given a held floor.
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
for (index, edition) in editions.iter().enumerate() {
@@ -311,7 +311,7 @@ pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>)
}
/// The highest version overall, ignoring contiguity.
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
editions
.iter()
.enumerate()
+6 -4
View File
@@ -583,7 +583,7 @@ mod tests {
at_ms,
None,
);
let (wrap, _) = seal_rumor(&rumor, &group(), author, false).expect("seals");
let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group(), author, false)).expect("seals");
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
}
@@ -699,7 +699,7 @@ mod tests {
AT_MS + 5_000,
None,
);
let (wrap, _) = seal_rumor(&edit, &group(), &author, false).expect("seals");
let (wrap, _) = smol::block_on(seal_rumor(&edit, &group(), &author, false)).expect("seals");
let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
@@ -718,7 +718,8 @@ mod tests {
AT_MS + 6_000,
None,
);
let (wrap, _) = seal_rumor(&hijack, &group(), &stranger, false).expect("seals");
let (wrap, _) =
smol::block_on(seal_rumor(&hijack, &group(), &stranger, false)).expect("seals");
let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
@@ -805,7 +806,8 @@ mod tests {
None,
AT_MS + 1_000,
);
let (wrap, _) = seal_rumor(&delete, &group(), author_keys, false).expect("seals");
let (wrap, _) =
smol::block_on(seal_rumor(&delete, &group(), author_keys, false)).expect("seals");
let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
assert_eq!(
+3 -3
View File
@@ -100,7 +100,7 @@ pub struct Role {
}
impl Role {
pub fn parse(content: &str) -> Option<Self> {
fn parse(content: &str) -> Option<Self> {
serde_json::from_str(content).ok()
}
@@ -122,7 +122,7 @@ pub struct Grant {
}
impl Grant {
pub fn parse(content: &str) -> Option<Self> {
fn parse(content: &str) -> Option<Self> {
serde_json::from_str(content).ok()
}
@@ -135,7 +135,7 @@ impl Grant {
}
}
pub fn parse_banlist(content: &str) -> Option<Vec<PublicKey>> {
fn parse_banlist(content: &str) -> Option<Vec<PublicKey>> {
let entries: Vec<String> = serde_json::from_str(content).ok()?;
let mut banned = Vec::with_capacity(entries.len());
+44 -21
View File
@@ -11,10 +11,10 @@ use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError};
use crate::cord02::list::{canonical, union};
use crate::cord02::{ImageRef, MAX_RELAYS};
use crate::cord04::{TAG_SUBKIND, vsk};
use crate::derive::{TOKEN_LEN, verify_community_id};
use crate::utils::{canonical, union};
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
pub const KIND_BUNDLE: u16 = 33301;
@@ -447,16 +447,19 @@ pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
})
}
pub fn build_direct_invite(
inviter: &Keys,
pub async fn build_direct_invite<S>(
inviter: &S,
recipient: &PublicKey,
invite: &CommunityInvite,
) -> Result<Event, InviteError> {
) -> Result<Event, InviteError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
{
invite.validate()?;
let json = serde_json::to_string(invite).map_err(json_error)?;
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json)
.finalize_unsigned(inviter.public_key());
let author = inviter.get_public_key_async().await.map_err(crypto_error)?;
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json).finalize_unsigned(author);
let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])];
@@ -469,15 +472,22 @@ pub fn build_direct_invite(
GiftWrapBuilder::new(*recipient, rumor)
.extra_tags(tags)
.finalize(inviter)
.finalize_async(inviter)
.await
.map_err(crypto_error)
}
pub fn unwrap_direct_invite(
/// The NIP-59 unwrap is `Sized`-bounded in the SDK, so this stays `Sized` too.
pub async fn unwrap_direct_invite<S>(
wrap: &Event,
recipient: &Keys,
) -> Result<(PublicKey, CommunityInvite), InviteError> {
let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?;
recipient: &S,
) -> Result<(PublicKey, CommunityInvite), InviteError>
where
S: AsyncNip44,
{
let unwrapped = UnwrappedGift::from_gift_wrap_async(recipient, wrap)
.await
.map_err(crypto_error)?;
if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE {
return Err(InviteError::BadEvent("rumor is not a direct invite"));
@@ -590,25 +600,32 @@ pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList
}
}
pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
pub async fn build_invite_list<S>(keys: &S, list: &InviteList) -> Result<Event, InviteError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = cord01::seal_to_self(keys, json.as_bytes())?;
let content = cord01::seal_to_self(keys, &json).await?;
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
.finalize(keys)
.finalize_async(keys)
.await
.map_err(crypto_error)
}
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> {
pub async fn parse_invite_list<S>(keys: &S, event: &Event) -> Result<InviteList, InviteError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
if event.kind.as_u16() != KIND_INVITE_LIST {
return Err(InviteError::Kind(event.kind.as_u16()));
}
let json = cord01::open_to_self(keys, &event.content)?;
let json = cord01::open_to_self(keys, &event.content).await?;
serde_json::from_slice(&json).map_err(json_error)
serde_json::from_str(&json).map_err(json_error)
}
/// An entry is immutable once minted, so two copies should agree.
@@ -920,7 +937,12 @@ mod tests {
let recipient = Keys::generate();
let invite = bundle();
let wrap = build_direct_invite(&inviter, &recipient.public_key(), &invite).expect("builds");
let wrap = smol::block_on(build_direct_invite(
&inviter,
&recipient.public_key(),
&invite,
))
.expect("builds");
assert_eq!(wrap.kind, Kind::GiftWrap);
assert_ne!(
wrap.pubkey,
@@ -932,13 +954,14 @@ mod tests {
"the k tag is what makes an invite indexable"
);
let (sender, opened) = unwrap_direct_invite(&wrap, &recipient).expect("unwraps");
let (sender, opened) =
smol::block_on(unwrap_direct_invite(&wrap, &recipient)).expect("unwraps");
assert_eq!(sender, inviter.public_key());
assert_eq!(opened.community_id, invite.community_id);
// Somebody else's wrap is not ours to open...
let stranger = Keys::generate();
assert!(unwrap_direct_invite(&wrap, &stranger).is_err());
assert!(smol::block_on(unwrap_direct_invite(&wrap, &stranger)).is_err());
// ...and a wrap that opens to some other kind is not an invite.
let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello")
@@ -947,7 +970,7 @@ mod tests {
.finalize(&recipient)
.expect("wraps");
assert!(matches!(
unwrap_direct_invite(&wrap, &recipient),
smol::block_on(unwrap_direct_invite(&wrap, &recipient)),
Err(InviteError::BadEvent(_))
));
}
+137 -74
View File
@@ -2,9 +2,10 @@ use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, Timestamp, UnsignedEvent};
use data_encoding::{BASE64, HEXLOWER};
use nostr_sdk::prelude::{
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, PublicKey, Tag, Timestamp, UnsignedEvent,
};
use serde::{Deserialize, Serialize};
use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError};
@@ -298,34 +299,48 @@ pub fn blob_locator(
))
}
pub fn build_blob(
rotator: &Keys,
pub async fn build_blob<S>(
rotator: &S,
recipient: &PublicKey,
scope: RekeyScope,
epoch: Epoch,
new_key: &[u8; 32],
control_pk: Option<&[u8; 32]>,
control_root: Option<&[u8; 32]>,
) -> Result<RekeyBlob, RekeyError> {
) -> Result<RekeyBlob, RekeyError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let plaintext = encode_blob_plaintext(scope, epoch, new_key, control_pk, control_root)?;
let rotator_pk = rotator.get_public_key_async().await.map_err(crypto_error)?;
let wrapped = rotator
.nip44_encrypt_async(recipient, &BASE64.encode(&plaintext))
.await
.map_err(crypto_error)?;
Ok(RekeyBlob {
locator: blob_locator(&rotator.public_key(), recipient, scope, epoch),
wrapped: seal_to(rotator.secret_key(), recipient, &plaintext)?,
locator: blob_locator(&rotator_pk, recipient, scope, epoch),
wrapped,
})
}
pub fn open_blob(
recipient: &Keys,
pub async fn open_blob<S>(
recipient: &S,
rotator: &PublicKey,
scope: RekeyScope,
epoch: Epoch,
blob: &RekeyBlob,
community_id: &CommunityId,
) -> Result<KeyDelivery, RekeyError> {
let conversation =
ConversationKey::derive(recipient.secret_key(), rotator).map_err(crypto_error)?;
let plaintext = cord01::open_bytes(&conversation, &blob.wrapped)?;
) -> Result<KeyDelivery, RekeyError>
where
S: AsyncNip44 + ?Sized,
{
let text = recipient
.nip44_decrypt_async(rotator, &blob.wrapped)
.await
.map_err(crypto_error)?;
let plaintext = BASE64.decode(text.as_bytes()).map_err(crypto_error)?;
parse_blob_plaintext(&plaintext, scope, epoch, community_id)
}
@@ -341,15 +356,6 @@ pub fn find_my_blobs<'a>(
blobs.iter().filter(move |blob| blob.locator == wanted)
}
fn seal_to(
secret: &SecretKey,
recipient: &PublicKey,
plaintext: &[u8],
) -> Result<String, RekeyError> {
let conversation = ConversationKey::derive(secret, recipient).map_err(crypto_error)?;
Ok(cord01::seal_bytes(&conversation, plaintext)?)
}
#[derive(Debug, Clone)]
pub struct RekeyChunk {
pub rotator: PublicKey,
@@ -528,6 +534,43 @@ pub fn plan_refounding(epoch: Epoch) -> Result<Refounding> {
})
}
/// The material one rotation delivers, by scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotationPlan {
/// A refounding: a fresh root, with the root that reads and signs under it.
Base(Refounding),
/// A channel: a fresh channel key, and nothing beside it.
Channel { epoch: Epoch, new_key: [u8; 32] },
}
impl RotationPlan {
pub fn epoch(&self) -> Epoch {
match self {
Self::Base(refounding) => refounding.epoch,
Self::Channel { epoch, .. } => *epoch,
}
}
/// The secret the rotation delivers, which becomes the scope's new read key.
pub fn new_key(&self) -> [u8; 32] {
match self {
Self::Base(refounding) => refounding.new_root,
Self::Channel { new_key, .. } => *new_key,
}
}
}
/// Mint what a rotation of `scope` delivers at `epoch`.
pub fn plan_rotation(scope: RekeyScope, epoch: Epoch) -> Result<RotationPlan> {
match scope {
RekeyScope::Base => Ok(RotationPlan::Base(plan_refounding(epoch)?)),
RekeyScope::Channel(_) => Ok(RotationPlan::Channel {
epoch,
new_key: random_32()?,
}),
}
}
/// Carries the settled heads across a refounding.
pub fn compact(
seals: &[Event],
@@ -598,8 +641,8 @@ pub fn build_rekey_rumor(
}
#[allow(clippy::too_many_arguments)]
pub fn build_rekey_chunks(
rotator: &Keys,
pub async fn build_rekey_chunks<S>(
rotator: &S,
group: &GroupKey,
scope: RekeyScope,
new_epoch: Epoch,
@@ -609,7 +652,11 @@ pub fn build_rekey_chunks(
citation: Option<&AuthorityCitation>,
severed: bool,
at_secs: u64,
) -> Result<Vec<Event>, RekeyError> {
) -> Result<Vec<Event>, RekeyError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let rotator_key = rotator.get_public_key_async().await.map_err(crypto_error)?;
let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect();
if groups.is_empty() {
@@ -621,7 +668,7 @@ pub fn build_rekey_chunks(
for (index, group_blobs) in groups.into_iter().enumerate() {
let rumor = build_rekey_rumor(
rotator.public_key(),
rotator_key,
scope,
new_epoch,
prev_epoch,
@@ -633,7 +680,7 @@ pub fn build_rekey_chunks(
at_secs,
)?;
let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator)?;
let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator).await?;
let (wrap, _) = cord01::wrap_seal(
&seal,
group,
@@ -731,14 +778,17 @@ pub fn dissolved_tombstone_rumor(
)
}
pub fn seal_dissolved(
pub async fn seal_dissolved<S>(
rumor: &UnsignedEvent,
community_id: &CommunityId,
owner: &Keys,
owner: &S,
at_secs: u64,
) -> Result<Event, RekeyError> {
) -> Result<Event, RekeyError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let group = dissolved_group_key(community_id).map_err(crypto_error)?;
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner)?;
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner).await?;
let (wrap, _) = cord01::wrap_seal(
&seal,
&group,
@@ -858,6 +908,8 @@ fn crypto_error(error: impl fmt::Display) -> RekeyError {
mod tests {
use std::collections::BTreeSet;
use nostr_sdk::prelude::Keys;
use super::*;
use crate::cord01::KIND_WRAP;
use crate::cord02::{
@@ -912,16 +964,16 @@ mod tests {
let scope = RekeyScope::Channel(channel());
let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| {
open_blob(
smol::block_on(open_blob(
keys,
&rotator.public_key(),
scope,
epoch,
blob,
&community_id,
)
))
};
let blob = build_blob(
let blob = smol::block_on(build_blob(
&rotator,
&recipient.public_key(),
scope,
@@ -929,7 +981,7 @@ mod tests {
&key,
None,
None,
)
))
.expect("builds");
assert_eq!(
@@ -962,7 +1014,7 @@ mod tests {
.to_bytes();
let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| {
build_blob(
smol::block_on(build_blob(
&rotator,
&recipient.public_key(),
RekeyScope::Base,
@@ -970,7 +1022,7 @@ mod tests {
&key,
pk,
root,
)
))
.expect("builds")
};
@@ -1043,7 +1095,7 @@ mod tests {
let community_id = community();
let blob_for = |recipient: &Keys, key: [u8; 32]| {
build_blob(
smol::block_on(build_blob(
&rotator,
&recipient.public_key(),
scope,
@@ -1051,7 +1103,7 @@ mod tests {
&key,
None,
None,
)
))
.expect("builds")
};
let mine = blob_for(&me, [0xAA; 32]);
@@ -1059,7 +1111,7 @@ mod tests {
let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives");
let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY);
let chunks = build_rekey_chunks(
let chunks = smol::block_on(build_rekey_chunks(
&rotator,
&group,
scope,
@@ -1070,7 +1122,7 @@ mod tests {
None,
false,
AT,
)
))
.expect("builds");
assert_eq!(chunks.len(), 1);
@@ -1129,14 +1181,14 @@ mod tests {
.next()
.expect("located");
assert_eq!(
open_blob(
smol::block_on(open_blob(
&me,
&rotator.public_key(),
scope,
epoch,
located,
&community_id
)
))
.expect("opens")
.new_key,
[0xAA; 32]
@@ -1269,26 +1321,26 @@ mod tests {
content: String,
at_secs: u64,
) -> Event {
writer
.publish(
owner,
Edition {
subkind,
entity,
content: &content,
head: None,
citation: None,
},
at_secs,
)
.expect("publishes")
.0
smol::block_on(writer.publish(
owner,
Edition {
subkind,
entity,
content: &content,
head: None,
citation: None,
},
at_secs,
))
.expect("publishes")
.0
}
#[test]
fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() {
let owner = Keys::generate();
let minted = genesis(&owner, &CommunityMetadata::default(), AT).expect("mints");
let minted =
smol::block_on(genesis(&owner, &CommunityMetadata::default(), AT)).expect("mints");
let community_id = minted.identity.community_id;
let read =
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives");
@@ -1413,7 +1465,7 @@ mod tests {
.map(|_| {
let member = Keys::generate();
build_blob(
smol::block_on(build_blob(
&rotator,
&member.public_key(),
scope,
@@ -1421,12 +1473,12 @@ mod tests {
&[0xCD; 32],
None,
None,
)
))
.expect("builds")
})
.collect();
let chunks = build_rekey_chunks(
let chunks = smol::block_on(build_rekey_chunks(
&rotator,
&group,
scope,
@@ -1437,7 +1489,7 @@ mod tests {
None,
false,
AT,
)
))
.expect("builds");
assert_eq!(chunks.len(), 1, "a full send chunk is one event");
@@ -1452,7 +1504,7 @@ mod tests {
wrapped: "x".to_owned(),
});
let chunks = build_rekey_chunks(
let chunks = smol::block_on(build_rekey_chunks(
&rotator,
&group,
scope,
@@ -1463,7 +1515,7 @@ mod tests {
None,
false,
AT,
)
))
.expect("builds");
assert_eq!(chunks.len(), 2, "one over the cap splits across two events");
@@ -1485,8 +1537,13 @@ mod tests {
content: "{}",
at_secs: AT,
});
let seal =
cord01::build_seal(&rumor, SealForm::Plaintext, &prior_read, &owner).expect("seals");
let seal = smol::block_on(cord01::build_seal(
&rumor,
SealForm::Plaintext,
&prior_read,
&owner,
))
.expect("seals");
let refounding = plan_refounding(Epoch(1)).expect("plans");
let read = refounding.read(&community_id).expect("derives");
@@ -1507,8 +1564,13 @@ mod tests {
assert_eq!(reopened.author, owner.public_key());
// Only a plaintext seal can be carried forward.
let encrypted =
cord01::build_seal(&rumor, SealForm::Encrypted, &prior_read, &owner).expect("seals");
let encrypted = smol::block_on(cord01::build_seal(
&rumor,
SealForm::Encrypted,
&prior_read,
&owner,
))
.expect("seals");
assert!(matches!(
compact(&[encrypted], &read, &signer, AT + 1),
Err(RekeyError::Stream(StreamError::NotRewrappable))
@@ -1527,7 +1589,8 @@ mod tests {
};
let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT);
let wrap = seal_dissolved(&rumor, &community_id, &owner, AT).expect("seals");
let wrap =
smol::block_on(seal_dissolved(&rumor, &community_id, &owner, AT)).expect("seals");
assert!(verify_dissolved(&wrap, &identity));
assert_eq!(
@@ -1538,18 +1601,18 @@ mod tests {
// Anyone holding the community id finds the address, but only the committed
// owner's signature counts.
let impostor = Keys::generate();
let forged = seal_dissolved(
let forged = smol::block_on(seal_dissolved(
&dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT),
&community_id,
&impostor,
AT,
)
))
.expect("seals");
assert!(!verify_dissolved(&forged, &identity));
// The spec's all-zero `eid` is refused: it would let one owner's genuine
// tombstone be re-wrapped at another of their communities and kill it.
let zeroed = seal_dissolved(
let zeroed = smol::block_on(seal_dissolved(
&cord01::build_rumor_secs(
KIND_CONTROL,
owner.public_key(),
@@ -1563,7 +1626,7 @@ mod tests {
&community_id,
&owner,
AT,
)
))
.expect("seals");
assert!(matches!(
open_dissolved(&zeroed, &community_id),
+1 -2
View File
@@ -1,5 +1,4 @@
//! One module per CORD document. CORD-07 (audio/video) is unimplemented, and
//! CORD-08's timer rides the Chat and Control planes it edits rather than owning a file.
mod rumor;
pub mod cord01;
pub mod cord02;
+96
View File
@@ -0,0 +1,96 @@
use std::fmt;
use nostr_sdk::prelude::*;
use crate::cord01::StreamError;
use crate::cord04::{AuthorityCitation, TAG_CITATION, citation_from};
use crate::decode_hex_32;
#[derive(Debug)]
pub enum RumorError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
/// Neither a delete nor a timer notice may be erased by the policy it carries.
ExemptExpiration,
}
impl fmt::Display for RumorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RumorError::Stream(error) => write!(f, "stream: {error}"),
RumorError::NotEncryptedSealed => write!(f, "rumor must ride an encrypted seal"),
RumorError::UnknownKind(kind) => write!(f, "not a rumor kind: {kind}"),
RumorError::MissingTag(name) => write!(f, "missing tag: {name}"),
RumorError::DuplicateTag(name) => write!(f, "duplicate tag: {name}"),
RumorError::BadTag(name) => write!(f, "malformed tag: {name}"),
RumorError::ExemptExpiration => {
write!(f, "a delete or timer notice must not carry an expiration")
}
}
}
}
impl std::error::Error for RumorError {}
impl From<StreamError> for RumorError {
fn from(error: StreamError) -> Self {
RumorError::Stream(error)
}
}
pub fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, RumorError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(RumorError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
pub fn required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], RumorError> {
tag(rumor, name)?.ok_or(RumorError::MissingTag(name))
}
pub fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, RumorError> {
fields
.get(1)
.map(String::as_str)
.ok_or(RumorError::BadTag(name))
}
pub fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, RumorError> {
let bytes = decode_hex_32(hex).map_err(|_| RumorError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| RumorError::BadTag(name))
}
pub fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, RumorError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(RumorError::BadTag(TAG_CITATION))
}
+5 -5
View File
@@ -2,11 +2,11 @@ mod cords;
mod types;
mod utils;
pub mod store;
pub mod state;
pub use cords::{cord01, cord02, cord03, cord04, cord05, cord06};
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
pub use utils::derive::{self, GroupKey};
pub(crate) use types::Extra;
pub(crate) use utils::{decode_hex_32, decode_hex_lower, fill_random, random_32};
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
pub use utils::decode_hex_32;
pub use utils::derive::{self, GroupKey};
pub(crate) use utils::{decode_hex_lower, fill_random, random_32};
+627
View File
@@ -0,0 +1,627 @@
use std::collections::{BTreeMap, BTreeSet};
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::cord02::list::{CommunityListEntry, JoinMaterial};
use crate::cord02::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
};
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
use crate::cord05::ChannelGrant;
use crate::derive::control_signer_group_key;
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
/// The `concord/` namespace for locally-keyed documents.
pub const STATE_PREFIX: &str = "concord/";
/// A key epoch the client still holds, retained so history stays readable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeldKey {
pub epoch: Epoch,
pub key: [u8; 32],
/// The publish time of the rotation that superseded this key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<Timestamp>,
}
/// A community root epoch the client still holds, retained for the same reason.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeldRoot {
pub epoch: Epoch,
pub key: [u8; 32],
/// The epoch's Control Plane signer, when the rotation delivered one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>,
/// The publish time of the rotation that superseded this root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelKeyRef {
pub id: ChannelId,
pub name: String,
pub private: bool,
pub epoch: Epoch,
/// The channel's read secret when the member was granted it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<[u8; 32]>,
/// Keys this one superseded, retained so a rotation never blanks history.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub priors: Vec<HeldKey>,
}
impl ChannelKeyRef {
/// The write coordinate: only the current epoch is ever published under.
pub fn current(&self) -> Option<(Epoch, [u8; 32])> {
self.key.map(|key| (self.epoch, key))
}
}
/// How far a channel's history sync has reached, in wrap times.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelCursor {
/// The newest wrap ingested, so a live subscription knows where to resume.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub newest: Option<Timestamp>,
/// The oldest wrap paged back to, so the next round resumes below it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oldest: Option<Timestamp>,
/// History verifiably swept to the bottom.
#[serde(default)]
pub exhausted: bool,
}
impl ChannelCursor {
pub fn merge(self, round: Self) -> Self {
Self {
newest: later(self.newest, round.newest),
oldest: earlier(self.oldest, round.oldest),
exhausted: self.exhausted || round.exhausted,
}
}
}
fn later(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
match (held, round) {
(Some(held), Some(round)) => Some(held.max(round)),
(held, None) => held,
(None, round) => round,
}
}
fn earlier(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
match (held, round) {
(Some(held), Some(round)) => Some(held.min(round)),
(held, None) => held,
(None, round) => round,
}
}
/// One local document per community, keyed by `concord/<community_id>`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityState {
pub id: CommunityId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub owner: PublicKey,
pub owner_salt: [u8; 32],
pub community_root: [u8; 32],
pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_root: Option<[u8; 32]>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub control_pks: BTreeMap<u64, PublicKey>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelKeyRef>,
pub relays: Vec<RelayUrl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub heads: Vec<EntityHead>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>,
/// Where each channel's history sync has reached.
#[serde(
default,
rename = "channel_cursors",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
/// Root epochs the community has rotated past that this client still holds.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub held_roots: Vec<HeldRoot>,
/// The epoch a channel rotation removed us at.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub channel_cuts: BTreeMap<ChannelId, Epoch>,
/// The npubs whose rotation minted an epoch of this community we verified.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub refounders: BTreeSet<PublicKey>,
/// The base epoch we were excluded at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_at: Option<Epoch>,
/// A complete rotation ahead of our epoch predates our join and carries no blob.
#[serde(default)]
pub stranded: bool,
#[serde(default)]
pub dissolved: bool,
/// When this community joined the member's list, in milliseconds.
pub added_at_ms: u64,
}
impl CommunityState {
pub fn from_genesis(
genesis: &CommunityGenesis,
editions: &[ParsedEdition],
added_at_ms: u64,
) -> Result<Self> {
let mut channels = Vec::new();
let mut heads = Vec::with_capacity(editions.len());
let mut relays = Vec::new();
let mut name = None;
for edition in editions {
heads.push(EntityHead {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
});
match edition.subkind.as_str() {
vsk::COMMUNITY_METADATA => {
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
relays.extend(
metadata
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok()),
);
name = label(&metadata.name);
}
vsk::CHANNEL_METADATA => {
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
channels.push(ChannelKeyRef {
id: ChannelId::from_bytes(edition.entity),
name: metadata.name,
private: metadata.private,
epoch: ROOT_EPOCH,
key: None,
priors: Vec::new(),
});
}
_ => {}
}
}
let control_pks = BTreeMap::from([(
ROOT_EPOCH.0,
control_signer_group_key(
&genesis.control_root,
&genesis.identity.community_id,
ROOT_EPOCH,
)?
.pk(),
)]);
Ok(Self {
id: genesis.identity.community_id,
name,
owner: genesis.identity.owner,
owner_salt: genesis.identity.owner_salt,
community_root: genesis.community_root,
root_epoch: ROOT_EPOCH,
control_root: Some(genesis.control_root),
control_pks,
channels,
relays,
heads,
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,
})
}
pub fn from_join_material(material: &JoinMaterial, added_at_ms: u64) -> Result<Self> {
let control_pks = match material.control_pk {
Some(address) => BTreeMap::from([(material.root_epoch.0, address)]),
None => BTreeMap::new(),
};
let mut channels = Vec::with_capacity(material.channels.len());
for grant in &material.channels {
let key = match &grant.key {
Some(key) => Some(decode_hex_32(key)?),
None => None,
};
channels.push(ChannelKeyRef {
id: grant.id,
name: grant.name.clone(),
private: key.is_some(),
epoch: grant.epoch,
key,
priors: Vec::new(),
});
}
Ok(Self {
id: material.community_id,
name: label(&material.name),
owner: material.owner,
owner_salt: decode_hex_32(&material.owner_salt)?,
community_root: decode_hex_32(&material.community_root)?,
root_epoch: material.root_epoch,
control_root: match &material.control_root {
Some(root) => Some(decode_hex_32(root)?),
None => None,
},
control_pks,
channels,
relays: material
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect(),
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,
})
}
pub fn identifier(&self) -> String {
state_identifier(&self.id)
}
/// Every root epoch we hold, the current one first.
pub fn roots(&self) -> Vec<HeldRoot> {
let mut roots = Vec::with_capacity(self.held_roots.len() + 1);
roots.push(HeldRoot {
epoch: self.root_epoch,
key: self.community_root,
control_pk: self.control_pks.get(&self.root_epoch.0).copied(),
retired_at: None,
});
roots.extend(self.held_roots.iter().copied());
roots
}
/// Every secret held for a channel, newest epoch first.
pub fn held_keys(&self, channel: &ChannelId) -> Vec<HeldKey> {
let Some(held) = self.channels.iter().find(|held| held.id == *channel) else {
return Vec::new();
};
if held.private {
let mut keys: Vec<HeldKey> = held
.key
.map(|key| HeldKey {
epoch: held.epoch,
key,
retired_at: None,
})
.into_iter()
.collect();
keys.extend(held.priors.iter().copied());
return keys;
}
self.roots()
.into_iter()
.map(|root| HeldKey {
epoch: root.epoch,
key: root.key,
retired_at: root.retired_at,
})
.collect()
}
/// Whether a channel rotation removed us at or after `epoch`.
pub fn channel_cut(&self, channel: &ChannelId, epoch: Epoch) -> bool {
self.channel_cuts
.get(channel)
.is_some_and(|cut| epoch <= *cut)
}
pub fn floors(&self) -> Floors {
self.heads
.iter()
.map(|head| (head.entity, head.clone()))
.collect()
}
pub fn apply_fold(&mut self, fold: &ControlFold) {
self.heads = fold.floors.values().cloned().collect();
self.banned = fold.banned.clone();
if let Some(community) = &fold.community {
self.relays = community
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect();
if let Some(name) = label(&community.name) {
self.name = Some(name);
}
}
for (id, metadata) in &fold.channels {
if metadata.deleted.unwrap_or(false) {
self.channels.retain(|channel| channel.id != *id);
continue;
}
match self.channels.iter_mut().find(|channel| channel.id == *id) {
Some(channel) => {
channel.name = metadata.name.clone();
if !metadata.private {
channel.private = false;
}
}
None if !metadata.private => self.channels.push(ChannelKeyRef {
id: *id,
name: metadata.name.clone(),
private: false,
epoch: self.root_epoch,
key: None,
priors: Vec::new(),
}),
None => {}
}
}
}
}
pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry {
let material = JoinMaterial {
community_id: state.id,
owner: state.owner,
owner_salt: HEXLOWER.encode(&state.owner_salt),
community_root: HEXLOWER.encode(&state.community_root),
root_epoch: state.root_epoch,
control_pk: state.control_pks.get(&state.root_epoch.0).copied(),
control_root: state.control_root.map(|root| HEXLOWER.encode(&root)),
channels: state
.channels
.iter()
.map(|channel| ChannelGrant {
id: channel.id,
key: channel.key.map(|key| HEXLOWER.encode(&key)),
epoch: channel.epoch,
name: channel.name.clone(),
extra: Extra::default(),
})
.collect(),
relays: state.relays.iter().map(RelayUrl::to_string).collect(),
name: name.to_owned(),
extra: Extra::default(),
};
CommunityListEntry {
community_id: state.id,
seed: material.clone(),
current: material,
added_at: state.added_at_ms,
extra: Extra::default(),
}
}
fn label(name: &str) -> Option<String> {
let trimmed = name.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
/// The local document key a community's state is stored under.
pub fn state_identifier(id: &CommunityId) -> String {
format!("{STATE_PREFIX}{}", id.to_hex())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cursor_merge_only_moves_forward_and_never_seals() {
let held = ChannelCursor {
newest: Some(Timestamp::from_secs(1_000)),
oldest: Some(Timestamp::from_secs(5_000)),
exhausted: false,
};
// An incomplete round reports nothing and moves neither bound.
assert_eq!(held.merge(ChannelCursor::default()), held);
let merged = held.merge(ChannelCursor {
newest: Some(Timestamp::from_secs(2_000)),
oldest: Some(Timestamp::from_secs(3_000)),
exhausted: true,
});
assert_eq!(
merged,
ChannelCursor {
newest: Some(Timestamp::from_secs(2_000)),
oldest: Some(Timestamp::from_secs(3_000)),
exhausted: true,
}
);
// A later round that learned less cannot walk either bound back.
assert_eq!(
merged.merge(ChannelCursor {
newest: Some(Timestamp::from_secs(1_500)),
oldest: Some(Timestamp::from_secs(4_000)),
exhausted: false,
}),
merged
);
}
/// A cursor stored when the boundaries were milliseconds must not be read as
/// seconds. The key it was stored under is gone, so the document's counters
/// are ignored and the channel re-syncs rather than being sealed off by an
/// `exhausted` that outlived the bounds it was earned against.
#[test]
fn a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted() {
let channel = ChannelId::from_bytes([0x9c; 32]);
let cursor = ChannelCursor {
newest: Some(Timestamp::from_secs(1_700_000_000)),
exhausted: true,
..ChannelCursor::default()
};
let mut state = 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::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: 7,
};
// The document a version that stored milliseconds wrote: its own key,
// and boundaries padded by a thousand.
let mut stored = serde_json::Map::new();
stored.insert(
channel.to_hex(),
serde_json::json!({
"newest_ms": 1_700_000_000_000u64,
"oldest_ms": 1_699_999_000_000u64,
"exhausted": true
}),
);
let mut legacy = serde_json::to_value(&state).expect("serializes");
legacy
.as_object_mut()
.expect("a document")
.insert("cursors".to_owned(), serde_json::Value::Object(stored));
let read: CommunityState = serde_json::from_value(legacy).expect("deserializes");
assert!(
read.cursors.is_empty(),
"a millisecond cursor is not a seconds cursor"
);
// A typed cursor still round-trips under the key it is written with.
state.cursors.insert(channel, cursor);
let document = serde_json::to_value(&state).expect("serializes");
let read: CommunityState = serde_json::from_value(document).expect("deserializes");
assert_eq!(read.cursors.get(&channel), Some(&cursor));
}
#[test]
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
let owner = Keys::generate().public_key();
let control_pk = Keys::generate().public_key();
let staff = ChannelId::from_bytes([0x9c; 32]);
let general = ChannelId::from_bytes([0x9d; 32]);
let material = JoinMaterial {
community_id: CommunityId::from_bytes([0x42; 32]),
owner,
owner_salt: "01".repeat(32),
community_root: "02".repeat(32),
root_epoch: Epoch(3),
control_pk: Some(control_pk),
control_root: Some("03".repeat(32)),
channels: vec![
ChannelGrant {
id: staff,
key: Some("04".repeat(32)),
epoch: Epoch(2),
name: "staff".to_owned(),
extra: Extra::default(),
},
ChannelGrant {
id: general,
key: None,
epoch: Epoch(0),
name: "general".to_owned(),
extra: Extra::default(),
},
],
relays: vec!["wss://relay.example".to_owned()],
name: "Room".to_owned(),
extra: Extra::default(),
};
let state = CommunityState::from_join_material(&material, 7).expect("materializes");
assert_eq!(state.id, material.community_id);
assert_eq!(state.owner, owner);
assert_eq!(state.owner_salt, [0x01; 32]);
assert_eq!(state.community_root, [0x02; 32]);
assert_eq!(state.root_epoch, Epoch(3));
assert_eq!(state.control_root, Some([0x03; 32]));
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
assert!(
state.heads.is_empty(),
"the first control fold fills the heads"
);
assert!(state.banned.is_empty());
assert!(!state.dissolved);
assert_eq!(state.relays.len(), 1);
assert_eq!(state.added_at_ms, 7);
// A granted key lands on the channel and makes it private; a grant with
// no key is a public channel.
let granted = state
.channels
.iter()
.find(|c| c.id == staff)
.expect("staff");
assert!(granted.private);
assert_eq!(granted.key, Some([0x04; 32]));
assert_eq!(granted.epoch, Epoch(2));
assert_eq!(granted.name, "staff");
let public = state
.channels
.iter()
.find(|c| c.id == general)
.expect("general");
assert!(!public.private);
assert_eq!(public.key, None);
// A member who is not staff carries no control_root, but reading needs no
// secret: the address rides in the material either way.
let mut member = material.clone();
member.control_root = None;
let state = CommunityState::from_join_material(&member, 7).expect("materializes");
assert_eq!(state.control_root, None);
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
}
}
-648
View File
@@ -1,648 +0,0 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock;
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
use crate::cord02::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
};
use crate::cord03::{self, ChatRumor, plane_keys};
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
use crate::derive::control_signer_group_key;
use crate::{ChannelId, CommunityId, Epoch, GroupKey};
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
const MAX_PAGES: usize = 8;
const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
const MARK_VALUE: &str = "concord";
const WRAP_TAG: &str = "e";
const KIND_TAG: &str = "k";
const STATE_PREFIX: &str = "concord/";
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
pub async fn cache_rumor(
database: &dyn NostrDatabase,
channel: &ChannelId,
opened: &OpenedStream,
) -> Result<bool> {
if cord03::expiration_of(&opened.rumor)?
.is_some_and(|expiration| expiration <= Timestamp::now())
{
return Ok(false);
}
let tags = vec![
Tag::identifier(opened.rumor_id),
Tag::custom(KIND_TAG, [opened.rumor.kind.to_string()]),
Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]),
Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]),
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
Tag::public_key(opened.author),
];
let at = Timestamp::from_secs(opened.at_ms / 1000);
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
.tags(tags)
.custom_created_at(at)
.finalize_async(&*LOCAL_KEYS)
.await?;
database.save_event(&event).await?;
Ok(true)
}
pub async fn purge_expired(
database: &dyn NostrDatabase,
channel: &ChannelId,
now: Timestamp,
) -> Result<usize> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
let mut expired = Vec::new();
for event in database.query(filter).await? {
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
continue;
};
let Ok(Some(expiration)) = cord03::expiration_of(&rumor) else {
continue;
};
if expiration <= now {
expired.push(event.id);
}
}
let purged = expired.len();
if purged > 0 {
database.delete(Filter::new().ids(expired)).await?;
}
Ok(purged)
}
pub async fn query_rumors(
database: &dyn NostrDatabase,
channel: &ChannelId,
until: Option<Timestamp>,
limit: usize,
) -> Result<Vec<UnsignedEvent>> {
let mut filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
if let Some(until) = until {
filter = filter.until(until);
}
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
for event in database.query(filter).await? {
let Some(rumor_id) = event.tags.identifier() else {
continue;
};
match newest.get(&rumor_id) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(rumor_id, event);
}
}
}
let mut events: Vec<Event> = newest.into_values().collect();
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
events.truncate(limit);
let mut rumors = Vec::with_capacity(events.len());
for event in events {
let rumor = UnsignedEvent::from_json(event.content)
.map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?;
rumors.push(rumor);
}
Ok(rumors)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelKeyRef {
pub id: ChannelId,
pub name: String,
pub private: bool,
pub epoch: Epoch,
}
/// One local document per community, keyed by `concord/<community_id>`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityState {
pub id: CommunityId,
pub owner: PublicKey,
pub owner_salt: [u8; 32],
pub community_root: [u8; 32],
pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_root: Option<[u8; 32]>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub control_pks: BTreeMap<u64, PublicKey>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelKeyRef>,
pub relays: Vec<RelayUrl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub heads: Vec<EntityHead>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>,
#[serde(default)]
pub dissolved: bool,
pub added_at_ms: u64,
}
impl CommunityState {
pub fn from_genesis(
genesis: &CommunityGenesis,
editions: &[ParsedEdition],
added_at_ms: u64,
) -> Result<Self> {
let mut channels = Vec::new();
let mut heads = Vec::with_capacity(editions.len());
let mut relays = Vec::new();
for edition in editions {
heads.push(EntityHead {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
});
match edition.subkind.as_str() {
vsk::COMMUNITY_METADATA => {
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
relays.extend(
metadata
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok()),
);
}
vsk::CHANNEL_METADATA => {
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
channels.push(ChannelKeyRef {
id: ChannelId::from_bytes(edition.entity),
name: metadata.name,
private: metadata.private,
epoch: ROOT_EPOCH,
});
}
_ => {}
}
}
let control_pks = BTreeMap::from([(
ROOT_EPOCH.0,
control_signer_group_key(
&genesis.control_root,
&genesis.identity.community_id,
ROOT_EPOCH,
)?
.pk(),
)]);
Ok(Self {
id: genesis.identity.community_id,
owner: genesis.identity.owner,
owner_salt: genesis.identity.owner_salt,
community_root: genesis.community_root,
root_epoch: ROOT_EPOCH,
control_root: Some(genesis.control_root),
control_pks,
channels,
relays,
heads,
banned: BTreeSet::new(),
dissolved: false,
added_at_ms,
})
}
pub fn identifier(&self) -> String {
state_identifier(&self.id)
}
pub fn floors(&self) -> Floors {
self.heads
.iter()
.map(|head| (head.entity, head.clone()))
.collect()
}
pub fn apply_fold(&mut self, fold: &ControlFold) {
self.heads = fold.floors.values().cloned().collect();
self.banned = fold.banned.clone();
if let Some(community) = &fold.community {
self.relays = community
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect();
}
for (id, metadata) in &fold.channels {
if metadata.deleted.unwrap_or(false) {
self.channels.retain(|channel| channel.id != *id);
continue;
}
match self.channels.iter_mut().find(|channel| channel.id == *id) {
Some(channel) => {
channel.name = metadata.name.clone();
if !metadata.private {
channel.private = false;
}
}
None if !metadata.private => self.channels.push(ChannelKeyRef {
id: *id,
name: metadata.name.clone(),
private: false,
epoch: self.root_epoch,
}),
None => {}
}
}
}
}
fn state_identifier(id: &CommunityId) -> String {
format!("{STATE_PREFIX}{}", id.to_hex())
}
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
where
D: NostrDatabase,
{
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(state.identifier())])
.finalize_async(&*LOCAL_KEYS)
.await?;
database.save_event(&event).await?;
Ok(())
}
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
where
D: NostrDatabase,
{
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(state_identifier(id))
.limit(1);
match database.query(filter).await?.into_iter().next() {
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
None => Ok(None),
}
}
pub async fn backfill(
client: &Client,
database: &dyn NostrDatabase,
channel: &ChannelId,
held: &[(Epoch, [u8; 32])],
until: Option<Timestamp>,
limit: usize,
) -> Result<Vec<ChatRumor>> {
let planes = plane_keys(held, channel)?;
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
let mut cursor = until;
let mut seen: BTreeSet<EventId> = BTreeSet::new();
let mut found: Vec<ChatRumor> = Vec::new();
for _ in 0..MAX_PAGES {
let page = fetch_page(client, &authors, cursor, limit).await?;
if page.is_empty() {
break;
}
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
for (opened, rumor) in fresh {
if cache_rumor(database, channel, &opened).await? {
found.push(rumor);
}
}
match next {
Some(next) => cursor = Some(next),
None => break,
}
}
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
found.truncate(limit);
Ok(found)
}
fn advance(
page: &BTreeSet<Event>,
planes: &[(Epoch, GroupKey)],
channel: &ChannelId,
cursor: Option<Timestamp>,
limit: usize,
seen: &mut BTreeSet<EventId>,
) -> (Vec<(OpenedStream, ChatRumor)>, Option<Timestamp>) {
let mut fresh = Vec::new();
for wrap in page {
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
else {
continue;
};
let Ok((opened, rumor)) = cord03::open(wrap, group, channel, *epoch) else {
continue;
};
if seen.insert(rumor.id) {
fresh.push((opened, rumor));
}
}
if fresh.is_empty() || page.len() < limit {
return (fresh, None);
}
let oldest = page.iter().map(|event| event.created_at).min();
match oldest {
Some(oldest) if cursor != Some(oldest) => (fresh, Some(oldest)),
_ => (fresh, None),
}
}
async fn fetch_page(
client: &Client,
authors: &[PublicKey],
until: Option<Timestamp>,
limit: usize,
) -> Result<BTreeSet<Event>> {
let mut filter = Filter::new()
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
.authors(authors.iter().copied())
.limit(limit);
if let Some(until) = until {
filter = filter.until(until);
}
Ok(client.fetch_events(filter).await?)
}
#[cfg(test)]
mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
use crate::Epoch;
use crate::cord01::{
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
};
use crate::cord03::{build_message, seal_rumor};
use crate::derive::channel_group_key;
const SECRET: [u8; 32] = [0x07u8; 32];
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
/// What a relay does with an inclusive `until` and a `limit`.
fn serve_page(
relay: &BTreeSet<Event>,
cursor: Option<Timestamp>,
limit: usize,
) -> BTreeSet<Event> {
let mut events: Vec<Event> = relay
.iter()
.filter(|event| cursor.is_none_or(|cursor| event.created_at <= cursor))
.cloned()
.collect();
events.sort_by_key(|event| Reverse(event.created_at));
events.truncate(limit);
events.into_iter().collect()
}
#[test]
fn history_pages_back_across_a_rekey() {
let channel = ChannelId::from_bytes([0x9cu8; 32]);
let author = Keys::generate();
let held = [(Epoch(0), SECRET), (Epoch(1), NEXT_SECRET)];
let planes = plane_keys(&held, &channel).expect("derives");
// Three messages a second apart: a page boundary falls between each.
let base = 1_700_000_000_000;
let mut relay: BTreeSet<Event> = BTreeSet::new();
for (content, secret, epoch, at_ms) in [
("before the rekey", &SECRET, Epoch(0), base),
("still before", &SECRET, Epoch(0), base + 1_000),
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
] {
let group = channel_group_key(secret, &channel, epoch).expect("derives");
let rumor = build_message(
author.public_key(),
&channel,
epoch,
content,
None,
at_ms,
None,
);
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0);
}
let mut seen = BTreeSet::new();
let mut found = Vec::new();
let mut cursor = None;
for _ in 0..3 {
let page = serve_page(&relay, cursor, 2);
let (fresh, next) = advance(&page, &planes, &channel, cursor, 2, &mut seen);
found.extend(fresh.into_iter().map(|(_, rumor)| rumor));
match next {
Some(next) => cursor = Some(next),
None => break,
}
}
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
assert_eq!(
contents,
["after the rekey", "still before", "before the rekey"]
);
}
#[test]
fn rumors_read_back_after_a_restart() {
let database = MemoryDatabase::unbounded();
let channel = ChannelId::from_bytes([0xabu8; 32]);
let author = Keys::generate();
smol::block_on(async {
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] {
let rumor = build_rumor_ms(
9,
author.public_key(),
content,
channel_binding_tags(&channel, Epoch(0)),
at_ms,
);
let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals");
let (wrap, _) = wrap_seal(
&seal,
&group,
KIND_WRAP,
Timestamp::from_secs(at_ms / 1000),
&[],
)
.expect("wraps");
let opened = open_wrap(&wrap, &group).expect("opens");
cache_rumor(&database, &channel, &opened)
.await
.expect("caches");
}
// The group key is gone; only the local cache stands in for it.
let rumors = query_rumors(&database, &channel, None, 10)
.await
.expect("queries");
assert_eq!(rumors.len(), 2, "both messages come back");
assert_eq!(rumors[0].content, "second", "newest first");
assert_eq!(rumors[1].content, "first");
// A page boundary in message time, not in cache time.
let until = Timestamp::from_secs(1_500);
let page = query_rumors(&database, &channel, Some(until), 10)
.await
.expect("queries");
assert_eq!(page.len(), 1);
assert_eq!(page[0].content, "first");
let capped = query_rumors(&database, &channel, None, 1)
.await
.expect("queries");
assert_eq!(capped.len(), 1);
assert_eq!(capped[0].content, "second");
});
}
#[test]
fn an_expired_rumor_is_refused_at_ingest_and_purged_by_the_sweep() {
let database = MemoryDatabase::unbounded();
let channel = ChannelId::from_bytes([0x77u8; 32]);
let author = Keys::generate();
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
let now = Timestamp::now().as_secs();
smol::block_on(async {
// A live timer is stored; one that already elapsed is refused at ingest.
assert!(
cache(
&database,
&group,
&channel,
&author,
"live",
Some(3_600),
now
)
.await
);
assert!(
!cache(
&database,
&group,
&channel,
&author,
"gone",
Some(1),
now - 120
)
.await
);
let stored = query_rumors(&database, &channel, None, 10)
.await
.expect("queries");
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].content, "live");
// Hiding is not disappearing: the sweep removes the row itself,
// judged on the rumor's own signed tag.
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 7_200))
.await
.expect("sweeps");
assert_eq!(purged, 1);
assert!(
query_rumors(&database, &channel, None, 10)
.await
.expect("queries")
.is_empty()
);
// An untimed rumor is never swept, whatever the clock says.
assert!(cache(&database, &group, &channel, &author, "timeless", None, now).await);
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 86_400))
.await
.expect("sweeps");
assert_eq!(purged, 0);
});
}
async fn cache(
database: &MemoryDatabase,
group: &GroupKey,
channel: &ChannelId,
author: &Keys,
content: &str,
timer: Option<u64>,
at_secs: u64,
) -> bool {
let rumor = build_message(
author.public_key(),
channel,
Epoch(0),
content,
None,
at_secs * 1_000,
timer,
);
let (wrap, _) = seal_rumor(&rumor, group, author, false).expect("seals");
let opened = open_wrap(&wrap, group).expect("opens");
cache_rumor(database, channel, &opened)
.await
.expect("caches")
}
}
+34
View File
@@ -0,0 +1,34 @@
use anyhow::{Result, anyhow};
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
use base64::{Engine as _, alphabet};
/// Unpadded base64url (RFC 4648 §5), 43 characters for 32 bytes: §8's value
/// encoding at any depth.
///
/// The reader tolerates non-zero trailing bits; the writer never emits them.
/// The spec's own worked example (`examples.md` §6.2) contains five such
/// values, and a reader cannot tell a mis-encoded named field from a correctly
/// encoded one, so the boundary is the writer's alone.
const BASE64URL: GeneralPurpose = GeneralPurpose::new(
&alphabet::URL_SAFE,
GeneralPurposeConfig::new()
.with_encode_padding(false)
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
.with_decode_allow_trailing_bits(true),
);
pub(crate) fn encode(bytes: &[u8]) -> String {
BASE64URL.encode(bytes)
}
/// Decodes one 32-byte value, the width every §8 field has.
pub(crate) fn decode_32(value: &str) -> Result<[u8; 32]> {
let bytes = BASE64URL
.decode(value.trim())
.map_err(|error| anyhow!("invalid base64url: {error}"))?;
bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))
}
+38 -1
View File
@@ -1,12 +1,18 @@
pub mod base64url;
pub mod derive;
use anyhow::{Result, anyhow, bail};
use data_encoding::HEXLOWER;
use rand::TryRng as _;
use rand::rngs::SysRng;
use serde::Serialize;
use crate::Extra;
/// Decode a 64-character lowercase-hex string into 32 bytes.
///
/// Uppercase and other non-canonical spellings are rejected.
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
pub fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
decode_hex_lower::<32>(value)
}
@@ -38,3 +44,34 @@ pub(crate) fn random_32() -> Result<[u8; 32]> {
fill_random(&mut bytes)?;
Ok(bytes)
}
/// Hex to unpadded base64url for one 32-byte §8 value.
pub(crate) fn hex32_to_base64(value: &str) -> Result<String> {
Ok(base64url::encode(&decode_hex_32(value)?))
}
/// Unpadded base64url to lowercase hex for one 32-byte §8 value.
pub(crate) fn base64_to_hex32(value: &str) -> Result<String> {
Ok(HEXLOWER.encode(&base64url::decode_32(value)?))
}
/// Canonical JSON bytes: the total-order tie-break every content merge uses.
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_default()
}
/// Unions an unknown-field map. Where both sides carry a key, the
/// lexicographically lowest canonical bytes win, so two devices converge
/// instead of flapping.
pub(crate) fn union(into: &mut Extra, other: Extra) {
for (key, value) in other {
let replace = match into.get(&key) {
Some(existing) => canonical(&value) < canonical(existing),
None => true,
};
if replace {
into.insert(key, value);
}
}
}
+31 -13
View File
@@ -24,8 +24,8 @@ use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
const IDENTIFIER: &str = "coop:device";
pub fn init(window: &mut Window, cx: &mut App) {
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
pub fn init(cx: &mut App) {
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
}
struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
@@ -89,7 +89,8 @@ impl DeviceRegistry {
}
/// Create a new device registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
@@ -114,8 +115,10 @@ impl DeviceRegistry {
}),
);
cx.defer_in(window, |this, window, cx| {
this.handle_notifications(window, cx);
cx.defer(move |cx| {
entity
.update(cx, |this, cx| this.handle_notifications(cx))
.ok();
});
Self {
@@ -127,7 +130,7 @@ impl DeviceRegistry {
}
}
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
@@ -168,18 +171,18 @@ impl DeviceRegistry {
Ok(())
}));
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
self.tasks.push(cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await {
match event.kind {
Kind::Custom(10044) => {
this.update_in(cx, |this, _window, cx| {
this.update(cx, |this, cx| {
this.set_encryption(&event, cx);
})?;
}
// New request event from other device
Kind::Custom(4454) => {
this.update_in(cx, |this, window, cx| {
this.ask_for_approval(event, window, cx);
this.update(cx, |this, cx| {
this.ask_for_approval(event, cx);
})?;
}
// New response event from the master device
@@ -591,7 +594,7 @@ impl DeviceRegistry {
}
/// Handle encryption request
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
// Ignore if there is already a pending request
if self.pending_request {
return;
@@ -600,7 +603,18 @@ impl DeviceRegistry {
// Show notification
let notification = self.notification(event, cx);
window.push_notification(notification, cx);
// The registry is global and not bound to a window, so surface the
// request in an open window.
if let Some(window) = cx.windows().first().copied() {
if let Err(error) = window.update(cx, |_view, window, cx| {
window.push_notification(notification, cx);
}) {
log::warn!("Failed to show encryption key request: {error}");
}
} else {
log::warn!("Failed to show encryption key request: no open window");
}
}
/// Build a notification for the encryption request.
@@ -655,7 +669,11 @@ impl DeviceRegistry {
.child(
h_flex()
.gap_2()
.child(Avatar::new(profile.avatar()).xsmall())
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.xsmall(),
)
.child(profile.name()),
),
),
+7 -6
View File
@@ -3,7 +3,7 @@ use std::sync::RwLock;
use anyhow::{Error, anyhow};
use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use gpui::{App, AppContext, Context, Entity, Global, Task};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
@@ -13,8 +13,8 @@ mod person;
pub use person::*;
pub fn init(window: &mut Window, cx: &mut App) {
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
}
struct GlobalPersonRegistry(Entity<PersonRegistry>);
@@ -56,7 +56,8 @@ impl PersonRegistry {
}
/// Create a new person registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -96,8 +97,8 @@ impl PersonRegistry {
}));
// Load all user profiles from the database
cx.defer_in(window, |this, _window, cx| {
this.load(cx);
cx.defer(move |cx| {
entity.update(cx, |this, cx| this.load(cx)).ok();
});
Self {
+8 -4
View File
@@ -103,14 +103,18 @@ impl Person {
self.messaging_relays.first().cloned()
}
/// Get profile avatar
pub fn avatar(&self) -> SharedString {
/// Get profile picture, if the profile has one
pub fn avatar(&self) -> Option<SharedString> {
self.metadata()
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| picture.into())
.unwrap_or_else(|| "brand/avatar.png".into())
.map(SharedString::from)
}
/// A stable seed for this profile's generated avatar
pub fn avatar_seed(&self) -> SharedString {
SharedString::from(self.public_key().to_hex())
}
/// Get profile name
+40 -16
View File
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec};
use theme::{Theme, ThemeFamily, ThemeMode};
pub fn init(window: &mut Window, cx: &mut App) {
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
pub fn init(cx: &mut App) {
AppSettings::set_global(cx.new(AppSettings::new), cx)
}
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
@@ -46,8 +46,11 @@ setting_accessors! {
pub nip4e: bool,
pub trusted_relays: Vec<String>,
pub file_server: Url,
pub recent_communities: Vec<String>,
}
const RECENT_COMMUNITIES_CAP: usize = 10;
/// Signer kind
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerKind {
@@ -130,6 +133,10 @@ pub struct Settings {
/// Server for blossom media attachments
pub file_server: Url,
/// Recently opened community ids, newest first
#[serde(default)]
pub recent_communities: Vec<String>,
}
impl Default for Settings {
@@ -142,6 +149,7 @@ impl Default for Settings {
nip4e: false,
trusted_relays: vec![],
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
recent_communities: vec![],
}
}
}
@@ -160,7 +168,6 @@ impl Global for GlobalAppSettings {}
pub struct AppSettings {
/// Settings
inner: Entity<Settings>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
@@ -171,12 +178,18 @@ impl AppSettings {
cx.global::<GlobalAppSettings>().0.clone()
}
/// The underlying settings entity, which notifies whenever any field changes.
pub fn entity(&self) -> &Entity<Settings> {
&self.inner
}
/// Set the global settings instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAppSettings(state));
}
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let inner = cx.new(|_| Settings::default());
let mut subscriptions = smallvec![];
@@ -188,8 +201,8 @@ impl AppSettings {
);
// Run at the end of current cycle
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
cx.defer(move |cx| {
entity.update(cx, |this, cx| this.load(cx)).ok();
});
Self {
@@ -207,7 +220,7 @@ impl AppSettings {
}
/// Load settings
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -219,7 +232,7 @@ impl AppSettings {
Err(anyhow!("Not found"))
});
cx.spawn_in(window, async move |this, cx| {
cx.spawn(async move |this, cx| {
let mut settings = task.await.unwrap_or(Settings::default());
// Move settings still pointed at the old default file server over to the new one
@@ -228,9 +241,10 @@ impl AppSettings {
}
// Update settings
this.update_in(cx, |this, window, cx| {
this.update(cx, |this, cx| {
this.set_settings(settings, cx);
this.apply_theme(window, cx);
this.apply_theme(None, cx);
cx.refresh_windows();
})
.ok();
})
@@ -262,7 +276,7 @@ impl AppSettings {
});
// Apply the new theme
self.apply_theme(window, cx);
self.apply_theme(Some(window), cx);
}
/// Reset theme
@@ -271,22 +285,22 @@ impl AppSettings {
this.theme = None;
cx.notify();
});
self.apply_theme(window, cx);
self.apply_theme(Some(window), cx);
}
/// Apply theme
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub fn apply_theme(&mut self, mut window: Option<&mut Window>, cx: &mut Context<Self>) {
if let Some(name) = self.inner.read(cx).theme.as_ref() {
let mode = self.inner.read(cx).theme_mode;
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
Theme::change(mode, Some(window), cx);
Theme::apply_theme(Rc::new(new_theme), window.as_deref_mut(), cx);
Theme::change(mode, window, cx);
} else {
log::info!("Failed to load theme: {name}");
}
} else {
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
Theme::apply_theme(Rc::new(ThemeFamily::default()), window, cx);
}
}
@@ -318,4 +332,14 @@ impl AppSettings {
}
});
}
/// Move a community to the front of the recently opened list
pub fn record_recent_community(&mut self, id: String, cx: &mut Context<Self>) {
self.inner.update(cx, |this, cx| {
this.recent_communities.retain(|existing| existing != &id);
this.recent_communities.insert(0, id);
this.recent_communities.truncate(RECENT_COMMUNITIES_CAP);
cx.notify();
});
}
}
+57
View File
@@ -318,6 +318,63 @@ pub async fn download_and_decrypt_to_file(
Err(anyhow!("File download not supported on web"))
}
/// The cache file a decrypted blob for `plaintext_sha256` is written to.
#[cfg(not(target_arch = "wasm32"))]
fn blob_cache_path(plaintext_sha256: &str) -> PathBuf {
std::env::temp_dir()
.join("coop-blobs")
.join(plaintext_sha256)
}
/// Download an encrypted blob whose pointer carries the *plaintext* hash
/// and write the decrypted bytes to a content-addressed cache file,
/// so later renders skip the network.
///
/// The cache file carries no extension: `img` sniffs the format from the bytes.
#[cfg(not(target_arch = "wasm32"))]
pub async fn download_and_decrypt_to_cache(
url: &Url,
key: &str,
nonce: &str,
plaintext_sha256: &str,
cx: &AsyncApp,
) -> Result<PathBuf, Error> {
let path = blob_cache_path(plaintext_sha256);
if smol::fs::metadata(&path).await.is_ok() {
return Ok(path);
}
let data = download_and_decrypt(url, key, nonce, None, cx).await?;
if !sha256_hex(&data).eq_ignore_ascii_case(plaintext_sha256) {
bail!("Blob hash mismatch");
}
let Some(parent) = path.parent() else {
bail!("Invalid blob cache path");
};
smol::fs::create_dir_all(parent).await?;
// Write under a temporary name first, so an interrupted download is never reused
let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?;
Ok(path)
}
#[cfg(target_arch = "wasm32")]
pub async fn download_and_decrypt_to_cache(
_url: &Url,
_key: &str,
_nonce: &str,
_plaintext_sha256: &str,
_cx: &AsyncApp,
) -> Result<PathBuf, Error> {
Err(anyhow!("Blob download not supported on web"))
}
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
tags.iter()
.find(|tag| tag.kind() == name)
+46 -17
View File
@@ -4,7 +4,7 @@ use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))]
use browser_signer_proxy::prelude::*;
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
use gpui_tokio::Tokio;
use instant::Duration;
use nostr_connect::prelude::*;
@@ -29,7 +29,7 @@ pub use nip4e::*;
pub use nip05::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
pub fn init(cx: &mut App, cli_key: Option<SecretKey>) {
// rustls uses the `aws_lc_rs` provider by default
// This only errors if the default provider has already
// been installed. We can ignore this `Result`.
@@ -42,7 +42,7 @@ pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
#[cfg(not(target_arch = "wasm32"))]
gpui_tokio::init(cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(cx, cli_key)), cx);
}
struct GlobalNostrRegistry(Entity<NostrRegistry>);
@@ -87,6 +87,9 @@ pub struct NostrRegistry {
/// Current user's public key
current_user: Option<PublicKey>,
/// Whether the initial credential check has concluded
ready: bool,
/// Tasks for asynchronous operations
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -105,7 +108,8 @@ impl NostrRegistry {
}
/// Create a new nostr instance
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
fn new(cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
let entity = cx.entity().downgrade();
let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone());
@@ -132,25 +136,31 @@ impl NostrRegistry {
})
.build();
// Connect to bootstrap relays after the window is ready
cx.defer_in(window, |this, _window, cx| {
this.connect_bootstrap_relays(cx);
// Connect to bootstrap relays once the registry has been returned to the app
cx.defer(move |cx| {
entity
.update(cx, |this, cx| {
this.connect_bootstrap_relays(cx);
if cfg!(target_arch = "wasm32") {
cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as get_user_credential
let keys = Keys::new(secret);
this.set_signer(keys, cx);
} else {
this.get_user_credential(cx);
}
if cfg!(target_arch = "wasm32") {
this.mark_ready(cx);
cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as get_user_credential
let keys = Keys::new(secret);
this.set_signer(keys, cx);
} else {
this.get_user_credential(cx);
}
})
.ok();
});
Self {
client,
signer,
current_user: None,
ready: false,
tasks: vec![],
}
}
@@ -170,6 +180,20 @@ impl NostrRegistry {
self.current_user
}
/// Whether the initial credential check has concluded
pub fn ready(&self) -> bool {
self.ready
}
fn mark_ready(&mut self, cx: &mut Context<Self>) {
if self.ready {
return;
}
self.ready = true;
cx.notify();
}
/// Update the signer
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
where
@@ -184,6 +208,7 @@ impl NostrRegistry {
this.update(cx, |this, cx| {
this.signer.swap_inner(new_signer);
this.current_user = Some(public_key);
this.mark_ready(cx);
cx.emit(StateEvent::SignerChanged);
cx.notify();
})?;
@@ -270,12 +295,16 @@ impl NostrRegistry {
} else if content == "proxy" {
#[cfg(not(target_arch = "wasm32"))]
this.update(cx, |this, cx| {
this.mark_ready(cx);
this.connect_proxy(cx);
})?;
} else {
this.update(cx, |this, cx| this.mark_ready(cx))?;
}
}
_ => {
this.update(cx, |_, cx| {
this.update(cx, |this, cx| {
this.mark_ready(cx);
cx.emit(StateEvent::NoSigner);
})?;
}
+1 -1
View File
@@ -34,7 +34,7 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
/// Defines workspace tabbar height
pub const TABBAR_HEIGHT: Pixels = px(44.0);
pub const TABBAR_HEIGHT: Pixels = px(36.0);
/// Defines default sidebar width
pub const SIDEBAR_WIDTH: Pixels = px(240.);
+473 -29
View File
@@ -1,12 +1,25 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
Window, div, img, px,
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
};
use theme::ActiveTheme;
use crate::{Selectable, Sizable, Size};
use crate::{Selectable, Sizable, Size, StyledExt};
/// Number of rows and columns in the generated pixel grid.
const PIXEL_GRID: usize = 8;
/// Probability that a cell in the left half of the grid is filled.
const FILL_PROBABILITY: f32 = 0.42;
/// Probability that a filled cell uses the accent shade instead of the main color.
const ACCENT_PROBABILITY: f32 = 0.25;
/// Minimum number of filled left-half cells, so a pattern never reads as empty.
const MIN_FILLED: usize = 5;
/// Fallback seed for an avatar that has neither a picture nor a seed of its own.
const FALLBACK_SEED: &str = "coop";
/// Number of segments used to approximate the avatar circle.
const CIRCLE_SEGMENTS: usize = 32;
/// Returns the size of the avatar based on the given [`Size`].
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
@@ -19,19 +32,350 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
}
}
/// An element that renders a user avatar with customizable appearance options.
/// A deterministic, offline pixel-art avatar derived from a seed.
///
/// Use it for entities that have no profile picture: the same seed always
/// renders the same pattern, so identities stay recognizable without a
/// network round trip. The pattern is painted as geometry and cropped to a
/// circle, at the same sizes as [`Avatar`].
///
/// # Examples
///
/// ```
/// use ui::{Avatar};
/// use ui::avatar::PixelAvatar;
///
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
/// PixelAvatar::new("alice");
/// ```
#[derive(IntoElement)]
pub struct PixelAvatar {
seed: u64,
size: Size,
style: StyleRefinement,
}
impl PixelAvatar {
/// Creates a pixel avatar from `seed`.
pub fn new(seed: impl AsRef<str>) -> Self {
Self {
seed: fnv1a(seed.as_ref().as_bytes()),
size: Size::Medium,
style: StyleRefinement::default(),
}
}
}
impl Sizable for PixelAvatar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Styled for PixelAvatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for PixelAvatar {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let side = avatar_size(self.size).to_pixels(window.rem_size());
let seed = self.seed;
canvas(
move |_bounds, _window, _cx| seed,
move |bounds, seed, window, cx| {
let theme = cx.theme();
let main = Hsla {
h: (theme.icon_accent.h + seed as f32 / u64::MAX as f32) % 1.,
s: 0.6,
l: if theme.is_dark() { 0.6 } else { 0.45 },
a: 1.,
};
let shade = if theme.is_dark() {
Hsla {
l: (main.l * 1.6).min(0.95),
..main
}
} else {
Hsla {
l: (main.l * 0.45).max(0.18),
..main
}
};
let circle = circle_polygon(bounds.center(), bounds.size.width.as_f32() / 2.);
paint_polygons(window, std::iter::once(&circle), main.opacity(0.16));
let pattern = pixel_pattern(seed);
let mut cells = Vec::new();
for (value, color) in [(1u8, main), (2u8, shade)] {
cells.clear();
for row in 0..PIXEL_GRID {
for col in 0..PIXEL_GRID {
if pattern[row * PIXEL_GRID + col] != value {
continue;
}
let cell = clip_polygon(&cell_polygon(&bounds, row, col), &circle);
if cell.len() >= 3 {
cells.push(cell);
}
}
}
paint_polygons(window, cells.iter(), color);
}
},
)
.refine_style(&self.style)
.size(side)
.flex_shrink_0()
}
}
/// Builds the mirrored fill pattern for `seed`.
fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
let mut rng = PixelRng::new(seed);
let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID];
let mut filled = 0usize;
for row in 0..PIXEL_GRID {
for col in 0..PIXEL_GRID / 2 {
if rng.chance(FILL_PROBABILITY) {
let accent = rng.chance(ACCENT_PROBABILITY);
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
filled += 1;
}
}
}
if filled < MIN_FILLED {
let half = PIXEL_GRID * PIXEL_GRID / 2;
let start = (rng.next() % half as u64) as usize;
for offset in 0..half {
if filled >= MIN_FILLED {
break;
}
let ix = (start + offset) % half;
let row = ix / (PIXEL_GRID / 2);
let col = ix % (PIXEL_GRID / 2);
if pattern[row * PIXEL_GRID + col] == 0 {
set_cell(&mut pattern, row, col, 1);
filled += 1;
}
}
}
pattern
}
/// Paints `polygons` as a single anti-aliased filled path in `color`.
fn paint_polygons<'a>(
window: &mut Window,
polygons: impl IntoIterator<Item = &'a Vec<Point<Pixels>>>,
color: Hsla,
) {
let mut builder = PathBuilder::fill();
let mut painted = false;
for polygon in polygons {
if polygon.len() >= 3 {
builder.add_polygon(polygon, true);
painted = true;
}
}
if painted && let Ok(path) = builder.build() {
window.paint_path(path, color);
}
}
/// Approximates the circle of `radius` around `center` as a convex polygon,
/// wound so that its interior is on the left of every directed edge.
fn circle_polygon(center: Point<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
let center_x = center.x.as_f32();
let center_y = center.y.as_f32();
(0..CIRCLE_SEGMENTS)
.map(|index| {
let angle = std::f32::consts::TAU * index as f32 / CIRCLE_SEGMENTS as f32;
point(
px(center_x + radius * angle.cos()),
px(center_y + radius * angle.sin()),
)
})
.collect()
}
/// The four corners of cell `(row, col)` of the grid laid out in `bounds`.
fn cell_polygon(bounds: &Bounds<Pixels>, row: usize, col: usize) -> [Point<Pixels>; 4] {
let cell = bounds.size.width.as_f32() / PIXEL_GRID as f32;
let left = bounds.origin.x.as_f32() + col as f32 * cell;
let top = bounds.origin.y.as_f32() + row as f32 * cell;
[
point(px(left), px(top)),
point(px(left + cell), px(top)),
point(px(left + cell), px(top + cell)),
point(px(left), px(top + cell)),
]
}
/// Clips `subject` to the convex `clip` polygon, keeping the part inside it.
fn clip_polygon(subject: &[Point<Pixels>], clip: &[Point<Pixels>]) -> Vec<Point<Pixels>> {
let mut current = subject.to_vec();
let mut next = Vec::with_capacity(subject.len() + 4);
for (&start, &end) in clip.iter().zip(clip.iter().cycle().skip(1)) {
if current.is_empty() {
break;
}
next.clear();
let mut previous = match current.last() {
Some(&vertex) => vertex,
None => break,
};
for &vertex in current.iter() {
let previous_inside = is_inside(start, end, previous);
let vertex_inside = is_inside(start, end, vertex);
if vertex_inside {
if !previous_inside
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
{
next.push(crossing);
}
next.push(vertex);
} else if previous_inside
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
{
next.push(crossing);
}
previous = vertex;
}
std::mem::swap(&mut current, &mut next);
}
current
}
/// Whether `vertex` lies on the interior side of the directed edge `start -> end`.
fn is_inside(start: Point<Pixels>, end: Point<Pixels>, vertex: Point<Pixels>) -> bool {
let start_x = start.x.as_f32();
let start_y = start.y.as_f32();
let edge_x = end.x.as_f32() - start_x;
let edge_y = end.y.as_f32() - start_y;
let to_vertex_x = vertex.x.as_f32() - start_x;
let to_vertex_y = vertex.y.as_f32() - start_y;
edge_x * to_vertex_y - edge_y * to_vertex_x >= 0.
}
/// The intersection of segment `from -> to` with the infinite line `start -> end`.
fn line_intersection(
start: Point<Pixels>,
end: Point<Pixels>,
from: Point<Pixels>,
to: Point<Pixels>,
) -> Option<Point<Pixels>> {
let start_x = start.x.as_f32();
let start_y = start.y.as_f32();
let edge_x = end.x.as_f32() - start_x;
let edge_y = end.y.as_f32() - start_y;
let from_x = from.x.as_f32();
let from_y = from.y.as_f32();
let segment_x = to.x.as_f32() - from_x;
let segment_y = to.y.as_f32() - from_y;
let denominator = edge_x * segment_y - edge_y * segment_x;
if denominator.abs() < f32::EPSILON {
return None;
}
let offset_x = from_x - start_x;
let offset_y = from_y - start_y;
let t = (edge_y * offset_x - edge_x * offset_y) / denominator;
Some(point(
px(from_x + segment_x * t),
px(from_y + segment_y * t),
))
}
/// Fills `cell (row, col)` and its horizontal mirror.
fn set_cell(pattern: &mut [u8; PIXEL_GRID * PIXEL_GRID], row: usize, col: usize, value: u8) {
pattern[row * PIXEL_GRID + col] = value;
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)] = value;
}
/// FNV-1a 64-bit hash, stable across platforms and runs.
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for &byte in bytes {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
struct PixelRng(u64);
impl PixelRng {
fn new(seed: u64) -> Self {
Self(seed.max(1))
}
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
fn chance(&mut self, probability: f32) -> bool {
self.next() as f32 / (u64::MAX as f32) < probability
}
}
/// Renders the generated pixel avatar shown in place of a missing picture.
fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
PixelAvatar::new(seed.unwrap_or(FALLBACK_SEED))
.with_size(size)
.into_any_element()
}
/// An element that renders a user avatar with customizable appearance options.
///
/// Entities without a picture still get a stable identity: the avatar falls
/// back to a [`PixelAvatar`] seeded through [`Avatar::seed`], both when there
/// is no picture and when the picture fails to load.
///
/// # Examples
///
/// ```
/// use ui::avatar::Avatar;
///
/// Avatar::new(None).seed("alice");
/// ```
#[derive(IntoElement)]
pub struct Avatar {
base: Div,
image: Img,
picture: Option<ImageSource>,
grayscale: bool,
seed: Option<SharedString>,
style: StyleRefinement,
size: Size,
border_color: Option<Hsla>,
@@ -39,11 +383,25 @@ pub struct Avatar {
}
impl Avatar {
/// Creates a new avatar element with the specified image source.
pub fn new(src: impl Into<ImageSource>) -> Self {
/// Creates an avatar for an entity whose profile picture may be missing.
///
/// Use [`Avatar::seed`] to choose the generated
/// pixel avatar rendered when `picture` is `None`.
pub fn new(picture: Option<SharedString>) -> Self {
Self::from_picture(picture.map(ImageSource::from))
}
/// Creates an avatar from an already-resolved source.
pub fn from_source(picture: impl Into<ImageSource>) -> Self {
Self::from_picture(Some(picture.into()))
}
fn from_picture(picture: Option<ImageSource>) -> Self {
Avatar {
base: div(),
image: img(src),
picture,
grayscale: false,
seed: None,
style: StyleRefinement::default(),
size: Size::Medium,
border_color: None,
@@ -51,17 +409,26 @@ impl Avatar {
}
}
/// Sets the seed for the generated pixel avatar.
///
/// The seed should be a stable identifier of the entity the avatar
/// represents, such as a public key.
pub fn seed(mut self, seed: impl Into<SharedString>) -> Self {
self.seed = Some(seed.into());
self
}
/// Applies a grayscale filter to the avatar image.
///
/// # Examples
///
/// ```
/// use ui::{Avatar, AvatarShape};
/// use ui::avatar::Avatar;
///
/// let avatar = Avatar::new("path/to/image.png").grayscale(true);
/// Avatar::new(None).grayscale(true);
/// ```
pub fn grayscale(mut self, grayscale: bool) -> Self {
self.image = self.image.grayscale(grayscale);
self.grayscale = grayscale;
self
}
@@ -113,8 +480,24 @@ impl RenderOnce for Avatar {
} else {
px(0.)
};
let image_size = avatar_size(self.size);
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
let container_size = image_size + border_width * 2.;
let content = match self.picture {
Some(picture) => {
let seed = self.seed;
let grayscale = self.grayscale;
img(picture)
.size(image_size)
.rounded_full()
.object_fit(ObjectFit::Cover)
.grayscale(grayscale)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
.into_any_element()
}
None => generated_avatar(self.seed.as_deref(), image_size),
};
div()
.flex_shrink_0()
@@ -124,18 +507,79 @@ impl RenderOnce for Avatar {
.when_some(self.border_color, |this, color| {
this.border(border_width).border_color(color)
})
.child(
self.image
.size(image_size)
.rounded_full()
.object_fit(ObjectFit::Cover)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || {
img("brand/avatar.png")
.size(image_size)
.rounded_full()
.into_any_element()
}),
)
.child(content)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pixel_patterns_are_symmetric_and_stable() {
for seed in 0..50 {
let pattern = pixel_pattern(seed);
let filled = pattern.iter().filter(|&&cell| cell != 0).count();
assert!(
filled >= MIN_FILLED * 2,
"pattern too sparse for seed {seed}"
);
for row in 0..PIXEL_GRID {
for col in 0..PIXEL_GRID {
assert_eq!(
pattern[row * PIXEL_GRID + col],
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)],
"asymmetric pattern for seed {seed} at ({row}, {col})"
);
}
}
}
for seed in [0, 1, 42, u64::MAX] {
assert_eq!(pixel_pattern(seed), pixel_pattern(seed));
}
assert_ne!(pixel_pattern(42), pixel_pattern(43));
}
fn area(polygon: &[Point<Pixels>]) -> f32 {
let mut sum: f32 = 0.;
for (&a, &b) in polygon.iter().zip(polygon.iter().cycle().skip(1)) {
sum += a.x.as_f32() * b.y.as_f32() - b.x.as_f32() * a.y.as_f32();
}
(sum / 2.).abs()
}
#[test]
fn clipping_keeps_only_the_part_inside_the_circle() {
let circle = circle_polygon(point(px(10.), px(10.)), 10.);
let square = |left: f32, top: f32| {
[
point(px(left), px(top)),
point(px(left + 4.), px(top)),
point(px(left + 4.), px(top + 4.)),
point(px(left), px(top + 4.)),
]
};
let inside = clip_polygon(&square(8., 8.), &circle);
assert!((area(&inside) - 16.).abs() < 0.05, "area {}", area(&inside));
assert!(clip_polygon(&square(20., 20.), &circle).is_empty());
let straddling = clip_polygon(&square(0., 0.), &circle);
for vertex in &straddling {
let delta_x = vertex.x.as_f32() - 10.;
let delta_y = vertex.y.as_f32() - 10.;
assert!(
delta_x.hypot(delta_y) <= 10. + 0.1,
"clipped vertex outside the circle"
);
}
let area = area(&straddling);
assert!(area > 0. && area < 16., "area {area}");
}
}
-6
View File
@@ -116,26 +116,20 @@ pub trait ButtonVariants: Sized {
#[allow(clippy::type_complexity)]
pub struct Button {
base: BaseButton,
icon: Option<Icon>,
label: Option<SharedString>,
tooltip: Option<SharedString>,
children: Vec<AnyElement>,
variant: ButtonVariant,
size: Size,
disabled: bool,
loading: bool,
rounded: bool,
compact: bool,
caret: bool,
indicator: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
tab_index: isize,
tab_stop: bool,
+176 -59
View File
@@ -24,6 +24,7 @@ use crate::menu::DropdownMenu as _;
use crate::resizable::{resize_handle, resize_handle_appearance};
use crate::tab::Tab;
use crate::tab::tab_bar::TabBar;
use crate::title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers, window_controls};
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
mod panel;
@@ -31,12 +32,34 @@ pub use panel::*;
actions!(dock, [ToggleZoom, ClosePanel]);
pub type TitleBarRenderer = fn(&mut Window, &mut App) -> AnyElement;
#[derive(Default)]
pub struct TitleBarChrome {
trailing: Cell<Option<TitleBarRenderer>>,
}
impl TitleBarChrome {
pub fn set_trailing(&self, renderer: TitleBarRenderer) {
self.trailing.set(Some(renderer));
}
fn trailing(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
self.trailing.get().map(|render| render(window, cx))
}
}
pub fn dock_area(
id: impl Into<SharedString>,
window: &mut Window,
cx: &mut App,
) -> Entity<DockArea> {
let shared = Rc::new(SkinShared::default());
) -> (Entity<DockArea>, Rc<TitleBarChrome>) {
let chrome = Rc::new(TitleBarChrome::default());
let shared = Rc::new(SkinShared {
area: RefCell::new(None),
resizing: Cell::new(None),
chrome: chrome.clone(),
});
let area = cx.new(|cx| {
DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin {
shared: shared.clone(),
@@ -44,7 +67,7 @@ pub fn dock_area(
});
*shared.area.borrow_mut() = Some(area.downgrade());
area
(area, chrome)
}
pub fn add_panel(
@@ -167,6 +190,7 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
struct SkinShared {
area: RefCell<Option<WeakEntity<DockArea>>>,
resizing: Cell<Option<DockPlacement>>,
chrome: Rc<TitleBarChrome>,
}
impl SkinShared {
@@ -394,6 +418,53 @@ impl TabGroupSkin {
}
}
fn is_title_bar_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
};
area.read(cx)
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root()))
== Some(group.node())
}
/// Whether this group is the left dock's root with a single panel.
///
/// Such a group draws no tab bar, so its panel owns the window's top-left
/// corner — including the space the macOS traffic lights overlay.
fn is_plain_left_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
};
let area = area.read(cx);
area.layout(DockPlacement::Left)
.map(|tree| tree.root().id())
== Some(group.node())
&& group.panels().len() == 1
}
/// Whether this group is the topmost-left group on screen, which sits under
/// the native macOS traffic lights. The left dock's group is leftmost while
/// it is open and holds a panel; the center's is leftmost otherwise.
fn is_leftmost_top_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
};
let area = area.read(cx);
let left_open =
area.is_dock_open(DockPlacement::Left) && !area.is_empty(DockPlacement::Left, cx);
let tree = if left_open {
area.layout(DockPlacement::Left)
} else {
area.layout(DockPlacement::Center)
};
tree.and_then(|tree| left_top_group(tree.root())) == Some(group.node())
}
fn render_toolbar(
&self,
group: &TabGroupContext,
@@ -473,16 +544,19 @@ impl TabGroupSkin {
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let has_leading = left_button.is_some() || bottom_button.is_some();
let drag = tab_drag(group, ix, cx);
let is_title_bar = self.is_title_bar_group(group, cx);
let needs_traffic_light_padding =
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx))
.flatten();
h_flex()
let bar = h_flex()
.id("tab-title-bar")
.justify_between()
.items_center()
.line_height(rems(1.0))
.h(TABBAR_HEIGHT)
.py_2()
.pl_3()
.pr_2()
.rounded_t(cx.theme().radius_lg)
.bg(cx.theme().panel_background)
.when(left_button.is_some(), |this| this.pl_2())
.when(right_button.is_some(), |this| this.pr_2())
@@ -496,12 +570,15 @@ impl TabGroupSkin {
.children(bottom_button),
)
})
.when(needs_traffic_light_padding, |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
.child(
div()
.id("tab")
.flex_1()
.flex_initial()
.min_w_0()
.px_2()
.min_w_16()
.overflow_hidden()
.whitespace_nowrap()
.child(
@@ -524,6 +601,14 @@ impl TabGroupSkin {
})
}),
)
.child({
let space = div().id("tab-title-space").flex_1().h_full();
if is_title_bar {
title_bar_drag_handlers(space, window, cx).into_any_element()
} else {
space.into_any_element()
}
})
.child(
h_flex()
.flex_shrink_0()
@@ -532,7 +617,18 @@ impl TabGroupSkin {
.child(self.render_toolbar(group, window, cx))
.children(right_button),
)
.into_any_element()
.when_some(trailing_chrome, |this, chrome| this.child(chrome));
if is_title_bar {
h_flex()
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.child(bar.flex_1())
.child(window_controls())
.into_any_element()
} else {
bar.into_any_element()
}
}
fn render_tabs(
@@ -556,13 +652,41 @@ impl TabGroupSkin {
.iter()
.position(|panel| panel.panel_id(cx) == displayed)
});
let is_title_bar = self.is_title_bar_group(group, cx);
let needs_traffic_light_padding =
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx))
.flatten();
let empty_space = div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().surface_background))
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
let ix = (drag.source() == group.node()).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx);
}
})
});
let empty_space = if is_title_bar {
title_bar_drag_handlers(empty_space, window, cx).into_any_element()
} else {
empty_space.into_any_element()
};
TabBar::new("tab-bar")
let bar = TabBar::new("tab-bar")
.track_scroll(&self.scroll_handle)
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.rounded_t(cx.theme().radius_lg)
.when(has_leading, |this| {
.when(needs_traffic_light_padding, |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
.when(is_title_bar || has_leading, |this| {
this.prefix(
h_flex()
.items_center()
@@ -639,26 +763,7 @@ impl TabGroupSkin {
})
})
}))
.last_empty_space(
// Empty space so a panel can be moved past the last tab.
div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| {
this.bg(cx.theme().surface_background)
})
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
let ix = (drag.source() == group.node()).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx);
}
})
}),
)
.last_empty_space(empty_space)
.when(!collapsed, |this| {
this.suffix(
h_flex()
@@ -669,10 +774,22 @@ impl TabGroupSkin {
.px_0p5()
.gap_1()
.child(self.render_toolbar(group, window, cx))
.children(right_button),
.children(right_button)
.children(trailing_chrome),
)
})
.into_any_element()
});
if is_title_bar {
h_flex()
.h(TABBAR_HEIGHT)
.w_full()
.bg(cx.theme().panel_background)
.child(bar.flex_1())
.child(window_controls())
.into_any_element()
} else {
bar.into_any_element()
}
}
fn dock_toggle_button(
@@ -738,28 +855,23 @@ impl TabGroupSkin {
}
impl TabGroupRenderer for TabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
div()
.id("tab-panel")
.p_1()
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_xs())
.when(!group.is_collapsed(), |this| {
this.on_action({
let group = TabGroupContext::clone(group);
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
})
.on_action({
let group = TabGroupContext::clone(group);
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
let panel = panel.panel_id(cx);
group.close(panel, window, cx);
}
})
fn frame(&self, group: &TabGroupContext, _: &mut Window, _cx: &mut App) -> Stateful<Div> {
div().id("tab-panel").when(!group.is_collapsed(), |this| {
this.on_action({
let group = TabGroupContext::clone(group);
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
})
.on_action({
let group = TabGroupContext::clone(group);
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
let panel = panel.panel_id(cx);
group.close(panel, window, cx);
}
})
})
}
fn render_tab_bar(
@@ -768,6 +880,12 @@ impl TabGroupRenderer for TabGroupSkin {
window: &mut Window,
cx: &mut App,
) -> AnyElement {
// The left dock's only panel draws bare, so its content can own the
// window's top-left corner instead of a tab bar doing so.
if self.is_plain_left_group(group, cx) {
return Empty.into_any_element();
}
let visible: Vec<usize> = group
.panels()
.iter()
@@ -811,7 +929,6 @@ impl TabGroupRenderer for TabGroupSkin {
.child(
div()
.size_full()
.rounded_b(cx.theme().radius_lg)
.bg(cx.theme().panel_background)
.overflow_hidden()
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
+8
View File
@@ -33,12 +33,14 @@ pub enum IconName {
Close,
CloseCircle,
CloseCircleFill,
Compass,
Copy,
Device,
Door,
Ellipsis,
Emoji,
Eye,
Folder,
Input,
Info,
Invite,
@@ -47,6 +49,7 @@ pub enum IconName {
Link,
Loader,
Lock,
Message,
Moon,
Plus,
PlusCircle,
@@ -63,6 +66,7 @@ pub enum IconName {
Ship,
Shield,
Group,
History,
UserKey,
Upload,
Usb,
@@ -106,12 +110,14 @@ impl IconNamed for IconName {
Self::Close => "icons/close.svg",
Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Compass => "icons/compass.svg",
Self::Copy => "icons/copy.svg",
Self::Device => "icons/device.svg",
Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg",
Self::Eye => "icons/eye.svg",
Self::Folder => "icons/folder.svg",
Self::Input => "icons/input.svg",
Self::Info => "icons/info.svg",
Self::Invite => "icons/invite.svg",
@@ -120,6 +126,7 @@ impl IconNamed for IconName {
Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg",
Self::Message => "icons/message.svg",
Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg",
@@ -139,6 +146,7 @@ impl IconNamed for IconName {
Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg",
Self::Group => "icons/group.svg",
Self::History => "icons/history.svg",
Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRight => "icons/panel-right.svg",
+1
View File
@@ -18,6 +18,7 @@ pub mod indicator;
pub mod input;
pub mod menu;
pub mod modal;
pub mod nav_item;
pub mod notification;
pub mod popover;
pub mod resizable;
+100 -47
View File
@@ -1,15 +1,18 @@
use std::rc::Rc;
use gpui::{
Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
RenderOnce, SharedString, StyleRefinement, Styled, Window,
Anchor, AnyElement, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement,
IntoElement, MouseButton, RenderOnce, SharedString, StyleRefinement, Styled, Window,
};
use crate::Selectable;
use crate::avatar::Avatar;
use crate::button::Button;
use crate::menu::PopupMenu;
use crate::popover::Popover;
use crate::popover::{Popover, PopoverState};
/// Builds the items of a popup menu on each render.
type MenuBuilder = dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu;
/// A dropdown menu trait for buttons and other interactive elements
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
@@ -44,8 +47,7 @@ pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
style: StyleRefinement,
anchor: Anchor,
trigger: T,
#[allow(clippy::type_complexity)]
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
builder: Rc<MenuBuilder>,
}
impl<T> DropdownMenuPopover<T>
@@ -80,19 +82,95 @@ where
}
}
/// Opens a [`PopupMenu`] when its child is clicked with a mouse button
/// (right by default), keeping the child's own click handler intact.
#[derive(IntoElement)]
pub struct ContextMenu {
id: ElementId,
anchor: Anchor,
mouse_button: MouseButton,
child: AnyElement,
builder: Rc<MenuBuilder>,
}
impl ContextMenu {
pub fn new(
id: impl Into<ElementId>,
child: impl IntoElement,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
Self {
id: id.into(),
anchor: Anchor::TopLeft,
mouse_button: MouseButton::Right,
child: child.into_any_element(),
builder: Rc::new(builder),
}
}
/// Set the anchor corner of the menu, default is `Anchor::TopLeft`.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the mouse button that opens the menu, default is `MouseButton::Right`.
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
self.mouse_button = mouse_button;
self
}
}
#[derive(Default)]
struct DropdownMenuState {
struct MenuState {
menu: Option<Entity<PopupMenu>>,
}
/// Builds the menu once and reuses it until it is dismissed.
///
/// The popover content closure runs on every render, so rebuilding the menu
/// entity each time would drop its focus and selection state.
fn cached_menu(
menu_state: &Entity<MenuState>,
builder: Rc<MenuBuilder>,
window: &mut Window,
cx: &mut Context<PopoverState>,
) -> Entity<PopupMenu> {
if let Some(menu) = menu_state.read(cx).menu.clone() {
return menu;
}
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
builder(menu, window, cx)
});
menu_state.update(cx, |state, _| {
state.menu = Some(menu.clone());
});
menu.focus_handle(cx).focus(window, cx);
let popover_state = cx.entity();
window
.subscribe(&menu, cx, {
let menu_state = menu_state.clone();
move |_, _: &DismissEvent, window, cx| {
popover_state.update(cx, |state, cx| state.dismiss(window, cx));
menu_state.update(cx, |state, _| {
state.menu = None;
});
}
})
.detach();
menu
}
impl<T> RenderOnce for DropdownMenuPopover<T>
where
T: Selectable + IntoElement + 'static,
{
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let builder = self.builder.clone();
let menu_state =
window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
Popover::new(SharedString::from(format!("popover:{}", self.id)))
.appearance(false)
@@ -100,46 +178,21 @@ where
.trigger(self.trigger)
.trigger_style(self.style)
.anchor(self.anchor)
.content(move |_, window, cx| {
// Here is special logic to only create the PopupMenu once and reuse it.
// Because this `content` will called in every time render, so we need to store the menu
// in state to avoid recreating at every render.
//
// And we also need to rebuild the menu when it is dismissed, to rebuild menu items
// dynamically for support `dropdown_menu` method, so we listen for DismissEvent below.
let menu = match menu_state.read(cx).menu.clone() {
Some(menu) => menu,
None => {
let builder = builder.clone();
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
builder(menu, window, cx)
});
menu_state.update(cx, |state, _| {
state.menu = Some(menu.clone());
});
menu.focus_handle(cx).focus(window, cx);
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
}
}
// Listen for dismiss events from the PopupMenu to close the popover.
let popover_state = cx.entity();
window
.subscribe(&menu, cx, {
let menu_state = menu_state.clone();
move |_, _: &DismissEvent, window, cx| {
popover_state.update(cx, |state, cx| {
state.dismiss(window, cx);
});
menu_state.update(cx, |state, _| {
state.menu = None;
});
}
})
.detach();
impl RenderOnce for ContextMenu {
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let builder = self.builder.clone();
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
menu.clone()
}
};
menu.clone()
})
Popover::new(SharedString::from(format!("context-menu:{}", self.id)))
.appearance(false)
.overlay_closable(false)
.anchor(self.anchor)
.mouse_button(self.mouse_button)
.trigger_with(move |_open, _window, _cx| self.child)
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ mod dropdown_menu;
mod menu_item;
mod popup_menu;
pub use dropdown_menu::DropdownMenu;
pub use dropdown_menu::{ContextMenu, DropdownMenu};
pub use popup_menu::{PopupMenu, PopupMenuItem};
pub(crate) fn init(cx: &mut App) {
+94
View File
@@ -0,0 +1,94 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
div,
};
use theme::ActiveTheme;
use crate::{StyledExt, h_flex};
/// A single navigation entry in a sidebar.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
pub struct NavItem {
id: ElementId,
style: StyleRefinement,
icon: AnyElement,
label: SharedString,
suffix: Option<AnyElement>,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl NavItem {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
icon: impl IntoElement,
) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
icon: icon.into_any_element(),
label: label.into(),
suffix: None,
on_click: None,
}
}
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl Styled for NavItem {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for NavItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some();
h_flex()
.id(self.id)
.refine_style(&self.style)
.px_2()
.py_1()
.w_full()
.gap_2()
.rounded(cx.theme().radius)
.text_color(cx.theme().text)
.child(self.icon)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_sm()
.child(self.label),
)
.when_some(self.suffix, |this, suffix| {
this.child(div().flex_shrink_0().child(suffix))
})
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().ghost_element_hover))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
})
}
}
+13
View File
@@ -87,6 +87,19 @@ impl Popover {
self
}
/// Set the trigger from a builder, for elements that have no selected state.
///
/// [`Self::trigger`] marks the trigger as selected while the popover is
/// open, so it cannot be used with elements whose selection carries a
/// different meaning, such as a row that indicates the current room.
pub fn trigger_with<F>(mut self, trigger: F) -> Self
where
F: FnOnce(bool, &Window, &App) -> AnyElement + 'static,
{
self.trigger = Some(Box::new(trigger));
self
}
/// Set the default open state of the popover, default is `false`.
///
/// This is only used to initialize the open state of the popover.
+56 -4
View File
@@ -2,9 +2,10 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Context, Decorations, Hsla, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement as _,
StyleRefinement, Styled, TitlebarOptions, Window, WindowControlArea, div, px,
AnyElement, App, ClickEvent, Context, Decorations, Div, Hsla, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, Render, RenderOnce, Stateful,
StatefulInteractiveElement as _, StyleRefinement, Styled, TitlebarOptions, Window,
WindowControlArea, div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
@@ -210,10 +211,61 @@ impl RenderOnce for ControlIcon {
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
struct WindowControls {
pub(crate) struct WindowControls {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
}
pub(crate) fn window_controls() -> WindowControls {
WindowControls {
on_close_window: None,
}
}
pub fn title_bar_drag_handlers(
this: Stateful<Div>,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let state = window.use_state(cx, |_, _| TitleBarState { should_move: false });
let this = if cfg!(target_family = "wasm") {
this
} else {
this.window_control_area(WindowControlArea::Drag)
};
this.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = true;
}),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}),
)
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
if state.should_move {
state.should_move = false;
window.start_window_move();
}
}))
.on_click(|event, window, _| {
if event.click_count() == 2 {
if cfg!(target_os = "macos") {
window.titlebar_double_click();
} else {
window.zoom_window();
}
}
})
}
impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
+2
View File
@@ -12,6 +12,8 @@ state = { path = "../state" }
device = { path = "../device" }
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
community = { path = "../community" }
community_ui = { path = "../community_ui" }
settings = { path = "../settings" }
person = { path = "../person" }
auto_update = { path = "../auto_update" }
+15 -2
View File
@@ -1,8 +1,8 @@
use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder;
use gpui::{
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div,
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div, px,
};
use instant::Duration;
use nostr_connect::prelude::*;
@@ -12,6 +12,19 @@ use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex};
pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(450.))
.show_close(false)
.overlay_closable(false)
.keyboard(false)
.title("Onboarding")
.child(import.clone())
});
}
#[derive(Debug)]
pub struct ImportIdentity {
/// Secret key input
+2 -1
View File
@@ -1,4 +1,5 @@
pub mod import;
pub mod new_chat;
pub mod new_community;
pub mod restore;
pub mod screening;
pub mod settings;
+118
View File
@@ -0,0 +1,118 @@
use chat::{ChatRegistry, Room, RoomKind};
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Window, div, px,
};
use nostr_sdk::prelude::*;
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};
use ui::{StyledExt, WindowExtension, v_flex};
pub fn open(window: &mut Window, cx: &mut App) {
let view = cx.new(|cx| NewChat::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(420.)).title("New chat").child(view.clone())
});
}
pub struct NewChat {
/// Public key input
input: Entity<InputState>,
/// Error message
error: Option<SharedString>,
/// Input subscription
_subscription: Option<Subscription>,
}
impl NewChat {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub"));
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.start_chat(window, cx);
}
});
Self {
input,
error: None,
_subscription: Some(subscription),
}
}
fn start_chat(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.input.read(cx).value().to_string();
let Ok(peer) = PublicKey::parse(&value) else {
self.set_error("Public key is invalid", cx);
return;
};
let nostr = NostrRegistry::global(cx);
let Some(current_user) = nostr.read(cx).current_user() else {
self.set_error("You are not signed in", cx);
return;
};
if peer == current_user {
self.set_error("You cannot chat with yourself", cx);
return;
}
let room = Room::new(current_user, [peer])
.organize(&current_user)
.kind(RoomKind::Ongoing);
let chat = ChatRegistry::global(cx);
chat.update(cx, |chat, cx| {
let room = cx.new(|_| room);
chat.emit_room(&room, window, cx);
});
window.close_modal(cx);
}
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
self.error = Some(message.into());
cx.notify();
}
}
impl Render for NewChat {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_4()
.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Public key of the person you want to chat with")
.child(Input::new(&self.input)),
)
.child(
Button::new("start-chat")
.label("Start chat")
.primary()
.font_semibold()
.on_click(cx.listener(|this, _event, window, cx| {
this.start_chat(window, cx);
})),
)
.when_some(self.error.clone(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_danger)
.child(error),
)
})
}
}
@@ -0,0 +1,34 @@
use community::{CommunityMetadata, CommunityRegistry};
use gpui::{App, AppContext, ParentElement, Window, px};
use ui::WindowExtension;
use ui::input::{Input, InputState};
pub fn open(window: &mut Window, cx: &mut App) {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Community name"));
window.open_modal(cx, move |this, _window, _cx| {
let name_input = name_input.clone();
this.width(px(380.))
.confirm()
.title("New community")
.child(Input::new(&name_input))
.on_ok(move |_event, _window, cx| {
let name = name_input.read(cx).value().trim().to_owned();
if name.is_empty() {
return false;
}
let metadata = CommunityMetadata {
name,
..CommunityMetadata::default()
};
CommunityRegistry::global(cx)
.update(cx, |registry, cx| registry.create(metadata, cx));
true
})
});
}
-545
View File
@@ -1,545 +0,0 @@
use std::collections::HashMap;
use anyhow::Error;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx))
}
/// Screening
pub struct Screening {
/// Public Key of the person being screened.
public_key: PublicKey,
/// Whether the person's address is verified.
verified: bool,
/// Whether the person is followed by current user.
followed: bool,
/// Last time the person was active.
last_active: Option<Timestamp>,
/// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>,
/// Async tasks
tasks: SmallVec<[Task<()>; 3]>,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
this.tasks.clear();
window.close_all_modals(cx);
}));
cx.defer_in(window, |this, _window, cx| {
this.check_contact(cx);
this.check_wot(cx);
this.check_last_activity(cx);
this.verify_identifier(cx);
});
Self {
public_key,
verified: false,
followed: false,
last_active: None,
mutual_contacts: vec![],
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
fn check_contact(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
// Check if user is in contact list
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let followed = client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
.map(|event| event.tags.public_keys().any(|k| k == public_key))
.unwrap_or(false);
Ok(followed)
});
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false);
this.update(cx, |this, cx| {
this.followed = result;
cx.notify();
})
.ok();
}));
}
fn check_wot(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
// Check mutual contacts
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
if let Ok(events) = client.database().query(filter).await {
for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
mutual_contacts.push(event.pubkey);
}
}
Ok(mutual_contacts)
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.mutual_contacts = contacts;
cx.notify();
})
.ok();
}
Err(e) => {
log::error!("Failed to fetch mutual contacts: {}", e);
}
};
}));
}
fn check_last_activity(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Option<Timestamp>> = cx.background_spawn(async move {
let filter = Filter::new().author(public_key).limit(1);
let mut activity: Option<Timestamp> = None;
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
if let Ok(mut stream) = client
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await
{
while let Some((_url, event)) = stream.next().await {
if let Ok(event) = event {
activity = Some(event.created_at);
}
}
}
activity
});
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| {
this.last_active = result;
cx.notify();
})
.ok();
}));
}
fn verify_identifier(&mut self, cx: &mut Context<Self>) {
let http_client = cx.http_client();
let public_key = self.public_key;
// Skip if the user doesn't have a NIP-05 identifier
let Some(address) = self.address(cx) else {
return;
};
let task: Task<Result<bool, Error>> =
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await.unwrap_or(false);
this.update(cx, |this, cx| {
this.verified = result;
cx.notify();
})
.ok();
}));
}
fn profile(&self, cx: &Context<Self>) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
self.profile(cx)
.metadata()
.nip05
.and_then(|addr| Nip05Address::parse(&addr).ok())
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.profile(cx).public_key().to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
}
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = self.public_key;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let tag = Tag::from(Nip56Tag::PublicKey {
public_key,
report: Report::Impersonation,
});
let event = EventBuilder::new(Kind::Reporting, "")
.tag(tag)
.finalize_async(&signer)
.await?;
// Send the report to the public relays
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
Ok(())
});
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
if task.await.is_ok() {
cx.update(|window, cx| {
window.close_modal(cx);
window.push_notification("Report submitted successfully", cx);
})
.ok();
}
}));
}
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let contacts = self.mutual_contacts.clone();
window.open_modal(cx, move |this, _window, _cx| {
let contacts = contacts.clone();
let total = contacts.len();
this.title("Mutual contacts").child(
v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(total);
for ix in range {
let Some(contact) = contacts.get(ix) else {
continue;
};
let profile = persons.read(cx).get(contact, cx);
items.push(
h_flex()
.h_11()
.w_full()
.px_2()
.gap_1p5()
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.child(Avatar::new(profile.avatar()).small())
.child(profile.name()),
);
}
items
})
.h(px(300.)),
),
)
});
}
}
impl Render for Screening {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const CONTACT: &str = "This person is one of your contacts.";
const NOT_CONTACT: &str = "This person is not one of your contacts.";
const NO_ACTIVITY: &str = "This person hasn't had any activity.";
const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate.";
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
const NIP05_MATCH: &str = "The address matches the user's public key.";
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
const NO_NIP05: &str = "This person has not set up their friendly address";
let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
let last_active = self.last_active.map(|_| true);
let mutuals = self.mutual_contacts.len();
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
v_flex()
.gap_4()
.child(
v_flex()
.gap_3()
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar()).large())
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.name()),
),
)
.child(
h_flex()
.gap_3()
.child(
h_flex()
.p_1()
.flex_1()
.h_7()
.justify_center()
.rounded_full()
.bg(cx.theme().elevated_surface_background)
.text_sm()
.truncate()
.text_ellipsis()
.text_center()
.line_height(relative(1.))
.child(shorten_pubkey),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("njump")
.icon(IconName::Link)
.label("njump.me")
.secondary()
.small()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_njump(window, cx);
})),
)
.child(
Button::new("report")
.tooltip("Report as a scam or impostor")
.icon(IconName::Warning)
.small()
.warning()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.report(window, cx);
})),
),
),
)
.child(
v_flex()
.gap_3()
.child(
h_flex()
.items_start()
.gap_2()
.text_sm()
.child(status_badge(Some(self.followed), cx))
.child(
v_flex().text_sm().child("Contact").child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.text_sm()
.child(status_badge(last_active, cx))
.child(
v_flex()
.text_sm()
.child(
h_flex()
.gap_0p5()
.child("Activity on Public Relays")
.child(
Button::new("active")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.tooltip(RELAY_INFO),
),
)
.child(
div()
.w_full()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.map(|this| {
if let Some(t) = self.last_active {
this.child(SharedString::from(format!(
"Last active: {}.",
t.to_human_time()
)))
} else {
this.child(SharedString::from(NO_ACTIVITY))
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(self.verified), cx))
.child(
v_flex()
.text_sm()
.child({
if let Some(addr) = self.address(cx) {
SharedString::from(format!("{} validation", addr))
} else {
SharedString::from(
"Friendly Address (NIP-05) validation",
)
}
})
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.address(cx).is_some() {
if self.verified {
SharedString::from(NIP05_MATCH)
} else {
SharedString::from(NIP05_NOT_MATCH)
}
} else {
SharedString::from(NO_NIP05)
}
}),
),
),
)
.child(
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(mutuals > 0), cx))
.child(
h_flex()
.text_sm()
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if mutuals > 0 {
SharedString::from(mutuals_str)
} else {
SharedString::from(NO_MUTUAL)
}
}),
)
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(window, cx);
})),
),
),
),
)
}
}
fn status_badge(status: Option<bool>, cx: &App) -> Div {
h_flex()
.size_6()
.justify_center()
.flex_shrink_0()
.map(|this| {
if let Some(status) = status {
this.child(Icon::new(IconName::CheckCircle).small().text_color({
if status {
cx.theme().icon_accent
} else {
cx.theme().icon_muted
}
}))
} else {
this.child(Indicator::new().small())
}
})
}
+117 -150
View File
@@ -1,3 +1,4 @@
use std::rc::Rc;
use std::sync::Arc;
use ::settings::AppSettings;
@@ -5,11 +6,13 @@ use anyhow::Error;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry};
use common::download_dir;
use community::{CommunityEvent, CommunityRegistry};
use community_ui::CommunityPanel;
use device::{DeviceEvent, DeviceRegistry};
use gpui::prelude::FluentBuilder;
use gpui::{
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Subscription, Task, Window, div, px,
Action, AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, px,
};
use nostr_sdk::prelude::*;
use person::{PersonRegistry, shorten_pubkey};
@@ -17,17 +20,18 @@ use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::{Notification, NotificationKind};
use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_flex};
use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
use crate::dialogs::import::ImportIdentity;
use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::settings;
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list};
use crate::dialogs::{new_chat, new_community, settings};
use crate::panels::{
backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests,
search,
};
use crate::sidebar::Sidebar;
mod dialogs;
@@ -57,28 +61,34 @@ enum Command {
ShowSettings,
ShowBackup,
ShowContactList,
ShowInbox,
ShowRequests,
ShowBrowse,
ShowSearch,
NewChat,
NewCommunity,
}
pub struct Workspace {
sidebar: Entity<Sidebar>,
/// App's Dock Area
dock: Entity<DockArea>,
title_bar_chrome: Rc<dock::TitleBarChrome>,
/// The community panel currently docked, if any
community_panel: Option<WeakEntity<CommunityPanel>>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 6]>,
_subscriptions: SmallVec<[Subscription; 7]>,
}
impl Workspace {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let chat = ChatRegistry::global(cx);
let communities = CommunityRegistry::global(cx);
let device = DeviceRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
let dock = dock::dock_area("coop", window, cx);
let (dock, title_bar_chrome) = dock::dock_area("coop", window, cx);
let mut subscriptions = smallvec![];
@@ -91,16 +101,10 @@ impl Workspace {
subscriptions.push(
// Subscribe to the nostr events
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
match event {
StateEvent::SignerChanged => {
window.close_all_modals(cx);
}
StateEvent::NoSigner => {
this.import_identity(window, cx);
}
_ => {}
};
cx.subscribe_in(&nostr, window, move |_this, _state, event, window, cx| {
if let StateEvent::SignerChanged = event {
window.close_all_modals(cx);
}
}),
);
@@ -213,25 +217,77 @@ impl Workspace {
}),
);
cx.defer_in(window, |this, window, cx| {
subscriptions.push(
// Observe all events emitted by the community registry
cx.subscribe_in(
&communities,
window,
move |this, communities, event, window, cx| match event {
CommunityEvent::Open(id) => {
if let Some(community) = communities.read(cx).community(id) {
let panel = community_ui::init(community, window, cx);
this.community_panel = Some(panel.downgrade());
this.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
}
CommunityEvent::Close(_) => {
let Some(panel) = this
.community_panel
.take()
.and_then(|panel| panel.upgrade())
else {
return;
};
this.dock.update(cx, |area, cx| {
ui::dock::add_panel(
area,
PanelHandle::new(panel),
DockPlacement::Center,
window,
cx,
);
ui::dock::focus_tab_panel(area, window, cx);
cx.defer_in(window, |_, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
});
});
}
_ => {}
},
),
);
cx.defer_in(window, move |this, window, cx| {
let sidebar = PanelHandle::new(sidebar);
this.dock.update(cx, |area, cx| {
let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx);
area.set_dock(DockPlacement::Left, left, window, cx);
area.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
});
let greeter = PanelHandle::new(greeter::init(window, cx));
let center = DockLayout::v_split()
.child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None);
this.dock
.update(cx, |area, cx| area.set_center(center, window, cx));
this.dock.update(cx, |area, cx| {
area.set_center(center, window, cx);
});
});
Self {
sidebar,
dock,
title_bar_chrome,
community_panel: None,
tasks: vec![],
_subscriptions: subscriptions,
}
}
/// Add a panel to the dock, from anywhere that has the window but not the
/// workspace.
/// Add a panel to the dock, from anywhere that has the window but not the workspace.
pub fn add_panel<P: Panel>(
panel: Entity<P>,
placement: DockPlacement,
@@ -294,6 +350,29 @@ impl Workspace {
cx,
);
}
Command::ShowInbox => {
self.add_panel_to_dock(inbox::init(window, cx), DockPlacement::Center, window, cx);
}
Command::ShowRequests => {
self.add_panel_to_dock(
requests::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}
Command::ShowBrowse => {
self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx);
}
Command::ShowSearch => {
self.add_panel_to_dock(search::init(window, cx), DockPlacement::Center, window, cx);
}
Command::NewChat => {
new_chat::open(window, cx);
}
Command::NewCommunity => {
new_community::open(window, cx);
}
Command::ShowBackup => {
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
}
@@ -425,19 +504,6 @@ impl Workspace {
});
}
fn import_identity(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(450.))
.show_close(false)
.overlay_closable(false)
.keyboard(false)
.title("Onboarding")
.child(import.clone())
});
}
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, move |this, _window, cx| {
let registry = ThemeRegistry::global(cx);
@@ -518,95 +584,14 @@ impl Workspace {
});
}
fn titlebar_left(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
h_flex()
.flex_shrink_0()
.gap_2()
.when_none(&current_user, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Import your identity to continue")),
)
})
.when_some(current_user.as_ref(), |this, public_key| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(public_key, cx);
let avatar = profile.avatar();
let name = profile.name();
this.child(
Button::new("current-user")
.child(Avatar::new(avatar.clone()).xsmall())
.small()
.caret()
.compact()
.transparent()
.dropdown_menu(move |this, _window, cx| {
let avatar = avatar.clone();
let name = name.clone();
this.min_w(px(256.))
.item(PopupMenuItem::element(move |_window, cx| {
h_flex()
.gap_1p5()
.text_xs()
.text_color(cx.theme().text_muted)
.child(Avatar::new(avatar.clone()).xsmall())
.child(name.clone())
}))
.separator()
.menu_with_icon(
"Profile",
IconName::Profile,
Box::new(Command::ShowProfile),
)
.menu_with_icon(
"Contact List",
IconName::Book,
Box::new(Command::ShowContactList),
)
.menu_with_icon(
"Backup",
IconName::UserKey,
Box::new(Command::ShowBackup),
)
.menu_with_icon(
"Themes",
IconName::Sun,
Box::new(Command::ToggleTheme),
)
// Only offer in-app updates when auto-update is
// enabled (managed channels update themselves).
.when(AutoUpdater::is_available(cx), |this| {
this.separator().menu_with_icon(
"Check for Updates",
IconName::Device,
Box::new(Command::Update),
)
})
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Command::ShowSettings),
)
}),
)
})
}
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
fn titlebar_right(_window: &mut Window, cx: &mut App) -> AnyElement {
let auto_updater = AutoUpdater::try_global(cx);
let chat = ChatRegistry::global(cx);
let nip4e_enabled = AppSettings::get_nip4e(cx);
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return div();
return div().into_any_element();
};
let persons = PersonRegistry::global(cx);
@@ -635,11 +620,11 @@ impl Workspace {
.tooltip("Quit and relaunch into the installed update")
.small()
.ghost()
.on_click(cx.listener(|_this, _event, _window, cx| {
.on_click(|_event, _window, cx| {
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
auto_updater.update(cx, |this, cx| this.restart(cx));
}
})),
}),
)
})
.when(nip4e_enabled, |this| {
@@ -764,6 +749,7 @@ impl Workspace {
)
}),
)
.into_any_element()
}
}
@@ -772,34 +758,15 @@ impl Render for Workspace {
let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
// Render the title bar chrome
self.title_bar_chrome.set_trailing(Self::titlebar_right);
div()
.id("workspace")
.on_action(cx.listener(Self::on_command))
.relative()
.size_full()
.child(
v_flex()
.size_full()
// Title Bar
.child(
TitleBar::new()
.child(self.titlebar_left(cx))
.child(self.titlebar_right(cx)),
)
// Main
.child(
h_flex()
.size_full()
.child(
div()
.flex_shrink_0()
.h_full()
.w(SIDEBAR_WIDTH)
.child(self.sidebar.clone()),
)
.child(self.dock.clone()),
),
)
.child(self.dock.clone())
// Notifications
.children(notification_layer)
// Modals
+62
View File
@@ -0,0 +1,62 @@
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use theme::ActiveTheme;
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
cx.new(|cx| BrowsePanel::new(window, cx))
}
pub struct BrowsePanel {
name: SharedString,
focus_handle: FocusHandle,
}
impl BrowsePanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
name: "Browse".into(),
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for BrowsePanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Compass)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for BrowsePanel {}
impl Focusable for BrowsePanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for BrowsePanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(self.name.clone())
}
}
+5 -1
View File
@@ -239,7 +239,11 @@ impl ContactListPanel {
h_flex()
.gap_2()
.text_sm()
.child(Avatar::new(profile.avatar()).small())
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
.child(profile.name()),
)
.child(
+5 -2
View File
@@ -157,8 +157,11 @@ impl Render for GreeterPanel {
.label("Change theme")
.ghost()
.small()
.on_click(cx.listener(move |_, _, _, cx| {
cx.dispatch_action(&Command::ToggleTheme);
.on_click(cx.listener(move |_, _, window, cx| {
window.dispatch_action(
Box::new(Command::ToggleTheme),
cx,
);
})),
),
),
+62
View File
@@ -0,0 +1,62 @@
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use theme::ActiveTheme;
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<InboxPanel> {
cx.new(|cx| InboxPanel::new(window, cx))
}
pub struct InboxPanel {
name: SharedString,
focus_handle: FocusHandle,
}
impl InboxPanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
name: "Inbox".into(),
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for InboxPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Inbox)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for InboxPanel {}
impl Focusable for InboxPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for InboxPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(self.name.clone())
}
}
+4
View File
@@ -1,6 +1,10 @@
pub mod backup;
pub mod browse;
pub mod contact_list;
pub mod greeter;
pub mod inbox;
pub mod messaging_relays;
pub mod profile;
pub mod relay_list;
pub mod requests;
pub mod search;
+2 -7
View File
@@ -309,12 +309,7 @@ impl Render for ProfilePanel {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar_input = self.avatar_input.read(cx).value();
// Get the avatar
let avatar = if avatar_input.is_empty() {
"brand/avatar.png"
} else {
avatar_input.as_str()
};
let picture = (!avatar_input.is_empty()).then_some(avatar_input);
// Get the public key as short string
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
@@ -331,7 +326,7 @@ impl Render for ProfilePanel {
.items_center()
.justify_center()
.gap_4()
.child(Avatar::new(avatar).large())
.child(Avatar::new(picture).seed(self.public_key.to_hex()).large())
.child(
Button::new("upload")
.icon(IconName::PlusCircle)
+62
View File
@@ -0,0 +1,62 @@
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use theme::ActiveTheme;
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<RequestsPanel> {
cx.new(|cx| RequestsPanel::new(window, cx))
}
pub struct RequestsPanel {
name: SharedString,
focus_handle: FocusHandle,
}
impl RequestsPanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
name: "Requests".into(),
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for RequestsPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Invite)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for RequestsPanel {}
impl Focusable for RequestsPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for RequestsPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(self.name.clone())
}
}
+547
View File
@@ -0,0 +1,547 @@
use std::collections::HashSet;
use std::ops::Range;
use anyhow::Error;
use chat::{ChatRegistry, Room, RoomKind};
use common::DebouncedDelay;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div,
uniform_list,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputEvent, InputState};
use ui::notification::Notification;
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use crate::sidebar::{TreeRow, TreeRowKind};
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
cx.new(|cx| SearchPanel::new(window, cx))
}
pub struct SearchPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Find input state
find_input: Entity<InputState>,
/// Debounced delay for find input
find_debouncer: DebouncedDelay<Self>,
/// Whether a search is in progress
finding: bool,
/// Find results
find_results: Entity<Option<Vec<PublicKey>>>,
/// Async find operation
find_task: Option<Task<Result<(), Error>>>,
/// Selected public keys
selected_pkeys: Entity<HashSet<PublicKey>>,
/// User's contacts
contact_list: Entity<Option<Vec<PublicKey>>>,
/// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl SearchPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let contact_list = cx.new(|_| None);
let selected_pkeys = cx.new(|_| HashSet::new());
let find_results = cx.new(|_| None);
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(INPUT_PLACEHOLDER)
.clean_on_escape()
});
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to find input events
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
let delay = Duration::from_millis(FIND_DELAY);
match event {
InputEvent::PressEnter { .. } => {
this.search(window, cx);
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
// Clear results when input is empty
this.reset(window, cx);
} else {
// Run debounced search
this.find_debouncer
.fire_new(delay, window, cx, |this, window, cx| {
this.debounced_search(window, cx)
});
}
}
InputEvent::Focus => {
this.get_contact_list(window, cx);
}
_ => {}
};
}),
);
Self {
name: "Search".into(),
focus_handle: cx.focus_handle(),
find_input,
find_debouncer: DebouncedDelay::new(),
find_results,
find_task: None,
finding: false,
contact_list,
selected_pkeys,
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
/// Get the contact list.
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
Ok(contacts)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.set_contact_list(contacts, cx);
})?;
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
})?;
}
};
Ok(())
}));
}
/// Set the contact list with new contacts.
fn set_contact_list<I>(&mut self, contacts: I, cx: &mut Context<Self>)
where
I: IntoIterator<Item = PublicKey>,
{
self.contact_list.update(cx, |this, cx| {
*this = Some(contacts.into_iter().collect());
cx.notify();
});
}
/// Trigger the debounced search
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
this.search(window, cx);
})
.ok();
})
}
/// Search
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get query
let query = self.find_input.read(cx).value();
// Return if the query is empty
if query.is_empty() {
return;
}
// Block the input until the search completes
self.set_finding(true, window, cx);
// Create the search task
let nostr = NostrRegistry::global(cx);
let find_users = nostr.read(cx).search(&query, cx);
// Run task in the main thread
self.find_task = Some(cx.spawn_in(window, async move |this, cx| {
let rooms = find_users.await?;
// Update the UI with the search results
this.update_in(cx, |this, window, cx| {
this.set_results(rooms, cx);
this.set_finding(false, window, cx);
})?;
Ok(())
}));
}
/// Set the results of the search
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
self.find_results.update(cx, |this, cx| {
*this = Some(results);
cx.notify();
});
}
/// Set the finding status
fn set_finding(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
// Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_loading(status, window, cx);
});
// Set the search status
self.finding = status;
cx.notify();
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Clear all search results
self.find_results.update(cx, |this, cx| {
*this = None;
cx.notify();
});
// Clear all selected public keys
self.selected_pkeys.update(cx, |this, cx| {
this.clear();
cx.notify();
});
// Reset the search status
self.set_finding(false, window, cx);
// Cancel the current search task
self.find_task = None;
cx.notify();
}
/// Select a public key in the search panel.
fn select(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
self.selected_pkeys.update(cx, |this, cx| {
if this.contains(public_key) {
this.remove(public_key);
} else {
this.insert(public_key.to_owned());
}
cx.notify();
});
}
/// Check if a public key is selected in the search panel.
fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool {
self.selected_pkeys.read(cx).contains(public_key)
}
/// Get all selected public keys in the search panel.
fn get_selected(&self, cx: &Context<Self>) -> HashSet<PublicKey> {
self.selected_pkeys.read(cx).clone()
}
/// Create a new room
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
let async_chat = chat.downgrade();
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
// Get all selected public keys
let receivers = self.get_selected(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Create a new room and emit it
async_chat.update_in(cx, |this, _window, cx| {
let room = cx.new(|_| {
Room::new(public_key, receivers)
.organize(&public_key)
.kind(RoomKind::Ongoing)
});
this.emit_room(&room, _window, cx);
})?;
// Reset the find panel
this.update_in(cx, |this, window, cx| {
this.reset(window, cx);
})?;
Ok(())
}));
}
/// Render the search results
fn render_results(
&self,
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
let persons = PersonRegistry::global(cx);
// Get the results
let Some(results) = self.find_results.read(cx) else {
return vec![];
};
// Map the results to a list of elements
results
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, public_key)| {
let selected = self.is_selected(public_key, cx);
let profile = persons.read(cx).get(public_key, cx);
let pkey_clone = public_key.to_owned();
let handler = cx.listener(move |this, _ev, _window, cx| {
this.select(&pkey_clone, cx);
});
TreeRow::new(
ElementId::NamedInteger("search-result".into(), (range.start + ix) as u64),
TreeRowKind::Room,
profile.name(),
)
.avatar(profile.avatar_seed())
.picture(profile.avatar())
.on_click(handler)
.selected(selected)
.into_any_element()
})
.collect()
}
/// Render the contact list
fn render_contacts(
&self,
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
let persons = PersonRegistry::global(cx);
// Get the contact list
let Some(contacts) = self.contact_list.read(cx) else {
return vec![];
};
// Map the contact list to a list of elements
contacts
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, public_key)| {
let selected = self.is_selected(public_key, cx);
let profile = persons.read(cx).get(public_key, cx);
let pkey_clone = public_key.to_owned();
let handler = cx.listener(move |this, _ev, _window, cx| {
this.select(&pkey_clone, cx);
});
TreeRow::new(
ElementId::NamedInteger("contact".into(), (range.start + ix) as u64),
TreeRowKind::Room,
profile.name().trim(),
)
.avatar(profile.avatar_seed())
.picture(profile.avatar())
.on_click(handler)
.selected(selected)
.into_any_element()
})
.collect()
}
}
impl Panel for SearchPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Search)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for SearchPanel {}
impl Focusable for SearchPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for SearchPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let chat = ChatRegistry::global(cx);
let logged_in = nostr.read(cx).current_user().is_some();
let loading = chat.read(cx).loading() && logged_in;
// Set button label based on total selected users
let button_label = if self.selected_pkeys.read(cx).len() > 1 {
"Create Group DM"
} else {
"Create DM"
};
v_flex()
.size_full()
.gap_3()
.p_2()
.child(
h_flex().child(
Input::new(&self.find_input)
.small()
.text_xs()
.disabled(loading)
.when(
!self.find_input.read(cx).presentation().is_loading(),
|this| {
this.suffix(
Button::new("find-icon")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.transparent()
.small(),
)
},
),
),
)
.child(
v_flex()
.flex_1()
.gap_3()
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
this.child(
v_flex()
.gap_1()
.flex_1()
.border_b_1()
.border_color(cx.theme().border_variant)
.child(
h_flex()
.gap_0p5()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown))
.child("Results"),
)
.child(
uniform_list(
"rooms",
results.len(),
cx.processor(move |this, range, _window, cx| {
this.render_results(range, cx)
}),
)
.flex_1()
.h_full(),
),
)
})
.when_some(self.contact_list.read(cx).as_ref(), |this, contacts| {
this.child(
v_flex()
.gap_1()
.flex_1()
.child(
h_flex()
.gap_0p5()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown).small())
.child("Contacts"),
)
.child(
uniform_list(
"contacts",
contacts.len(),
cx.processor(|this, range, _window, cx| {
this.render_contacts(range, cx)
}),
)
.flex_1()
.h_full(),
),
)
}),
)
.when(!self.selected_pkeys.read(cx).is_empty(), |this| {
this.child(
div()
.absolute()
.bottom_2()
.left_0()
.h_9()
.w_full()
.px_4()
.child(
Button::new("create")
.label(button_label)
.primary()
.rounded()
.shadow_md()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.create_room(window, cx);
})),
),
)
})
}
}
-174
View File
@@ -1,174 +0,0 @@
use std::rc::Rc;
use chat::RoomKind;
use gpui::prelude::FluentBuilder;
use gpui::{
App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div,
};
use nostr_sdk::prelude::*;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::dock::ClosePanel;
use ui::modal::ModalButtonProps;
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex};
use crate::dialogs::screening;
#[derive(IntoElement)]
pub struct RoomEntry {
ix: usize,
public_key: Option<PublicKey>,
name: Option<SharedString>,
avatar: Option<SharedString>,
created_at: Option<SharedString>,
kind: Option<RoomKind>,
selected: bool,
#[allow(clippy::type_complexity)]
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl RoomEntry {
pub fn new(ix: usize) -> Self {
Self {
ix,
public_key: None,
name: None,
avatar: None,
created_at: None,
kind: None,
handler: None,
selected: false,
}
}
pub fn public_key(mut self, public_key: PublicKey) -> Self {
self.public_key = Some(public_key);
self
}
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.name = Some(name.into());
self
}
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
self.avatar = Some(avatar.into());
self
}
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
self.created_at = Some(created_at.into());
self
}
pub fn kind(mut self, kind: RoomKind) -> Self {
self.kind = Some(kind);
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.handler = Some(Rc::new(handler));
self
}
}
impl Selectable for RoomEntry {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl RenderOnce for RoomEntry {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let screening = AppSettings::get_screening(cx);
let public_key = self.public_key;
let is_selected = self.is_selected();
h_flex()
.id(self.ix)
.h_9()
.w_full()
.px_1p5()
.gap_2()
.text_sm()
.rounded(cx.theme().radius)
.when(!hide_avatar, |this| {
this.when_some(self.avatar, |this, avatar| {
this.child(Avatar::new(avatar).small().flex_shrink_0())
})
})
.child(
div()
.flex_1()
.flex()
.items_center()
.justify_between()
.when_some(self.name, |this, name| {
this.child(
h_flex()
.flex_1()
.justify_between()
.line_clamp(1)
.text_ellipsis()
.truncate()
.font_medium()
.child(name)
.when(is_selected, |this| {
this.child(
Icon::new(IconName::CheckCircle)
.small()
.text_color(cx.theme().icon_accent),
)
}),
)
})
.child(
h_flex()
.gap_1p5()
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().text_placeholder)
.when_some(self.created_at, |this, created_at| this.child(created_at)),
),
)
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.when_some(self.handler, |this, handler| {
this.on_click(move |event, window, cx| {
handler(event, window, cx);
if let Some(public_key) = public_key
&& self.kind != Some(RoomKind::Ongoing)
&& screening
{
let screening = screening::init(public_key, window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.confirm()
.child(screening.clone())
.button_props(
ModalButtonProps::default()
.cancel_text("Ignore")
.ok_text("Response"),
)
.on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
true
})
});
}
})
})
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
use gpui::{App, InteractiveElement, IntoElement, ParentElement, Styled, Window, div, svg};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants};
use ui::{StyledExt, h_flex, title_bar_drag_handlers, v_flex};
use crate::dialogs::import;
const TITLE: &str = "Welcome to Coop!";
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
pub(super) fn render(window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
.size_full()
.relative()
.bg(cx.theme().surface_background)
.child(title_bar_drag_handlers(
div()
.id("onboarding-drag")
.absolute()
.top_0()
.left_0()
.h(TABBAR_HEIGHT)
.w_full(),
window,
cx,
))
.child(
v_flex()
.size_full()
.justify_end()
.gap_4()
.p_4()
.child(
h_flex()
.gap_2()
.child(
svg()
.path("brand/coop.svg")
.size_8()
.text_color(cx.theme().icon_muted),
)
.child(
v_flex().child(div().font_semibold().child(TITLE)).child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(DESCRIPTION),
),
),
)
.child(
v_flex()
.gap_2()
.w_full()
.child(
Button::new("join-now")
.label("Join now")
.primary()
.font_semibold()
.h_8()
.w_full(),
)
.child(
Button::new("import-identity")
.label("Import identity")
.secondary()
.font_semibold()
.h_8()
.w_full()
.on_click(|_event, window, cx| import::open(window, cx)),
),
),
)
}
+121
View File
@@ -0,0 +1,121 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{App, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::{IconName, Selectable, h_flex};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SidebarTab {
Recents,
Chats,
Communities,
}
impl SidebarTab {
pub const ALL: [SidebarTab; 3] = [Self::Recents, Self::Chats, Self::Communities];
pub fn label(self) -> &'static str {
match self {
Self::Recents => "Recents",
Self::Chats => "Chats",
Self::Communities => "Communities",
}
}
pub fn icon(self) -> IconName {
match self {
Self::Recents => IconName::History,
Self::Chats => IconName::Message,
Self::Communities => IconName::Group,
}
}
pub fn list_id(self) -> &'static str {
match self {
Self::Recents => "sidebar-recents",
Self::Chats => "sidebar-chats",
Self::Communities => "sidebar-communities",
}
}
pub fn index(self) -> usize {
match self {
Self::Recents => 0,
Self::Chats => 1,
Self::Communities => 2,
}
}
pub fn chat(self) -> bool {
matches!(self, Self::Chats)
}
pub fn community(self) -> bool {
matches!(self, Self::Communities)
}
}
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub struct TabBar {
active: SidebarTab,
on_select: Option<Rc<dyn Fn(SidebarTab, &mut Window, &mut App)>>,
}
impl TabBar {
pub fn new(active: SidebarTab) -> Self {
Self {
active,
on_select: None,
}
}
pub fn on_select(
mut self,
handler: impl Fn(SidebarTab, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TabBar {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self { active, on_select } = self;
div()
.id("sidebar-tabs")
.absolute()
.bottom_3()
.left_0()
.w_full()
.px_4()
.child(
h_flex()
.w_full()
.p_1()
.gap_1()
.rounded_full()
.bg(cx.theme().background)
.when(cx.theme().shadow, |this| this.shadow_md())
.children(SidebarTab::ALL.into_iter().map(|tab| {
let on_select = on_select.clone();
Button::new(format!("tab-{}", tab.list_id()))
.icon(tab.icon())
.ghost()
.flex_1()
.rounded()
.selected(tab == active)
.tooltip(tab.label())
.on_click(move |_event, window, cx| {
if let Some(on_select) = on_select.as_ref() {
on_select(tab, window, cx);
}
})
})),
)
}
}
+235
View File
@@ -0,0 +1,235 @@
use std::rc::Rc;
use chat::Room;
use community::Community;
use gpui::prelude::FluentBuilder;
use gpui::{
App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
};
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::{Avatar, PixelAvatar};
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex};
use super::tab::SidebarTab;
pub enum SidebarRow {
Section {
label: SharedString,
count: usize,
},
Room {
room: Entity<Room>,
},
Community {
community: Entity<Community>,
},
Action {
label: SharedString,
tab: SidebarTab,
},
Hint {
text: SharedString,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TreeRowKind {
Section,
Room,
Community,
Action,
Hint,
}
#[derive(IntoElement)]
pub struct TreeRow {
id: ElementId,
kind: TreeRowKind,
label: SharedString,
avatar: Option<SharedString>,
picture: Option<ImageSource>,
icon: Option<IconName>,
count: Option<usize>,
created_at: Option<SharedString>,
selected: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl TreeRow {
pub fn new(
id: impl Into<ElementId>,
kind: TreeRowKind,
label: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
kind,
label: label.into(),
avatar: None,
picture: None,
icon: None,
count: None,
created_at: None,
selected: false,
on_click: None,
}
}
/// Sets the seed for the row's generated avatar.
pub fn avatar(mut self, seed: impl Into<SharedString>) -> Self {
self.avatar = Some(seed.into());
self
}
/// Shows `picture` instead of the generated avatar.
pub fn picture(mut self, picture: Option<impl Into<ImageSource>>) -> Self {
self.picture = picture.map(Into::into);
self
}
/// Shows `icon` in the avatar slot when the row has no avatar or picture.
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn count(mut self, count: usize) -> Self {
self.count = Some(count);
self
}
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
self.created_at = Some(created_at.into());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl Selectable for TreeRow {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl RenderOnce for TreeRow {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let is_section = self.kind == TreeRowKind::Section;
let is_room = self.kind == TreeRowKind::Room;
let is_community = self.kind == TreeRowKind::Community;
let is_action = self.kind == TreeRowKind::Action;
let is_hint = self.kind == TreeRowKind::Hint;
let is_selected = self.selected;
let avatar = if hide_avatar {
None
} else {
match (self.avatar, self.picture) {
(None, None) => None,
(seed, Some(picture)) => Some(
Avatar::from_source(picture)
.when_some(seed, |avatar, seed| avatar.seed(seed))
.xsmall()
.flex_shrink_0()
.into_any_element(),
),
(Some(seed), None) => Some(
PixelAvatar::new(seed)
.xsmall()
.flex_shrink_0()
.into_any_element(),
),
}
};
let avatar = avatar.or_else(|| {
self.icon.map(|icon| {
h_flex()
.flex_shrink_0()
.w(px(20.))
.justify_center()
.text_color(cx.theme().icon_muted)
.child(Icon::new(icon).small())
.into_any_element()
})
});
h_flex()
.id(self.id)
.h_8()
.w_full()
.px_2()
.gap_2()
.rounded(cx.theme().radius)
.when(is_section, |this| {
this.text_xs()
.text_color(cx.theme().text_placeholder)
.font_semibold()
})
.when(is_room || is_community, |this| this.text_sm())
.when(is_action, |this| {
this.text_sm().text_color(cx.theme().text_muted)
})
.when(is_hint, |this| {
this.text_xs()
.font_normal()
.text_color(cx.theme().text_placeholder)
})
.when_some(avatar, |this, avatar| this.child(avatar))
.child(
h_flex()
.gap_1()
.flex_1()
.child(
div()
.truncate()
.min_w_0()
.when(is_room, |this| this.font_medium())
.child(self.label),
)
.when(is_selected, |this| {
this.child(
Icon::new(IconName::CheckCircle)
.small()
.flex_shrink_0()
.text_color(cx.theme().icon_accent),
)
})
.when_some(self.count, |this, count| {
this.child(div().flex_shrink_0().font_normal().child(count.to_string()))
})
.when_some(self.created_at, |this, created_at| {
this.child(div().flex_1()).child(
div()
.flex_shrink_0()
.text_color(cx.theme().text_placeholder)
.text_xs()
.child(created_at),
)
}),
)
.when_some(self.on_click, |this, handler| {
this.cursor_pointer()
.when(!is_section, |this| {
this.hover(|this| this.bg(cx.theme().ghost_element_hover))
})
.on_click(move |event, window, cx| handler(event, window, cx))
})
}
}