Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa86fef16f | ||
|
|
bbc69c9861 | ||
|
|
a249326c28 | ||
|
|
61289edb9a | ||
|
|
a365a8f584 | ||
|
|
7c42466b0a | ||
|
|
291471075b | ||
|
|
b4ce9368e2 | ||
|
|
05f934c93a | ||
|
|
f8032b2074 | ||
|
|
f1192492d2 | ||
|
|
440d6dfea3 | ||
|
|
4c279a0686 | ||
|
|
f83922df2e |
Generated
+167
-642
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.2"
|
version = "2.0.0-alpha"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -57,7 +57,6 @@ smol = "2"
|
|||||||
webbrowser = "1.0.4"
|
webbrowser = "1.0.4"
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
instant = "0.1"
|
instant = "0.1"
|
||||||
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
@@ -6,10 +6,12 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
instant.workspace = true
|
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
ureq.workspace = true
|
futures.workspace = true
|
||||||
|
|
||||||
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
|
gpui-updater-pre = "0.2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
reqwest_client.workspace = true
|
||||||
|
|||||||
+55
-166
@@ -1,11 +1,9 @@
|
|||||||
#![cfg(not(target_arch = "wasm32"))]
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::time::Duration;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
||||||
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
||||||
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
|
use gpui_updater_pre::{EngineConfig, UpdateStatus, Updater, Verification, Version};
|
||||||
use instant::Duration;
|
|
||||||
|
|
||||||
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
|
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
|
||||||
|
|
||||||
@@ -66,13 +64,10 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
|||||||
impl Global for GlobalAutoUpdater {}
|
impl Global for GlobalAutoUpdater {}
|
||||||
|
|
||||||
pub struct AutoUpdater {
|
pub struct AutoUpdater {
|
||||||
/// The blocking engine, driven on the background executor.
|
updater: Entity<Updater>,
|
||||||
engine: Arc<UpdateEngine<GiteaSource>>,
|
|
||||||
status: UpdateStatus,
|
status: UpdateStatus,
|
||||||
/// The newer release found by the last successful check, if any.
|
/// The pending revert of an `Errored` status back to `Idle`, if any.
|
||||||
available: Option<Release>,
|
error_revert: Option<Task<()>>,
|
||||||
/// The in-flight check or download, if any.
|
|
||||||
task: Option<Task<()>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoUpdater {
|
impl AutoUpdater {
|
||||||
@@ -87,12 +82,21 @@ impl AutoUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn new(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(
|
||||||
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
|
GITEA_API_BASE,
|
||||||
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
|
GITEA_REPO_OWNER,
|
||||||
let engine = Arc::new(UpdateEngine::new(source, config));
|
GITEA_REPO_NAME,
|
||||||
|
filter,
|
||||||
|
cx.http_client(),
|
||||||
|
);
|
||||||
|
let config = EngineConfig::new(version).verification(Verification::Checksum);
|
||||||
|
let updater = cx.new(|cx| Updater::new(source, config, cx));
|
||||||
|
|
||||||
|
cx.observe(&updater, |this, _, cx| this.observe_status(cx))
|
||||||
|
.detach();
|
||||||
|
|
||||||
// Schedule an auto-check after a 2-minute delay
|
// Schedule an auto-check after a 2-minute delay
|
||||||
|
let entity = cx.entity().downgrade();
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||||
@@ -102,13 +106,41 @@ impl AutoUpdater {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
engine,
|
updater,
|
||||||
status: UpdateStatus::Idle,
|
status: UpdateStatus::Idle,
|
||||||
available: None,
|
error_revert: None,
|
||||||
task: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_status(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let status = self.updater.read(cx).status().clone();
|
||||||
|
self.status = status.clone();
|
||||||
|
|
||||||
|
self.error_revert = if matches!(status, UpdateStatus::Errored(_)) {
|
||||||
|
Some(cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if matches!(this.status, UpdateStatus::Errored(_)) {
|
||||||
|
this.status = UpdateStatus::Idle;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// The adapter stops at `Available`; continue into the download.
|
||||||
|
if matches!(status, UpdateStatus::Available(_)) {
|
||||||
|
self.updater.update(cx, |updater, cx| {
|
||||||
|
updater.download_and_install(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the running version is the newest release, so the status line can be hidden.
|
/// Whether the running version is the newest release, so the status line can be hidden.
|
||||||
pub fn up_to_date(&self) -> bool {
|
pub fn up_to_date(&self) -> bool {
|
||||||
matches!(self.status, UpdateStatus::Idle | UpdateStatus::UpToDate)
|
matches!(self.status, UpdateStatus::Idle | UpdateStatus::UpToDate)
|
||||||
@@ -143,136 +175,9 @@ impl AutoUpdater {
|
|||||||
|
|
||||||
/// Check the release host for a newer version, then download and install it.
|
/// Check the release host for a newer version, then download and install it.
|
||||||
pub fn check(&mut self, cx: &mut Context<Self>) {
|
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.status.is_busy() {
|
self.updater.update(cx, |updater, cx| {
|
||||||
return;
|
updater.check(cx);
|
||||||
}
|
});
|
||||||
self.set_status(UpdateStatus::Checking, cx);
|
|
||||||
|
|
||||||
let engine = self.engine.clone();
|
|
||||||
|
|
||||||
self.task = Some(cx.spawn(async move |this, cx| {
|
|
||||||
let result = cx
|
|
||||||
.background_executor()
|
|
||||||
.spawn(async move { engine.check() })
|
|
||||||
.await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.task = None;
|
|
||||||
match result {
|
|
||||||
Ok(Some(release)) => {
|
|
||||||
log::info!("Update {} is available", release.version);
|
|
||||||
let version = release.version.clone();
|
|
||||||
this.available = Some(release);
|
|
||||||
this.set_status(UpdateStatus::Available(version), cx);
|
|
||||||
this.download_and_install(cx);
|
|
||||||
}
|
|
||||||
Ok(None) => this.set_status(UpdateStatus::UpToDate, cx),
|
|
||||||
Err(error) => {
|
|
||||||
log::warn!("Update check failed: {error}");
|
|
||||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download the available update, verify it, and swap it into place.
|
|
||||||
fn download_and_install(&mut self, cx: &mut Context<Self>) {
|
|
||||||
if self.status.is_busy() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(release) = self.available.clone() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let engine = self.engine.clone();
|
|
||||||
self.set_status(
|
|
||||||
UpdateStatus::Downloading {
|
|
||||||
downloaded: 0,
|
|
||||||
total: None,
|
|
||||||
},
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
|
|
||||||
self.task = Some(cx.spawn(async move |this, cx| {
|
|
||||||
let downloaded = Arc::new(AtomicU64::new(0));
|
|
||||||
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
|
|
||||||
let done = Arc::new(AtomicBool::new(false));
|
|
||||||
|
|
||||||
let download_task = {
|
|
||||||
let (engine, release) = (engine.clone(), release.clone());
|
|
||||||
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
|
|
||||||
cx.background_executor().spawn(async move {
|
|
||||||
let result = engine.download(&release, |got, expected| {
|
|
||||||
downloaded.store(got, Ordering::Relaxed);
|
|
||||||
total.store(expected.unwrap_or(0), Ordering::Relaxed);
|
|
||||||
});
|
|
||||||
done.store(true, Ordering::Relaxed);
|
|
||||||
result
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let got = downloaded.load(Ordering::Relaxed);
|
|
||||||
let total = total.load(Ordering::Relaxed);
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_status(
|
|
||||||
UpdateStatus::Downloading {
|
|
||||||
downloaded: got,
|
|
||||||
total: (total != 0).then_some(total),
|
|
||||||
},
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
if done.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
cx.background_executor()
|
|
||||||
.timer(Duration::from_millis(120))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let artifact = match download_task.await {
|
|
||||||
Ok(artifact) => artifact,
|
|
||||||
Err(error) => {
|
|
||||||
log::warn!("Update download failed: {error}");
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.task = None;
|
|
||||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
|
|
||||||
|
|
||||||
let installed = {
|
|
||||||
let engine = engine.clone();
|
|
||||||
cx.background_executor()
|
|
||||||
.spawn(async move { engine.install(&artifact) })
|
|
||||||
.await
|
|
||||||
};
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.task = None;
|
|
||||||
match installed {
|
|
||||||
Ok(installed) => {
|
|
||||||
if let Some(path) = installed.restart_path {
|
|
||||||
cx.set_restart_path(path);
|
|
||||||
}
|
|
||||||
let version = release.version.clone();
|
|
||||||
this.set_status(UpdateStatus::Staged(version), cx);
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Relaunch into the staged update.
|
/// Relaunch into the staged update.
|
||||||
@@ -281,24 +186,8 @@ impl AutoUpdater {
|
|||||||
log::warn!("Ignoring restart request: no update is staged");
|
log::warn!("Ignoring restart request: no update is staged");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cx.restart();
|
self.updater.update(cx, |updater, cx| {
|
||||||
}
|
updater.restart(cx);
|
||||||
|
});
|
||||||
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
|
|
||||||
let errored = matches!(status, UpdateStatus::Errored(_));
|
|
||||||
self.status = status;
|
|
||||||
|
|
||||||
if errored {
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_status(UpdateStatus::Idle, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag};
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures::AsyncReadExt;
|
||||||
|
use futures::executor::block_on;
|
||||||
|
use gpui::http_client::{AsyncBody, HttpClient, HttpRequestExt, RedirectPolicy, Request};
|
||||||
|
use gpui_updater_pre::{Asset, Error, Release, Result, UpdateSource, parse_tag};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
||||||
const RELEASE_PAGE_SIZE: usize = 20;
|
const RELEASE_PAGE_SIZE: usize = 20;
|
||||||
|
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
|
||||||
|
/// Deadline for the complete metadata request, including its body.
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// Which published artifact belongs to a target platform.
|
/// Which published artifact belongs to a target platform.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -39,12 +49,38 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
|||||||
Some(AssetFilter { extension, arch })
|
Some(AssetFilter { extension, arch })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A release as returned by the Gitea API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaRelease {
|
||||||
|
tag_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
draft: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
prerelease: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
assets: Vec<GiteaAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release asset as returned by the Gitea API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaAsset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads releases from a Gitea repository's Releases.
|
/// Reads releases from a Gitea repository's Releases.
|
||||||
pub struct GiteaSource {
|
pub struct GiteaSource {
|
||||||
api_base: String,
|
api_base: String,
|
||||||
owner: String,
|
owner: String,
|
||||||
repo: String,
|
repo: String,
|
||||||
filter: AssetFilter,
|
filter: AssetFilter,
|
||||||
|
http_client: Arc<dyn HttpClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GiteaSource {
|
impl GiteaSource {
|
||||||
@@ -55,12 +91,14 @@ impl GiteaSource {
|
|||||||
owner: impl Into<String>,
|
owner: impl Into<String>,
|
||||||
repo: impl Into<String>,
|
repo: impl Into<String>,
|
||||||
filter: AssetFilter,
|
filter: AssetFilter,
|
||||||
|
http_client: Arc<dyn HttpClient>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
api_base: api_base.into().trim_end_matches('/').to_string(),
|
api_base: api_base.into().trim_end_matches('/').to_string(),
|
||||||
owner: owner.into(),
|
owner: owner.into(),
|
||||||
repo: repo.into(),
|
repo: repo.into(),
|
||||||
filter,
|
filter,
|
||||||
|
http_client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +108,49 @@ impl GiteaSource {
|
|||||||
self.api_base, self.owner, self.repo
|
self.api_base, self.owner, self.repo
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
|
||||||
|
let request = Request::get(url)
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.follow_redirects(RedirectPolicy::FollowAll)
|
||||||
|
.body(AsyncBody::empty())
|
||||||
|
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
|
||||||
|
|
||||||
|
let mut response = self
|
||||||
|
.http_client
|
||||||
|
.send(request)
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(Error::Http(format!("GET {url} -> {status}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut body = Vec::new();
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
|
||||||
|
let body = self.get_bytes(url).await?;
|
||||||
|
serde_json::from_slice(&body).map_err(|error| Error::Parse(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_string(&self, url: &str) -> Result<String> {
|
||||||
|
let body = self.get_bytes(url).await?;
|
||||||
|
String::from_utf8(body).map_err(|error| Error::Parse(error.to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpdateSource for GiteaSource {
|
impl UpdateSource for GiteaSource {
|
||||||
fn fetch_latest(&self) -> Result<Release> {
|
fn fetch_latest(&self) -> Result<Release> {
|
||||||
let releases: Vec<GiteaRelease> = http::get_json(&self.releases_url())?;
|
let releases: Vec<GiteaRelease> = block_on(self.get_json(&self.releases_url()))?;
|
||||||
let release = newest_published(&releases)
|
let release = newest_published(&releases)
|
||||||
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
|
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
|
||||||
|
|
||||||
@@ -92,26 +168,27 @@ impl UpdateSource for GiteaSource {
|
|||||||
.assets
|
.assets
|
||||||
.iter()
|
.iter()
|
||||||
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
|
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
|
||||||
.map(|sums| http::get_string(&sums.browser_download_url))
|
.map(|sums| block_on(self.get_string(&sums.browser_download_url)))
|
||||||
.transpose()?
|
.transpose()?
|
||||||
.and_then(|sums| sha256_for(&sums, &asset.name));
|
.and_then(|sums| sha256_for(&sums, &asset.name));
|
||||||
|
|
||||||
Ok(Release {
|
let notes = release
|
||||||
version: parse_tag(&release.tag_name)?,
|
.body
|
||||||
notes: release
|
.clone()
|
||||||
.body
|
.filter(|body| !body.trim().is_empty())
|
||||||
.clone()
|
.or_else(|| release.name.clone());
|
||||||
.filter(|body| !body.trim().is_empty())
|
|
||||||
.or_else(|| release.name.clone()),
|
let mut update = Release::new(
|
||||||
asset: Asset {
|
parse_tag(&release.tag_name)?,
|
||||||
name: asset.name.clone(),
|
Asset::new(&asset.name, &asset.browser_download_url, asset.size),
|
||||||
url: asset.browser_download_url.clone(),
|
);
|
||||||
size: asset.size,
|
if let Some(notes) = notes {
|
||||||
},
|
update = update.with_notes(notes);
|
||||||
signature: None,
|
}
|
||||||
signature_url: None,
|
if let Some(sha256) = sha256 {
|
||||||
sha256,
|
update = update.with_sha256(sha256);
|
||||||
})
|
}
|
||||||
|
Ok(update)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,83 +216,9 @@ fn sha256_for(sums: &str, asset_name: &str) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A release as returned by the Gitea API.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct GiteaRelease {
|
|
||||||
tag_name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
body: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
draft: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
prerelease: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
assets: Vec<GiteaAsset>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A release asset as returned by the Gitea API.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct GiteaAsset {
|
|
||||||
name: String,
|
|
||||||
browser_download_url: String,
|
|
||||||
#[serde(default)]
|
|
||||||
size: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Blocking HTTP helpers for release metadata.
|
|
||||||
mod http {
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use gpui_updater_core::{Error, Result};
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use ureq::Agent;
|
|
||||||
use ureq::tls::{RootCerts, TlsConfig};
|
|
||||||
|
|
||||||
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
|
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
|
||||||
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
fn agent() -> Agent {
|
|
||||||
Agent::config_builder()
|
|
||||||
.user_agent(USER_AGENT)
|
|
||||||
.tls_config(
|
|
||||||
TlsConfig::builder()
|
|
||||||
.root_certs(RootCerts::PlatformVerifier)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.timeout_resolve(Some(CONNECT_TIMEOUT))
|
|
||||||
.timeout_connect(Some(CONNECT_TIMEOUT))
|
|
||||||
.timeout_recv_response(Some(RESPONSE_TIMEOUT))
|
|
||||||
.build()
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_bytes(url: &str) -> Result<Vec<u8>> {
|
|
||||||
let mut response = agent().get(url).call().map_err(|error| match error {
|
|
||||||
ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")),
|
|
||||||
other => Error::Http(other.to_string()),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
response
|
|
||||||
.body_mut()
|
|
||||||
.read_to_vec()
|
|
||||||
.map_err(|error| Error::Http(format!("GET {url} -> {error}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn get_json<T: DeserializeOwned>(url: &str) -> Result<T> {
|
|
||||||
serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn get_string(url: &str) -> Result<String> {
|
|
||||||
String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use gpui_updater_core::Version;
|
use gpui_updater_pre::Version;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -344,7 +347,13 @@ abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
|
|||||||
fn live_release_source_resolves_the_running_platform() {
|
fn live_release_source_resolves_the_running_platform() {
|
||||||
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
|
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
|
||||||
.expect("this platform should be supported");
|
.expect("this platform should be supported");
|
||||||
let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter);
|
let source = GiteaSource::new(
|
||||||
|
"https://git.reya.info/api/v1",
|
||||||
|
"reya",
|
||||||
|
"coop",
|
||||||
|
filter,
|
||||||
|
Arc::new(reqwest_client::ReqwestClient::new()),
|
||||||
|
);
|
||||||
|
|
||||||
let release = source
|
let release = source
|
||||||
.fetch_latest()
|
.fetch_latest()
|
||||||
|
|||||||
@@ -580,6 +580,7 @@ impl ChatRegistry {
|
|||||||
let room: Room = room.into().organize(&public_key);
|
let room: Room = room.into().organize(&public_key);
|
||||||
let room_id = room.id;
|
let room_id = room.id;
|
||||||
let entity = cx.new(|_| room);
|
let entity = cx.new(|_| room);
|
||||||
|
entity.update(cx, |room, cx| room.ensure_directory(cx));
|
||||||
|
|
||||||
self.room_index.insert(room_id, entity.clone());
|
self.room_index.insert(room_id, entity.clone());
|
||||||
self.rooms.insert(0, entity);
|
self.rooms.insert(0, entity);
|
||||||
@@ -593,10 +594,10 @@ impl ChatRegistry {
|
|||||||
let id = room.read(cx).id;
|
let id = room.read(cx).id;
|
||||||
|
|
||||||
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
||||||
let entity = room.to_owned();
|
room.update(cx, |room, cx| room.ensure_directory(cx));
|
||||||
e.insert(entity.clone());
|
e.insert(room.clone());
|
||||||
|
|
||||||
self.rooms.insert(0, entity);
|
self.rooms.insert(0, room.clone());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -670,6 +671,7 @@ impl ChatRegistry {
|
|||||||
} else {
|
} else {
|
||||||
let new_room_id = new_room.id;
|
let new_room_id = new_room.id;
|
||||||
let entity = cx.new(|_| new_room);
|
let entity = cx.new(|_| new_room);
|
||||||
|
entity.update(cx, |room, cx| room.ensure_directory(cx));
|
||||||
self.room_index.insert(new_room_id, entity.clone());
|
self.room_index.insert(new_room_id, entity.clone());
|
||||||
self.rooms.push(entity);
|
self.rooms.push(entity);
|
||||||
|
|
||||||
|
|||||||
+172
-2
@@ -1,13 +1,16 @@
|
|||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls};
|
||||||
use gpui::{SharedString, SharedUri};
|
use gpui::{SharedString, SharedUri};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::FileAttachment;
|
use state::FileAttachment;
|
||||||
|
|
||||||
pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||||
|
|
||||||
|
const SCHEME_WITH_COLON: &str = "nostr:";
|
||||||
|
const NEVENT_PREFIX: &str = "nevent1";
|
||||||
|
|
||||||
/// Rendered message.
|
/// Rendered message.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
@@ -22,6 +25,8 @@ pub struct Message {
|
|||||||
pub created_at: Timestamp,
|
pub created_at: Timestamp,
|
||||||
/// List of mentioned public keys in the message
|
/// List of mentioned public keys in the message
|
||||||
pub mentions: Vec<Mention>,
|
pub mentions: Vec<Mention>,
|
||||||
|
/// Custom emoji (NIP-30) declared by this message's `emoji` tags
|
||||||
|
pub emojis: Vec<(String, Url)>,
|
||||||
/// List of event of the message this message is a reply to
|
/// List of event of the message this message is a reply to
|
||||||
pub replies_to: Vec<EventId>,
|
pub replies_to: Vec<EventId>,
|
||||||
/// Encrypted file attachment
|
/// Encrypted file attachment
|
||||||
@@ -85,6 +90,7 @@ fn from_parts(
|
|||||||
let has_file = file.is_some();
|
let has_file = file.is_some();
|
||||||
|
|
||||||
let replies_to = extract_reply_ids(tags);
|
let replies_to = extract_reply_ids(tags);
|
||||||
|
let emojis = emojis_from_tags(tags);
|
||||||
|
|
||||||
// For file messages `.content` is the encrypted blob URL, not text or media
|
// For file messages `.content` is the encrypted blob URL, not text or media
|
||||||
let mentions = if has_file {
|
let mentions = if has_file {
|
||||||
@@ -106,6 +112,7 @@ fn from_parts(
|
|||||||
media,
|
media,
|
||||||
created_at,
|
created_at,
|
||||||
mentions,
|
mentions,
|
||||||
|
emojis,
|
||||||
replies_to,
|
replies_to,
|
||||||
file,
|
file,
|
||||||
}
|
}
|
||||||
@@ -144,7 +151,40 @@ impl Message {
|
|||||||
return format!("[File] {}", file.display_name()).into();
|
return format!("[File] {}", file.display_name()).into();
|
||||||
}
|
}
|
||||||
|
|
||||||
self.content.clone().into()
|
// Media-only messages carry their URLs outside `.content`
|
||||||
|
if self.content.is_empty() && !self.media.is_empty() {
|
||||||
|
return self
|
||||||
|
.media
|
||||||
|
.iter()
|
||||||
|
.map(|url| url.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let references = nevent_refs(&self.content);
|
||||||
|
if references.is_empty() {
|
||||||
|
return self.content.clone().into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut preview = String::with_capacity(self.content.len());
|
||||||
|
let mut last = 0;
|
||||||
|
|
||||||
|
for reference in references {
|
||||||
|
preview.push_str(&self.content[last..reference.range.start]);
|
||||||
|
preview.push_str(&reference.label);
|
||||||
|
last = reference.range.end;
|
||||||
|
}
|
||||||
|
preview.push_str(&self.content[last..]);
|
||||||
|
|
||||||
|
preview.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image URL of a `:shortcode:` reaction, if the shortcode is declared
|
||||||
|
pub fn reaction_image(content: &str, emojis: &[(String, Url)]) -> Option<Url> {
|
||||||
|
let shortcode = content.strip_prefix(':')?.strip_suffix(':')?;
|
||||||
|
let (_, url) = emojis.iter().find(|(name, _)| name == shortcode)?;
|
||||||
|
Some(url.clone())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +248,61 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `nevent` reference found in a content string.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NeventRef {
|
||||||
|
/// Range of the reference, including the optional `nostr:` scheme
|
||||||
|
pub range: Range<usize>,
|
||||||
|
/// Compact display label, like `https://njump.me/nevent1...d9sp`
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
|
||||||
|
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
|
||||||
|
let mut references = Vec::new();
|
||||||
|
|
||||||
|
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
|
||||||
|
// Only lowercase references are matched, matching NIP-19 encodings in practice
|
||||||
|
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
|
||||||
|
match_start - SCHEME_WITH_COLON.len()
|
||||||
|
} else {
|
||||||
|
match_start
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
|
||||||
|
if content[..start].ends_with(char::is_alphanumeric) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = match_start
|
||||||
|
+ content[match_start..]
|
||||||
|
.bytes()
|
||||||
|
.take_while(u8::is_ascii_alphanumeric)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
// The bech32 token excludes the optional `nostr:` scheme
|
||||||
|
let bech32 = &content[match_start..end];
|
||||||
|
if Nip19Event::from_bech32(bech32).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
references.push(NeventRef {
|
||||||
|
range: start..end,
|
||||||
|
label: nevent_label(bech32),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
references
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
|
||||||
|
fn nevent_label(bech32: &str) -> String {
|
||||||
|
match bech32.len() {
|
||||||
|
0..=4 => format!("https://njump.me/{bech32}"),
|
||||||
|
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Extracts all reply (ids) from the event tags.
|
/// Extracts all reply (ids) from the event tags.
|
||||||
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||||
let mut replies_to = vec![];
|
let mut replies_to = vec![];
|
||||||
@@ -226,3 +321,78 @@ fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
|||||||
|
|
||||||
replies_to
|
replies_to
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rumor(kind: Kind, content: &str, tags: Tags) -> UnsignedEvent {
|
||||||
|
let sender = Keys::generate();
|
||||||
|
|
||||||
|
let mut rumor = EventBuilder::new(kind, content)
|
||||||
|
.tags(tags)
|
||||||
|
.finalize_unsigned(sender.public_key());
|
||||||
|
rumor.ensure_id();
|
||||||
|
rumor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emoji_tag(shortcode: &str, url: &str) -> Tag {
|
||||||
|
Tag::parse(vec!["emoji", shortcode, url]).expect("valid emoji tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_parses_emoji_tags() {
|
||||||
|
let tags = Tags::from_list(vec![emoji_tag(
|
||||||
|
"soapbox",
|
||||||
|
"https://example.com/soapbox.png",
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let message = Message::from(&rumor(Kind::PrivateDirectMessage, "hi :soapbox:", tags));
|
||||||
|
|
||||||
|
assert_eq!(message.emojis.len(), 1);
|
||||||
|
assert_eq!(message.emojis[0].0, "soapbox");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reaction_image_matches_declared_shortcode() {
|
||||||
|
let emojis = vec![(
|
||||||
|
String::from("soapbox"),
|
||||||
|
Url::parse("https://example.com/soapbox.png").unwrap(),
|
||||||
|
)];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Message::reaction_image(":soapbox:", &emojis),
|
||||||
|
Some(Url::parse("https://example.com/soapbox.png").unwrap())
|
||||||
|
);
|
||||||
|
assert_eq!(Message::reaction_image("😂", &emojis), None);
|
||||||
|
assert_eq!(Message::reaction_image(":unknown:", &emojis), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_nevent() -> String {
|
||||||
|
Nip19Event::new(EventId::from_byte_array([7; 32]))
|
||||||
|
.to_bech32()
|
||||||
|
.expect("valid nevent")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_only_messages_preview_their_url() {
|
||||||
|
let url = "https://example.com/picture.png";
|
||||||
|
let message = Message::from(&rumor(Kind::PrivateDirectMessage, url, Tags::new()));
|
||||||
|
|
||||||
|
assert!(message.content.is_empty());
|
||||||
|
assert_eq!(message.preview().to_string(), url);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_compacts_nevent_references() {
|
||||||
|
let nevent = valid_nevent();
|
||||||
|
let message = Message::from(&rumor(
|
||||||
|
Kind::PrivateDirectMessage,
|
||||||
|
&format!("see nostr:{nevent}"),
|
||||||
|
Tags::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let label = format!("https://njump.me/nevent1...{}", &nevent[nevent.len() - 4..]);
|
||||||
|
assert_eq!(message.preview().to_string(), format!("see {label}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+42
-14
@@ -3,12 +3,12 @@ use std::hash::{Hash, Hasher};
|
|||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::EventExt;
|
use common::EventExt;
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Task};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nip4e::DeviceRegistry;
|
use nip4e::DeviceRegistry;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Directory, Person};
|
||||||
use settings::{RoomConfig, SignerKind};
|
use settings::{RoomConfig, SignerKind};
|
||||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||||
|
|
||||||
@@ -88,6 +88,9 @@ pub struct Room {
|
|||||||
|
|
||||||
/// Configuration
|
/// Configuration
|
||||||
config: RoomConfig,
|
config: RoomConfig,
|
||||||
|
|
||||||
|
/// Person directory, attached when the room is registered
|
||||||
|
directory: Option<Entity<Directory>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ord for Room {
|
impl Ord for Room {
|
||||||
@@ -136,6 +139,7 @@ impl From<&UnsignedEvent> for Room {
|
|||||||
members,
|
members,
|
||||||
kind: RoomKind::default(),
|
kind: RoomKind::default(),
|
||||||
config: RoomConfig::new(),
|
config: RoomConfig::new(),
|
||||||
|
directory: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +240,32 @@ impl Room {
|
|||||||
self.members.len() > 2
|
self.members.len() > 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The room's person directory, attached when the room is registered
|
||||||
|
pub fn directory(&self) -> Option<Entity<Directory>> {
|
||||||
|
self.directory.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach the person directory, tracking the room's members
|
||||||
|
pub(crate) fn ensure_directory(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.directory.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let directory = Directory::new(cx);
|
||||||
|
let members = self.members.clone();
|
||||||
|
directory.update(cx, |directory, cx| directory.track(members, cx));
|
||||||
|
self.directory = Some(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The person for a room member
|
||||||
|
fn member(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||||
|
let Some(directory) = self.directory.as_ref() else {
|
||||||
|
return Person::new(*public_key, Metadata::default());
|
||||||
|
};
|
||||||
|
|
||||||
|
directory.read(cx).person(public_key, cx)
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets the display name for the room
|
/// Gets the display name for the room
|
||||||
pub fn display_name(&self, cx: &App) -> SharedString {
|
pub fn display_name(&self, cx: &App) -> SharedString {
|
||||||
if let Some(value) = self.subject.clone() {
|
if let Some(value) = self.subject.clone() {
|
||||||
@@ -267,19 +297,16 @@ impl Room {
|
|||||||
///
|
///
|
||||||
/// Display member is always different from the current user.
|
/// Display member is always different from the current user.
|
||||||
pub fn display_member(&self, cx: &App) -> Person {
|
pub fn display_member(&self, cx: &App) -> Person {
|
||||||
let persons = PersonRegistry::global(cx);
|
self.member(&self.members[0], cx)
|
||||||
persons.read(cx).get(&self.members[0], cx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge the names of the first two members of the room.
|
/// Merge the names of the first two members of the room.
|
||||||
fn merged_name(&self, cx: &App) -> SharedString {
|
fn merged_name(&self, cx: &App) -> SharedString {
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
|
|
||||||
if self.is_group() {
|
if self.is_group() {
|
||||||
let profiles: Vec<Person> = self
|
let profiles: Vec<Person> = self
|
||||||
.members
|
.members
|
||||||
.iter()
|
.iter()
|
||||||
.map(|public_key| persons.read(cx).get(public_key, cx))
|
.map(|public_key| self.member(public_key, cx))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut name = profiles
|
let mut name = profiles
|
||||||
@@ -404,9 +431,13 @@ impl Room {
|
|||||||
let sender = nostr.read(cx).current_user()?;
|
let sender = nostr.read(cx).current_user()?;
|
||||||
|
|
||||||
// Construct a direct message rumor event
|
// Construct a direct message rumor event
|
||||||
|
//
|
||||||
// WARNING: never sign and send this event to relays
|
// WARNING: never sign and send this event to relays
|
||||||
|
let mut tags = self.conversation_tags(&replies, sender, cx);
|
||||||
|
tags.extend(self.member(&sender, cx).emoji_tags(&content));
|
||||||
|
|
||||||
let mut event = EventBuilder::new(kind, content)
|
let mut event = EventBuilder::new(kind, content)
|
||||||
.tags(self.conversation_tags(&replies, sender, cx))
|
.tags(tags)
|
||||||
.finalize_unsigned(sender);
|
.finalize_unsigned(sender);
|
||||||
|
|
||||||
// Ensure that the ID is set
|
// Ensure that the ID is set
|
||||||
@@ -443,8 +474,6 @@ impl Room {
|
|||||||
|
|
||||||
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
|
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
|
||||||
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
|
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
|
|
||||||
// Construct event's tags
|
// Construct event's tags
|
||||||
let mut tags = vec![];
|
let mut tags = vec![];
|
||||||
|
|
||||||
@@ -460,7 +489,7 @@ impl Room {
|
|||||||
|
|
||||||
// Add all receiver tags (no intermediate allocation)
|
// Add all receiver tags (no intermediate allocation)
|
||||||
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
||||||
let member = persons.read(cx).get(public_key, cx);
|
let member = self.member(public_key, cx);
|
||||||
tags.push(Tag::from(Nip01Tag::PublicKey {
|
tags.push(Tag::from(Nip01Tag::PublicKey {
|
||||||
public_key: member.public_key(),
|
public_key: member.public_key(),
|
||||||
relay_hint: member.messaging_relay_hint(),
|
relay_hint: member.messaging_relay_hint(),
|
||||||
@@ -483,15 +512,14 @@ impl Room {
|
|||||||
let current_user = nostr.read(cx).current_user()?;
|
let current_user = nostr.read(cx).current_user()?;
|
||||||
|
|
||||||
// Get sender's profile
|
// Get sender's profile
|
||||||
let persons = PersonRegistry::global(cx);
|
let sender = self.member(¤t_user, cx);
|
||||||
let sender = persons.read(cx).get(¤t_user, cx);
|
|
||||||
|
|
||||||
// Get all members (excluding sender)
|
// Get all members (excluding sender)
|
||||||
let receivers: Vec<Person> = self
|
let receivers: Vec<Person> = self
|
||||||
.members
|
.members
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|public_key| public_key != &&sender.public_key())
|
.filter(|public_key| public_key != &&sender.public_key())
|
||||||
.map(|member| persons.read(cx).get(member, cx))
|
.map(|member| self.member(member, cx))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(cx.background_spawn(async move {
|
Some(cx.background_spawn(async move {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use settings::SignerKind;
|
|||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
#[action(namespace = chat, no_json)]
|
#[action(namespace = chat, no_json)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
Insert(&'static str),
|
Insert(String),
|
||||||
ChangeSubject(String),
|
ChangeSubject(String),
|
||||||
ChangeSigner(SignerKind),
|
ChangeSigner(SignerKind),
|
||||||
ToggleBackup,
|
ToggleBackup,
|
||||||
|
|||||||
+109
-170
@@ -5,19 +5,19 @@ use std::sync::{Arc, LazyLock, RwLock};
|
|||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport};
|
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport};
|
||||||
use common::TimestampExt;
|
use common::{TimestampExt, emojis_from_tags};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, ImageSource, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
MouseButton, ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
||||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
||||||
retain_all, svg, white,
|
retain_all, svg, white,
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Directory, Person};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use settings::{AppSettings, SignerKind};
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -30,7 +30,9 @@ use ui::button::{Button, ButtonVariants};
|
|||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||||
use ui::menu::DropdownMenu;
|
use ui::menu::DropdownMenu;
|
||||||
use ui::message::{MessageRow, WelcomeMessage};
|
use ui::message::{
|
||||||
|
MessageMedia, MessageRow, ReactionBar, ReplyPreview, WelcomeMessage, group_reactions, opens_run,
|
||||||
|
};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::tooltip::Tooltip;
|
use ui::tooltip::Tooltip;
|
||||||
@@ -51,6 +53,10 @@ const PRIVATE_NOTICE: &str =
|
|||||||
static EMOJI_RE: LazyLock<Regex> =
|
static EMOJI_RE: LazyLock<Regex> =
|
||||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
||||||
|
|
||||||
|
/// Regex matching a single custom emoji reference, like `:soapbox:`
|
||||||
|
static CUSTOM_EMOJI_RE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^:[A-Za-z0-9_-]+:$").unwrap());
|
||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
mod file;
|
mod file;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
@@ -67,6 +73,9 @@ pub struct ChatPanel {
|
|||||||
/// Chat Room
|
/// Chat Room
|
||||||
room: WeakEntity<Room>,
|
room: WeakEntity<Room>,
|
||||||
|
|
||||||
|
/// Person directory shared with the room
|
||||||
|
directory: Entity<Directory>,
|
||||||
|
|
||||||
/// Message list state
|
/// Message list state
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
|
|
||||||
@@ -77,7 +86,7 @@ pub struct ChatPanel {
|
|||||||
message_index: HashMap<EventId, usize>,
|
message_index: HashMap<EventId, usize>,
|
||||||
|
|
||||||
/// All reactions
|
/// All reactions
|
||||||
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
|
reactions: BTreeMap<EventId, Vec<(SharedString, Option<SharedUri>, PublicKey)>>,
|
||||||
|
|
||||||
/// Mapping message ids to their rendered texts
|
/// Mapping message ids to their rendered texts
|
||||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||||
@@ -127,6 +136,13 @@ impl ChatPanel {
|
|||||||
let messages = Vec::new();
|
let messages = Vec::new();
|
||||||
let list_state = ListState::new(messages.len() + 1, ListAlignment::Bottom, px(1024.));
|
let list_state = ListState::new(messages.len() + 1, ListAlignment::Bottom, px(1024.));
|
||||||
|
|
||||||
|
// Share the room's person directory
|
||||||
|
let directory = room
|
||||||
|
.read_with(cx, |room, _cx| room.directory())
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| Directory::new(cx));
|
||||||
|
|
||||||
// Get room id and name
|
// Get room id and name
|
||||||
let (id, name) = room
|
let (id, name) = room
|
||||||
.read_with(cx, |this, _cx| {
|
.read_with(cx, |this, _cx| {
|
||||||
@@ -188,6 +204,7 @@ impl ChatPanel {
|
|||||||
message_index: HashMap::new(),
|
message_index: HashMap::new(),
|
||||||
reactions: BTreeMap::new(),
|
reactions: BTreeMap::new(),
|
||||||
room,
|
room,
|
||||||
|
directory,
|
||||||
list_state,
|
list_state,
|
||||||
input,
|
input,
|
||||||
subject_input,
|
subject_input,
|
||||||
@@ -329,7 +346,7 @@ impl ChatPanel {
|
|||||||
// If replying to exactly one message with only a valid emoji,
|
// If replying to exactly one message with only a valid emoji,
|
||||||
// send as a reaction instead of a text message
|
// send as a reaction instead of a text message
|
||||||
if replies.len() == 1
|
if replies.len() == 1
|
||||||
&& EMOJI_RE.is_match(&content)
|
&& (EMOJI_RE.is_match(&content) || CUSTOM_EMOJI_RE.is_match(&content))
|
||||||
&& self.attachments.read(cx).is_empty()
|
&& self.attachments.read(cx).is_empty()
|
||||||
&& files.is_empty()
|
&& files.is_empty()
|
||||||
{
|
{
|
||||||
@@ -534,11 +551,16 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let emojis = emojis_from_tags(&event.tags);
|
||||||
|
let image = Message::reaction_image(&event.content, &emojis)
|
||||||
|
.map(|url| SharedUri::from(url.to_string()));
|
||||||
|
|
||||||
for id in event.tags.event_ids() {
|
for id in event.tags.event_ids() {
|
||||||
self.reactions
|
self.reactions.entry(id).or_default().push((
|
||||||
.entry(id)
|
SharedString::from(&event.content),
|
||||||
.or_default()
|
image.clone(),
|
||||||
.push((SharedString::from(&event.content), event.pubkey));
|
event.pubkey,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -562,7 +584,7 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reaction by its target ID (returns reference, no allocation)
|
/// Get a reaction by its target ID (returns reference, no allocation)
|
||||||
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
|
fn reaction(&self, id: &EventId) -> &[(SharedString, Option<SharedUri>, PublicKey)] {
|
||||||
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
|
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,8 +843,7 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
|
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||||
let persons = PersonRegistry::global(cx);
|
self.directory.read(cx).person(public_key, cx)
|
||||||
persons.read(cx).get(public_key, cx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -1037,29 +1058,17 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_group_start(&self, ix: usize) -> bool {
|
fn is_group_start(&self, ix: usize) -> bool {
|
||||||
// 5 minutes
|
|
||||||
const GROUP_WINDOW: u64 = 300;
|
|
||||||
|
|
||||||
if ix == 0 {
|
if ix == 0 {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(previous) = self.messages.get(ix - 1)
|
match (self.messages.get(ix - 1), self.messages.get(ix)) {
|
||||||
&& let Some(current) = self.messages.get(ix)
|
(Some(previous), Some(current)) => opens_run(
|
||||||
{
|
Some((previous.author, previous.created_at.as_secs())),
|
||||||
if current.author != previous.author {
|
(current.author, current.created_at.as_secs()),
|
||||||
return true;
|
),
|
||||||
}
|
_ => false,
|
||||||
|
|
||||||
let gap = current
|
|
||||||
.created_at
|
|
||||||
.as_secs()
|
|
||||||
.saturating_sub(previous.created_at.as_secs());
|
|
||||||
|
|
||||||
return gap > GROUP_WINDOW;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_message(
|
fn render_message(
|
||||||
@@ -1086,13 +1095,19 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(message) = self.messages.get(index) {
|
if let Some(message) = self.messages.get(index) {
|
||||||
let persons = PersonRegistry::global(cx);
|
let directory = self.directory.clone();
|
||||||
let show_author = self.is_group_start(index);
|
let show_author = self.is_group_start(index);
|
||||||
let text = self
|
let text = self
|
||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(message.id)
|
.entry(message.id)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
text::rendered_text(&message.content, &message.mentions, &persons, cx)
|
text::rendered_text(
|
||||||
|
&message.content,
|
||||||
|
&message.mentions,
|
||||||
|
&message.emojis,
|
||||||
|
&directory,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.element(ix.into(), window, cx);
|
.element(ix.into(), window, cx);
|
||||||
|
|
||||||
@@ -1119,8 +1134,13 @@ impl ChatPanel {
|
|||||||
let has_reactions = self.has_reaction(&id);
|
let has_reactions = self.has_reaction(&id);
|
||||||
let has_reports = self.has_reports(&id);
|
let has_reports = self.has_reports(&id);
|
||||||
|
|
||||||
// Hide avatar setting
|
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
|
let reactions = ReactionBar::new(group_reactions(
|
||||||
|
self.reaction(&id).iter().cloned(),
|
||||||
|
nostr.read(cx).current_user(),
|
||||||
|
));
|
||||||
|
|
||||||
MessageRow::new(ix)
|
MessageRow::new(ix)
|
||||||
.show_author(show_author)
|
.show_author(show_author)
|
||||||
@@ -1146,13 +1166,11 @@ impl ChatPanel {
|
|||||||
this.children(self.render_message_replies(replies, cx))
|
this.children(self.render_message_replies(replies, cx))
|
||||||
})
|
})
|
||||||
.when(message.file.is_none(), |this| this.child(rendered_text))
|
.when(message.file.is_none(), |this| this.child(rendered_text))
|
||||||
.child(self.render_media(&message.media, cx))
|
.child(MessageMedia::new(&message.media))
|
||||||
.when_some(message.file.as_ref(), |this, file| {
|
.when_some(message.file.as_ref(), |this, file| {
|
||||||
this.child(self.render_message_file(&id, file, cx))
|
this.child(self.render_message_file(&id, file, cx))
|
||||||
})
|
})
|
||||||
.when(has_reactions, |this| {
|
.when(has_reactions, |this| this.child(reactions))
|
||||||
this.child(self.render_reactions(&id, cx))
|
|
||||||
})
|
|
||||||
.overlay(
|
.overlay(
|
||||||
div()
|
div()
|
||||||
.group_hover("", |this| this.bg(cx.theme().element_active))
|
.group_hover("", |this| this.bg(cx.theme().element_active))
|
||||||
@@ -1176,55 +1194,6 @@ impl ChatPanel {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_media(&self, media: &[SharedUri], cx: &Context<Self>) -> impl IntoElement {
|
|
||||||
// No media: return empty div
|
|
||||||
if media.is_empty() {
|
|
||||||
return div();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Single media item: render full-width image
|
|
||||||
if media.len() == 1 {
|
|
||||||
return div().child(
|
|
||||||
img(media[0].clone())
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border_faint)
|
|
||||||
.h(px(250.))
|
|
||||||
.object_fit(ObjectFit::Cover)
|
|
||||||
.rounded(px(theme::bubble_radius())),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple media items: render in a row
|
|
||||||
div()
|
|
||||||
.w_full()
|
|
||||||
.flex_1()
|
|
||||||
.flex()
|
|
||||||
.flex_row()
|
|
||||||
.flex_wrap()
|
|
||||||
.gap(px(theme::SPACE))
|
|
||||||
.children({
|
|
||||||
let mut items = vec![];
|
|
||||||
|
|
||||||
for (ix, item) in media.iter().enumerate() {
|
|
||||||
items.push(
|
|
||||||
div()
|
|
||||||
.id(format!("media-{ix}"))
|
|
||||||
.flex_grow_0()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.child(
|
|
||||||
img(item.clone())
|
|
||||||
.h_32()
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border_faint)
|
|
||||||
.rounded(px(theme::bubble_radius())),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
items
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_message_replies(
|
fn render_message_replies(
|
||||||
&self,
|
&self,
|
||||||
replies: &[EventId],
|
replies: &[EventId],
|
||||||
@@ -1239,73 +1208,21 @@ impl ChatPanel {
|
|||||||
let author = self.profile(&message.author, cx);
|
let author = self.profile(&message.author, cx);
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
div()
|
ReplyPreview::new(ix, author.name(), message.preview()).on_click({
|
||||||
.id(ix)
|
let id = *id;
|
||||||
.w_full()
|
cx.listener(move |this, _event, _window, _cx| {
|
||||||
.px_2()
|
this.scroll_to(&id);
|
||||||
.border_l_2()
|
})
|
||||||
.border_color(cx.theme().element_active)
|
}),
|
||||||
.text_style(TextStyle::Callout)
|
|
||||||
.child(div().font_semibold().child(author.name()))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.w_full()
|
|
||||||
.text_ellipsis()
|
|
||||||
.line_clamp(1)
|
|
||||||
.child(message.preview()),
|
|
||||||
)
|
|
||||||
.hover(|this| this.bg(cx.theme().surface_raised))
|
|
||||||
.on_click({
|
|
||||||
let id = *id;
|
|
||||||
cx.listener(move |this, _event, _window, _cx| {
|
|
||||||
this.scroll_to(&id);
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_reactions(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
fn render_sent_reports(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||||
let current_user = NostrRegistry::global(cx).read(cx).current_user();
|
|
||||||
let reactions = self.reaction(id);
|
|
||||||
|
|
||||||
// Group reactions by emoji and collect authors for each
|
|
||||||
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
|
|
||||||
for (emoji, author) in reactions {
|
|
||||||
grouped.entry(emoji.clone()).or_default().push(*author);
|
|
||||||
}
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.mt_2()
|
|
||||||
.gap_1()
|
|
||||||
.children(grouped.into_iter().map(|(emoji, authors)| {
|
|
||||||
let count = authors.len();
|
|
||||||
let has_reacted = current_user
|
|
||||||
.map(|pk| authors.contains(&pk))
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.gap(px(theme::SPACE))
|
|
||||||
.py_0p5()
|
|
||||||
.px_1()
|
|
||||||
.rounded(px(theme::control_radius()))
|
|
||||||
.text_style(TextStyle::Caption)
|
|
||||||
.border_1()
|
|
||||||
.when(has_reacted, |this| {
|
|
||||||
this.text_color(cx.theme().text)
|
|
||||||
.bg(cx.theme().surface_raised)
|
|
||||||
.border_color(cx.theme().element_active)
|
|
||||||
})
|
|
||||||
.when(!has_reacted, |this| this.border_color(cx.theme().border))
|
|
||||||
.child(emoji)
|
|
||||||
.child(SharedString::from(count.to_string()))
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
|
||||||
let reports = self.sent_reports(id);
|
let reports = self.sent_reports(id);
|
||||||
|
let panel = cx.weak_entity();
|
||||||
|
|
||||||
let pending = reports.as_ref().is_some_and(|reports| reports.is_empty());
|
let pending = reports.as_ref().is_some_and(|reports| reports.is_empty());
|
||||||
|
|
||||||
@@ -1335,28 +1252,29 @@ impl ChatPanel {
|
|||||||
this.when(!pending, |this| {
|
this.when(!pending, |this| {
|
||||||
this.on_click(move |_e, window, cx| {
|
this.on_click(move |_e, window, cx| {
|
||||||
let reports = reports.clone();
|
let reports = reports.clone();
|
||||||
|
let panel = panel.clone();
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, cx| {
|
window.open_dialog(cx, move |this, _window, cx| {
|
||||||
|
let items = panel
|
||||||
|
.update(cx, |panel, cx| {
|
||||||
|
reports
|
||||||
|
.iter()
|
||||||
|
.map(|report| panel.render_report(report, cx))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
this.title(SharedString::from("Sent Reports"))
|
this.title(SharedString::from("Sent Reports"))
|
||||||
.show_close(true)
|
.show_close(true)
|
||||||
.child(v_flex().gap_4().children({
|
.child(v_flex().gap_4().children(items))
|
||||||
let mut items = Vec::with_capacity(reports.len());
|
|
||||||
|
|
||||||
for report in reports.iter() {
|
|
||||||
items.push(Self::render_report(report, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
items
|
|
||||||
}))
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_report(report: &SendReport, cx: &App) -> impl IntoElement {
|
fn render_report(&self, report: &SendReport, cx: &App) -> AnyElement {
|
||||||
let persons = PersonRegistry::global(cx);
|
let profile = self.directory.read(cx).person(&report.receiver, cx);
|
||||||
let profile = persons.read(cx).get(&report.receiver, cx);
|
|
||||||
let name = profile.name();
|
let name = profile.name();
|
||||||
let avatar = profile.avatar();
|
let avatar = profile.avatar();
|
||||||
|
|
||||||
@@ -1457,6 +1375,7 @@ impl ChatPanel {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_actions(
|
fn render_actions(
|
||||||
@@ -1746,8 +1665,7 @@ impl ChatPanel {
|
|||||||
|
|
||||||
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||||
if let Some(text) = self.message(id) {
|
if let Some(text) = self.message(id) {
|
||||||
let persons = PersonRegistry::global(cx);
|
let profile = self.directory.read(cx).person(&text.author, cx);
|
||||||
let profile = persons.read(cx).get(&text.author, cx);
|
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -1755,9 +1673,7 @@ impl ChatPanel {
|
|||||||
.border_l_2()
|
.border_l_2()
|
||||||
.border_color(cx.theme().element_active)
|
.border_color(cx.theme().element_active)
|
||||||
.child(
|
.child(
|
||||||
div()
|
h_flex()
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -1836,7 +1752,7 @@ impl ChatPanel {
|
|||||||
auto,
|
auto,
|
||||||
)
|
)
|
||||||
.menu_with_check_and_disabled(
|
.menu_with_check_and_disabled(
|
||||||
"Decoupled Encryption Key",
|
"Encryption Key",
|
||||||
encryption,
|
encryption,
|
||||||
Box::new(Command::ChangeSigner(SignerKind::Encryption)),
|
Box::new(Command::ChangeSigner(SignerKind::Encryption)),
|
||||||
encryption,
|
encryption,
|
||||||
@@ -1853,15 +1769,34 @@ impl ChatPanel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_emoji_menu(&self, _window: &Window, _cx: &Context<Self>) -> impl IntoElement {
|
fn render_emoji_menu(&self, _window: &Window, cx: &Context<Self>) -> impl IntoElement {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let current_user = nostr.read(cx).current_user();
|
||||||
|
|
||||||
|
let emojis = current_user
|
||||||
|
.map(|current_user| {
|
||||||
|
self.directory
|
||||||
|
.read(cx)
|
||||||
|
.person(¤t_user, cx)
|
||||||
|
.emojis()
|
||||||
|
.to_vec()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
Button::new("emoji")
|
Button::new("emoji")
|
||||||
.icon(IconName::Emoji)
|
.icon(IconName::Emoji)
|
||||||
.ghost()
|
.ghost()
|
||||||
.large()
|
.large()
|
||||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
||||||
let menu = this.horizontal();
|
let menu = REACTION_EMOJIS
|
||||||
REACTION_EMOJIS.iter().fold(menu, |this, emoji| {
|
.iter()
|
||||||
this.menu(*emoji, Box::new(Command::Insert(emoji)))
|
.fold(this.horizontal(), |this, emoji| {
|
||||||
|
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
||||||
|
});
|
||||||
|
|
||||||
|
emojis.iter().fold(menu, |this, (shortcode, _)| {
|
||||||
|
let label = format!(":{shortcode}:");
|
||||||
|
this.menu(label.clone(), Box::new(Command::Insert(label)))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1911,6 +1846,10 @@ impl Panel for ChatPanel {
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn background(&self, cx: &App) -> Option<ImageSource> {
|
||||||
|
AppSettings::panel_background_source(cx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for ChatPanel {}
|
impl EventEmitter<PanelEvent> for ChatPanel {}
|
||||||
|
|||||||
@@ -1,25 +1,63 @@
|
|||||||
use chat::Mention;
|
use chat::{Mention, nevent_refs};
|
||||||
use gpui::{App, Entity};
|
use gpui::{App, Entity};
|
||||||
use person::PersonRegistry;
|
use nostr_sdk::prelude::Url;
|
||||||
|
use person::Directory;
|
||||||
use ui::markdown::InlineReplacement;
|
use ui::markdown::InlineReplacement;
|
||||||
pub use ui::markdown::RenderedText;
|
pub use ui::markdown::RenderedText;
|
||||||
|
|
||||||
/// Render message `content`, replacing mentions with their display names.
|
|
||||||
pub fn rendered_text(
|
pub fn rendered_text(
|
||||||
content: &str,
|
content: &str,
|
||||||
mentions: &[Mention],
|
mentions: &[Mention],
|
||||||
persons: &Entity<PersonRegistry>,
|
emojis: &[(String, Url)],
|
||||||
|
persons: &Entity<Directory>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
let replacements = mentions
|
let mut replacements = mentions
|
||||||
.iter()
|
.iter()
|
||||||
.map(|mention| {
|
.map(|mention| {
|
||||||
InlineReplacement::new(
|
InlineReplacement::new(
|
||||||
mention.range.clone(),
|
mention.range.clone(),
|
||||||
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name()),
|
format!(
|
||||||
|
"@{}",
|
||||||
|
persons.read(cx).person(&mention.public_key, cx).name()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
replacements.extend(emoji_replacements(content, emojis));
|
||||||
|
replacements.extend(
|
||||||
|
nevent_refs(content)
|
||||||
|
.into_iter()
|
||||||
|
.map(|reference| InlineReplacement::new(reference.range, reference.label)),
|
||||||
|
);
|
||||||
|
replacements.sort_by_key(|replacement| replacement.range.start);
|
||||||
|
|
||||||
RenderedText::new(content, &replacements)
|
RenderedText::new(content, &replacements)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emoji_replacements(content: &str, emojis: &[(String, Url)]) -> Vec<InlineReplacement> {
|
||||||
|
let mut replacements = Vec::new();
|
||||||
|
|
||||||
|
for (shortcode, url) in emojis {
|
||||||
|
let pattern = format!(":{shortcode}:");
|
||||||
|
for (start, _) in content.match_indices(&pattern) {
|
||||||
|
let range = start..start + pattern.len();
|
||||||
|
|
||||||
|
// Skip matches glued to word characters, like the `30` in `12:30:45`
|
||||||
|
if content[..start].ends_with(char::is_alphanumeric)
|
||||||
|
|| content[range.end..].starts_with(char::is_alphanumeric)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
replacements.push(InlineReplacement::image(
|
||||||
|
range,
|
||||||
|
shortcode.clone(),
|
||||||
|
url.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replacements
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,3 +51,53 @@ impl EventExt for UnsignedEvent {
|
|||||||
public_keys.into_iter().unique().sorted().collect()
|
public_keys.into_iter().unique().sorted().collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins
|
||||||
|
pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> {
|
||||||
|
let mut emojis: Vec<(String, Url)> = Vec::new();
|
||||||
|
|
||||||
|
for tag in tags.iter() {
|
||||||
|
let Ok(Nip30Tag::Emoji {
|
||||||
|
shortcode,
|
||||||
|
image_url,
|
||||||
|
..
|
||||||
|
}) = Nip30Tag::try_from(tag)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !emojis.iter().any(|(existing, _)| *existing == shortcode) {
|
||||||
|
emojis.push((shortcode, image_url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emojis
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emojis_from_tags_parses_and_deduplicates() {
|
||||||
|
let tags = Tags::from_list(vec![
|
||||||
|
Tag::parse(vec!["emoji", "soapbox", "https://example.com/soapbox.png"]).unwrap(),
|
||||||
|
Tag::parse(vec!["emoji", "soapbox", "https://example.com/other.png"]).unwrap(),
|
||||||
|
Tag::parse(vec![
|
||||||
|
"emoji",
|
||||||
|
"bad shortcode",
|
||||||
|
"https://example.com/bad.png",
|
||||||
|
])
|
||||||
|
.unwrap(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let emojis = emojis_from_tags(&tags);
|
||||||
|
|
||||||
|
assert_eq!(emojis.len(), 1);
|
||||||
|
assert_eq!(emojis[0].0, "soapbox");
|
||||||
|
assert_eq!(
|
||||||
|
emojis[0].1,
|
||||||
|
Url::parse("https://example.com/soapbox.png").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "concord"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
publish.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
nostr.workspace = true
|
|
||||||
nostr-sdk.workspace = true
|
|
||||||
|
|
||||||
hkdf.workspace = true
|
|
||||||
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]
|
|
||||||
smol.workspace = true
|
|
||||||
@@ -1,723 +0,0 @@
|
|||||||
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::{
|
|
||||||
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent,
|
|
||||||
FinalizeEventAsync, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::derive::GroupKey;
|
|
||||||
use crate::{ChannelId, Epoch};
|
|
||||||
|
|
||||||
pub const KIND_WRAP: u16 = 1059;
|
|
||||||
pub const KIND_WRAP_EPHEMERAL: u16 = 21059;
|
|
||||||
pub const KIND_SEAL_ENCRYPTED: u16 = 20013;
|
|
||||||
pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
|
|
||||||
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
|
|
||||||
|
|
||||||
const TAG_MS: &str = "ms";
|
|
||||||
pub(crate) const TAG_CHANNEL: &str = "channel";
|
|
||||||
pub(crate) const TAG_EPOCH: &str = "epoch";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum SealForm {
|
|
||||||
Encrypted,
|
|
||||||
Plaintext,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SealForm {
|
|
||||||
pub fn kind(self) -> u16 {
|
|
||||||
match self {
|
|
||||||
SealForm::Encrypted => KIND_SEAL_ENCRYPTED,
|
|
||||||
SealForm::Plaintext => KIND_SEAL_PLAINTEXT,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_kind(kind: u16) -> Option<Self> {
|
|
||||||
match kind {
|
|
||||||
KIND_SEAL_ENCRYPTED => Some(SealForm::Encrypted),
|
|
||||||
KIND_SEAL_PLAINTEXT => Some(SealForm::Plaintext),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum StreamError {
|
|
||||||
Sign(String),
|
|
||||||
Encrypt(String),
|
|
||||||
Decrypt(String),
|
|
||||||
Parse(String),
|
|
||||||
Oversize(usize),
|
|
||||||
BadWrapKind(u16),
|
|
||||||
WrongStream,
|
|
||||||
BadWrapSignature,
|
|
||||||
BadSealKind(u16),
|
|
||||||
BadSealSignature,
|
|
||||||
AuthorMismatch,
|
|
||||||
BadRumorId,
|
|
||||||
BadMs,
|
|
||||||
ChannelMismatch,
|
|
||||||
EpochMismatch,
|
|
||||||
MissingTag(&'static str),
|
|
||||||
DuplicateTag(&'static str),
|
|
||||||
NotRewrappable,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for StreamError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
StreamError::Sign(error) => write!(f, "sign: {error}"),
|
|
||||||
StreamError::Encrypt(error) => write!(f, "encrypt: {error}"),
|
|
||||||
StreamError::Decrypt(error) => write!(f, "decrypt: {error}"),
|
|
||||||
StreamError::Parse(error) => write!(f, "parse: {error}"),
|
|
||||||
StreamError::Oversize(len) => write!(f, "plaintext {len} bytes exceeds NIP-44 cap"),
|
|
||||||
StreamError::BadWrapKind(kind) => write!(f, "not a wrap kind: {kind}"),
|
|
||||||
StreamError::WrongStream => write!(f, "wrap author is not this stream"),
|
|
||||||
StreamError::BadWrapSignature => write!(f, "restricted wrap signature invalid"),
|
|
||||||
StreamError::BadSealKind(kind) => write!(f, "not a seal kind: {kind}"),
|
|
||||||
StreamError::BadSealSignature => write!(f, "seal signature invalid"),
|
|
||||||
StreamError::AuthorMismatch => write!(f, "rumor pubkey != seal pubkey"),
|
|
||||||
StreamError::BadRumorId => write!(f, "rumor id != computed hash"),
|
|
||||||
StreamError::BadMs => write!(f, "ms is not a canonical decimal in 0..=999"),
|
|
||||||
StreamError::ChannelMismatch => write!(f, "channel binding mismatch"),
|
|
||||||
StreamError::EpochMismatch => write!(f, "epoch binding mismatch"),
|
|
||||||
StreamError::MissingTag(name) => write!(f, "missing rumor tag: {name}"),
|
|
||||||
StreamError::DuplicateTag(name) => write!(f, "duplicate rumor tag: {name}"),
|
|
||||||
StreamError::NotRewrappable => write!(f, "only plaintext seals survive re-wrapping"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for StreamError {}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OpenedStream {
|
|
||||||
pub rumor_id: EventId,
|
|
||||||
pub author: PublicKey,
|
|
||||||
pub seal_form: SealForm,
|
|
||||||
pub seal: Event,
|
|
||||||
pub wrapper_id: EventId,
|
|
||||||
pub at_ms: u64,
|
|
||||||
pub rumor: UnsignedEvent,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn split_ms(at_ms: u64) -> (u64, u16) {
|
|
||||||
(at_ms / 1000, (at_ms % 1000) as u16)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a rumor carrying a full epoch-ms time: seconds in `created_at`, the remainder as `["ms", 0..=999]`.
|
|
||||||
pub fn build_rumor_ms(
|
|
||||||
kind: u16,
|
|
||||||
author: PublicKey,
|
|
||||||
content: &str,
|
|
||||||
mut tags: Vec<Tag>,
|
|
||||||
at_ms: u64,
|
|
||||||
) -> UnsignedEvent {
|
|
||||||
let (seconds, offset) = split_ms(at_ms);
|
|
||||||
tags.push(Tag::custom(TAG_MS, [offset.to_string()]));
|
|
||||||
build_rumor_secs(kind, author, content, tags, seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a rumor with a plain seconds timestamp and no `ms` tag.
|
|
||||||
pub fn build_rumor_secs(
|
|
||||||
kind: u16,
|
|
||||||
author: PublicKey,
|
|
||||||
content: &str,
|
|
||||||
tags: Vec<Tag>,
|
|
||||||
at_secs: u64,
|
|
||||||
) -> UnsignedEvent {
|
|
||||||
let mut rumor = UnsignedEvent::new(
|
|
||||||
author,
|
|
||||||
Timestamp::from_secs(at_secs),
|
|
||||||
Kind::Custom(kind),
|
|
||||||
tags,
|
|
||||||
content,
|
|
||||||
);
|
|
||||||
rumor.ensure_id();
|
|
||||||
rumor
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
|
|
||||||
let seconds = rumor.created_at.as_secs().saturating_mul(1000);
|
|
||||||
let mut tag: Option<Option<String>> = None;
|
|
||||||
|
|
||||||
for candidate in rumor.tags.iter() {
|
|
||||||
let fields = candidate.as_slice();
|
|
||||||
if fields.first().map(String::as_str) == Some(TAG_MS) {
|
|
||||||
tag = Some(fields.get(1).cloned());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(raw) = tag else {
|
|
||||||
return Ok(seconds);
|
|
||||||
};
|
|
||||||
let raw = raw.ok_or(StreamError::BadMs)?;
|
|
||||||
|
|
||||||
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
|
||||||
return Err(StreamError::BadMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
let offset: u64 = raw.parse().map_err(|_| StreamError::BadMs)?;
|
|
||||||
|
|
||||||
if offset > 999 || (raw.len() > 1 && raw.starts_with('0')) {
|
|
||||||
return Err(StreamError::BadMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(seconds.saturating_add(offset))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn seal_content(
|
|
||||||
rumor: &UnsignedEvent,
|
|
||||||
form: SealForm,
|
|
||||||
group: &GroupKey,
|
|
||||||
) -> Result<String, StreamError> {
|
|
||||||
let json = rumor.as_json();
|
|
||||||
check_plaintext_cap(json.len())?;
|
|
||||||
|
|
||||||
match form {
|
|
||||||
SealForm::Plaintext => Ok(json),
|
|
||||||
SealForm::Encrypted => seal_bytes(group.conversation(), json.as_bytes()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn seal_bytes(conversation: &ConversationKey, plaintext: &[u8]) -> Result<String, StreamError> {
|
|
||||||
check_plaintext_cap(plaintext.len())?;
|
|
||||||
Ok(BASE64.encode(&encrypt(conversation, plaintext)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u8>, StreamError> {
|
|
||||||
let payload = BASE64
|
|
||||||
.decode(content.as_bytes())
|
|
||||||
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
|
|
||||||
|
|
||||||
decrypt_to_bytes(conversation, &payload)
|
|
||||||
.map_err(|error| StreamError::Decrypt(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A member's own document (the Community List, the Invite List): NIP-44 to self.
|
|
||||||
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 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 async fn build_seal<S>(
|
|
||||||
rumor: &UnsignedEvent,
|
|
||||||
form: SealForm,
|
|
||||||
group: &GroupKey,
|
|
||||||
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_async(author)
|
|
||||||
.await
|
|
||||||
.map_err(|error| StreamError::Sign(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wrap_seal(
|
|
||||||
seal: &Event,
|
|
||||||
group: &GroupKey,
|
|
||||||
wrap_kind: u16,
|
|
||||||
at: Timestamp,
|
|
||||||
extra: &[Tag],
|
|
||||||
) -> Result<(Event, Keys), StreamError> {
|
|
||||||
wrap_seal_with(
|
|
||||||
seal,
|
|
||||||
group.conversation(),
|
|
||||||
group.keys(),
|
|
||||||
wrap_kind,
|
|
||||||
at,
|
|
||||||
extra,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wrap_seal_with(
|
|
||||||
seal: &Event,
|
|
||||||
conversation: &ConversationKey,
|
|
||||||
signer: &Keys,
|
|
||||||
wrap_kind: u16,
|
|
||||||
at: Timestamp,
|
|
||||||
extra: &[Tag],
|
|
||||||
) -> Result<(Event, Keys), StreamError> {
|
|
||||||
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
|
|
||||||
return Err(StreamError::BadWrapKind(wrap_kind));
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = seal.as_json();
|
|
||||||
check_plaintext_cap(json.len())?;
|
|
||||||
|
|
||||||
let content = BASE64.encode(&encrypt(conversation, json.as_bytes())?);
|
|
||||||
let ephemeral = Keys::generate();
|
|
||||||
|
|
||||||
let mut tags = vec![Tag::public_key(ephemeral.public_key())];
|
|
||||||
tags.extend_from_slice(extra);
|
|
||||||
|
|
||||||
let wrap = EventBuilder::new(Kind::Custom(wrap_kind), content)
|
|
||||||
.tags(tags)
|
|
||||||
.custom_created_at(at)
|
|
||||||
.finalize(signer)
|
|
||||||
.map_err(|error| StreamError::Sign(error.to_string()))?;
|
|
||||||
|
|
||||||
Ok((wrap, ephemeral))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rewrap_seal(
|
|
||||||
seal: &Event,
|
|
||||||
read: &GroupKey,
|
|
||||||
signer: &GroupKey,
|
|
||||||
at: Timestamp,
|
|
||||||
) -> Result<(Event, Keys), StreamError> {
|
|
||||||
if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
|
|
||||||
return Err(StreamError::NotRewrappable);
|
|
||||||
}
|
|
||||||
|
|
||||||
wrap_seal_with(seal, read.conversation(), signer.keys(), KIND_WRAP, at, &[])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
|
|
||||||
open_wrap_at(wrap, &group.pk(), group.conversation(), false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_wrap_at(
|
|
||||||
wrap: &Event,
|
|
||||||
address: &PublicKey,
|
|
||||||
conversation: &ConversationKey,
|
|
||||||
verify_wrap_signature: bool,
|
|
||||||
) -> Result<OpenedStream, StreamError> {
|
|
||||||
let wrap_kind = wrap.kind.as_u16();
|
|
||||||
|
|
||||||
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
|
|
||||||
return Err(StreamError::BadWrapKind(wrap_kind));
|
|
||||||
}
|
|
||||||
|
|
||||||
if wrap.pubkey != *address {
|
|
||||||
return Err(StreamError::WrongStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
if verify_wrap_signature && wrap.verify().is_err() {
|
|
||||||
return Err(StreamError::BadWrapSignature);
|
|
||||||
}
|
|
||||||
|
|
||||||
let seal: Event = Event::from_json(decode_content(conversation, &wrap.content)?)
|
|
||||||
.map_err(|error| StreamError::Parse(error.to_string()))?;
|
|
||||||
let seal_kind = seal.kind.as_u16();
|
|
||||||
let seal_form = SealForm::from_kind(seal_kind).ok_or(StreamError::BadSealKind(seal_kind))?;
|
|
||||||
seal.verify().map_err(|_| StreamError::BadSealSignature)?;
|
|
||||||
|
|
||||||
let rumor_json = match seal_form {
|
|
||||||
SealForm::Plaintext => seal.content.clone(),
|
|
||||||
SealForm::Encrypted => decode_content(conversation, &seal.content)?,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json.as_bytes())
|
|
||||||
.map_err(|error| StreamError::Parse(error.to_string()))?;
|
|
||||||
|
|
||||||
if rumor.pubkey != seal.pubkey {
|
|
||||||
return Err(StreamError::AuthorMismatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
let computed = rumor.compute_id();
|
|
||||||
if let Some(claimed) = rumor.id
|
|
||||||
&& claimed != computed
|
|
||||||
{
|
|
||||||
return Err(StreamError::BadRumorId);
|
|
||||||
}
|
|
||||||
rumor.id = Some(computed);
|
|
||||||
|
|
||||||
let at_ms = resolve_ms_strict(&rumor)?;
|
|
||||||
|
|
||||||
Ok(OpenedStream {
|
|
||||||
rumor_id: computed,
|
|
||||||
author: seal.pubkey,
|
|
||||||
seal_form,
|
|
||||||
seal,
|
|
||||||
wrapper_id: wrap.id,
|
|
||||||
at_ms,
|
|
||||||
rumor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec<Tag> {
|
|
||||||
vec![
|
|
||||||
Tag::custom(TAG_CHANNEL, [channel.to_hex()]),
|
|
||||||
Tag::custom(TAG_EPOCH, [epoch.0.to_string()]),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_channel_binding(
|
|
||||||
rumor: &UnsignedEvent,
|
|
||||||
channel: &ChannelId,
|
|
||||||
epoch: Epoch,
|
|
||||||
) -> Result<(), StreamError> {
|
|
||||||
match unique_tag(rumor, TAG_CHANNEL)? {
|
|
||||||
Some(value) if value == channel.to_hex() => {}
|
|
||||||
Some(_) => return Err(StreamError::ChannelMismatch),
|
|
||||||
None => return Err(StreamError::MissingTag(TAG_CHANNEL)),
|
|
||||||
}
|
|
||||||
|
|
||||||
match unique_tag(rumor, TAG_EPOCH)? {
|
|
||||||
Some(value) if value == epoch.0.to_string() => {}
|
|
||||||
Some(_) => return Err(StreamError::EpochMismatch),
|
|
||||||
None => return Err(StreamError::MissingTag(TAG_EPOCH)),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encrypt(conversation: &ConversationKey, plaintext: &[u8]) -> Result<Vec<u8>, StreamError> {
|
|
||||||
let mut nonce = [0u8; 32];
|
|
||||||
|
|
||||||
crate::fill_random(&mut nonce).map_err(|error| StreamError::Encrypt(error.to_string()))?;
|
|
||||||
|
|
||||||
encrypt_to_bytes_with_nonce(conversation, plaintext, nonce)
|
|
||||||
.map_err(|error| StreamError::Encrypt(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_content(conversation: &ConversationKey, content: &str) -> Result<String, StreamError> {
|
|
||||||
let plaintext = open_bytes(conversation, content)?;
|
|
||||||
|
|
||||||
String::from_utf8(plaintext).map_err(|error| StreamError::Parse(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_plaintext_cap(len: usize) -> Result<(), StreamError> {
|
|
||||||
if len > NIP44_MAX_PLAINTEXT {
|
|
||||||
return Err(StreamError::Oversize(len));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
let fields = tag.as_slice();
|
|
||||||
if fields.len() >= 2 && fields[0] == name {
|
|
||||||
if found.is_some() {
|
|
||||||
return Err(StreamError::DuplicateTag(name));
|
|
||||||
}
|
|
||||||
found = Some(fields[1].clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(found)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::derive::channel_group_key;
|
|
||||||
|
|
||||||
const SECRET: [u8; 32] = [0x07u8; 32];
|
|
||||||
const OTHER_SECRET: [u8; 32] = [0x08u8; 32];
|
|
||||||
|
|
||||||
fn channel() -> ChannelId {
|
|
||||||
ChannelId::from_bytes([0xabu8; 32])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn group(epoch: u64) -> GroupKey {
|
|
||||||
channel_group_key(&SECRET, &channel(), Epoch(epoch)).expect("derives")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wrapper_p_tag(wrap: &Event) -> Option<String> {
|
|
||||||
wrap.tags
|
|
||||||
.iter()
|
|
||||||
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("p"))
|
|
||||||
.and_then(|tag| tag.as_slice().get(1).cloned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bound_rumor(content: &str, author: PublicKey, at_ms: u64) -> UnsignedEvent {
|
|
||||||
build_rumor_ms(
|
|
||||||
9,
|
|
||||||
author,
|
|
||||||
content,
|
|
||||||
channel_binding_tags(&channel(), Epoch(0)),
|
|
||||||
at_ms,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
|
|
||||||
smol::block_on(build_seal(rumor, form, &group(0), author)).expect("seals")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
|
|
||||||
wrap_seal(seal, &group(0), kind, Timestamp::from_secs(at_secs), &[])
|
|
||||||
.expect("wraps")
|
|
||||||
.0
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encrypted_wrap(content: &str, author: &Keys, at_ms: u64, kind: u16) -> Event {
|
|
||||||
let rumor = bound_rumor(content, author.public_key(), at_ms);
|
|
||||||
wrapped(
|
|
||||||
&sealed(&rumor, SealForm::Encrypted, author),
|
|
||||||
kind,
|
|
||||||
at_ms / 1000,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn both_seal_forms_round_trip() {
|
|
||||||
let author = Keys::generate();
|
|
||||||
let at_ms = 1_686_840_217_417;
|
|
||||||
let wrap = encrypted_wrap("Hey chat!", &author, at_ms, KIND_WRAP);
|
|
||||||
|
|
||||||
assert_eq!(wrap.kind, Kind::GiftWrap, "the durable wrap is kind 1059");
|
|
||||||
assert_eq!(wrap.pubkey, group(0).pk(), "the stream key signs the wrap");
|
|
||||||
|
|
||||||
let opened = open_wrap(&wrap, &group(0)).expect("opens");
|
|
||||||
assert_eq!(opened.author, author.public_key());
|
|
||||||
assert_eq!(opened.rumor.content, "Hey chat!");
|
|
||||||
assert_eq!(opened.rumor_id, opened.rumor.id.expect("id is set"));
|
|
||||||
assert_eq!(opened.wrapper_id, wrap.id);
|
|
||||||
assert_eq!(opened.at_ms, at_ms);
|
|
||||||
assert_eq!(opened.seal_form, SealForm::Encrypted);
|
|
||||||
check_channel_binding(&opened.rumor, &channel(), Epoch(0)).expect("binding holds");
|
|
||||||
|
|
||||||
// The wrap's `p` tag must identify neither the stream nor the author.
|
|
||||||
let p = wrapper_p_tag(&wrap).expect("the wrap carries a p tag");
|
|
||||||
assert_ne!(p, group(0).pk_hex());
|
|
||||||
assert_ne!(p, author.public_key().to_hex());
|
|
||||||
|
|
||||||
// Ephemeral actions ride the same structure at a kind relays must drop.
|
|
||||||
let typing = encrypted_wrap("typing", &author, 5_000, KIND_WRAP_EPHEMERAL);
|
|
||||||
assert_eq!(typing.kind.as_u16(), 21059);
|
|
||||||
assert_eq!(
|
|
||||||
open_wrap(&typing, &group(0)).expect("opens").rumor.content,
|
|
||||||
"typing"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The plaintext form carries the rumor's bytes verbatim, which is what
|
|
||||||
// lets a compaction re-wrap the signed edition into a later epoch.
|
|
||||||
let edition = build_rumor_secs(
|
|
||||||
3308,
|
|
||||||
author.public_key(),
|
|
||||||
"an edition",
|
|
||||||
vec![],
|
|
||||||
1_700_000_000,
|
|
||||||
);
|
|
||||||
let seal = sealed(&edition, SealForm::Plaintext, &author);
|
|
||||||
assert_eq!(seal.content, edition.as_json(), "the rumor rides verbatim");
|
|
||||||
|
|
||||||
let opened = open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)).expect("opens");
|
|
||||||
assert_eq!(opened.seal_form, SealForm::Plaintext);
|
|
||||||
|
|
||||||
let (rewrapped, _) =
|
|
||||||
rewrap_seal(&opened.seal, &group(1), &group(1), Timestamp::from_secs(2))
|
|
||||||
.expect("rewraps");
|
|
||||||
let reopened = open_wrap(&rewrapped, &group(1)).expect("opens");
|
|
||||||
assert_eq!(reopened.rumor_id, opened.rumor_id, "the rumor id survives");
|
|
||||||
assert_eq!(reopened.author, author.public_key());
|
|
||||||
assert_eq!(
|
|
||||||
reopened.seal.sig, opened.seal.sig,
|
|
||||||
"the signature rides whole"
|
|
||||||
);
|
|
||||||
assert_ne!(reopened.wrapper_id, opened.wrapper_id);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
rewrap_seal(
|
|
||||||
&sealed(&edition, SealForm::Encrypted, &author),
|
|
||||||
&group(1),
|
|
||||||
&group(1),
|
|
||||||
Timestamp::from_secs(2)
|
|
||||||
),
|
|
||||||
Err(StreamError::NotRewrappable)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hostile_wraps_are_dropped_in_order() {
|
|
||||||
let author = Keys::generate();
|
|
||||||
let impostor = Keys::generate();
|
|
||||||
|
|
||||||
// Kind and address are settled before any decryption is attempted.
|
|
||||||
let mut wrong_kind = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
|
|
||||||
wrong_kind.kind = Kind::Custom(1058);
|
|
||||||
assert!(matches!(
|
|
||||||
open_wrap(&wrong_kind, &group(0)),
|
|
||||||
Err(StreamError::BadWrapKind(1058))
|
|
||||||
));
|
|
||||||
|
|
||||||
let foreign = channel_group_key(&OTHER_SECRET, &channel(), Epoch(0)).expect("derives");
|
|
||||||
let wrap = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
|
|
||||||
assert!(matches!(
|
|
||||||
open_wrap(&wrap, &foreign),
|
|
||||||
Err(StreamError::WrongStream)
|
|
||||||
));
|
|
||||||
|
|
||||||
// A flipped ciphertext byte fails the NIP-44 MAC.
|
|
||||||
let mut payload = BASE64
|
|
||||||
.decode(wrap.content.as_bytes())
|
|
||||||
.expect("content is base64");
|
|
||||||
payload[40] ^= 0x01;
|
|
||||||
let mut tampered = wrap.clone();
|
|
||||||
tampered.content = BASE64.encode(&payload);
|
|
||||||
assert!(matches!(
|
|
||||||
open_wrap(&tampered, &group(0)),
|
|
||||||
Err(StreamError::Decrypt(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
// A seal claiming an author it holds no signature for.
|
|
||||||
let seal = sealed(
|
|
||||||
&bound_rumor("spoof", author.public_key(), 1_000),
|
|
||||||
SealForm::Encrypted,
|
|
||||||
&impostor,
|
|
||||||
);
|
|
||||||
let mut swapped: serde_json::Value = serde_json::from_str(&seal.as_json()).expect("json");
|
|
||||||
swapped["pubkey"] = serde_json::Value::String(author.public_key().to_hex());
|
|
||||||
let seal = Event::from_json(swapped.to_string()).expect("a swapped pubkey still parses");
|
|
||||||
assert!(matches!(
|
|
||||||
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
|
|
||||||
Err(StreamError::BadSealSignature)
|
|
||||||
));
|
|
||||||
|
|
||||||
// A seal that does not vouch for the rumor's author.
|
|
||||||
let seal = sealed(
|
|
||||||
&bound_rumor("spoof", impostor.public_key(), 1_000),
|
|
||||||
SealForm::Encrypted,
|
|
||||||
&author,
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
|
|
||||||
Err(StreamError::AuthorMismatch)
|
|
||||||
));
|
|
||||||
|
|
||||||
// A claimed id the rumor's own bytes do not hash to. The plaintext seal
|
|
||||||
// smuggles the forgery through verbatim.
|
|
||||||
let rumor = bound_rumor("real", author.public_key(), 1_000);
|
|
||||||
let mut forged: serde_json::Value = serde_json::from_str(&rumor.as_json()).expect("json");
|
|
||||||
forged["id"] = serde_json::Value::String("00".repeat(32));
|
|
||||||
let seal = EventBuilder::new(Kind::Custom(KIND_SEAL_PLAINTEXT), forged.to_string())
|
|
||||||
.custom_created_at(rumor.created_at)
|
|
||||||
.finalize(&author)
|
|
||||||
.expect("seals");
|
|
||||||
assert!(matches!(
|
|
||||||
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
|
|
||||||
Err(StreamError::BadRumorId)
|
|
||||||
));
|
|
||||||
|
|
||||||
// Binding splices: another channel, another epoch, a duplicate or none.
|
|
||||||
let doubled = vec![channel_binding_tags(&channel(), Epoch(0)); 2].concat();
|
|
||||||
let rumor = bound_rumor("x", author.public_key(), 1_000);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
check_channel_binding(&rumor, &ChannelId::from_bytes([0xcdu8; 32]), Epoch(0)),
|
|
||||||
Err(StreamError::ChannelMismatch)
|
|
||||||
));
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
check_channel_binding(&rumor, &channel(), Epoch(1)),
|
|
||||||
Err(StreamError::EpochMismatch)
|
|
||||||
));
|
|
||||||
|
|
||||||
let duplicate = build_rumor_ms(9, author.public_key(), "x", doubled, 1_000);
|
|
||||||
assert!(matches!(
|
|
||||||
check_channel_binding(&duplicate, &channel(), Epoch(0)),
|
|
||||||
Err(StreamError::DuplicateTag(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
let unbound = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000);
|
|
||||||
assert!(matches!(
|
|
||||||
check_channel_binding(&unbound, &channel(), Epoch(0)),
|
|
||||||
Err(StreamError::MissingTag(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
let oversize = build_rumor_ms(
|
|
||||||
9,
|
|
||||||
author.public_key(),
|
|
||||||
&"x".repeat(NIP44_MAX_PLAINTEXT + 1),
|
|
||||||
vec![],
|
|
||||||
1_000,
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
seal_content(&oversize, SealForm::Encrypted, &group(0)),
|
|
||||||
Err(StreamError::Oversize(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ms_is_a_drop_gate() {
|
|
||||||
let author = Keys::generate();
|
|
||||||
|
|
||||||
let absent = build_rumor_secs(9, author.public_key(), "x", vec![], 1_000);
|
|
||||||
assert_eq!(resolve_ms_strict(&absent).expect("resolves"), 1_000_000);
|
|
||||||
|
|
||||||
let highest = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000_999);
|
|
||||||
assert_eq!(resolve_ms_strict(&highest).expect("resolves"), 1_000_999);
|
|
||||||
|
|
||||||
for malformed in ["1000", "007", "abc", "+5", ""] {
|
|
||||||
let rumor = build_rumor_secs(
|
|
||||||
9,
|
|
||||||
author.public_key(),
|
|
||||||
"x",
|
|
||||||
vec![Tag::custom(TAG_MS, [malformed.to_string()])],
|
|
||||||
1_000,
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
matches!(resolve_ms_strict(&rumor), Err(StreamError::BadMs)),
|
|
||||||
"{malformed:?} must be malformed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Present but valueless is malformed, not an offset-0 default.
|
|
||||||
let valueless = build_rumor_secs(
|
|
||||||
9,
|
|
||||||
author.public_key(),
|
|
||||||
"x",
|
|
||||||
vec![Tag::custom(TAG_MS, Vec::<String>::new())],
|
|
||||||
1_000,
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
resolve_ms_strict(&valueless),
|
|
||||||
Err(StreamError::BadMs)
|
|
||||||
));
|
|
||||||
|
|
||||||
// A valued duplicate takes the first, matching Armada.
|
|
||||||
let repeated = build_rumor_secs(
|
|
||||||
9,
|
|
||||||
author.public_key(),
|
|
||||||
"x",
|
|
||||||
vec![
|
|
||||||
Tag::custom(TAG_MS, ["1".to_string()]),
|
|
||||||
Tag::custom(TAG_MS, ["2".to_string()]),
|
|
||||||
],
|
|
||||||
1_000,
|
|
||||||
);
|
|
||||||
assert_eq!(resolve_ms_strict(&repeated).expect("resolves"), 1_000_001);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,822 +0,0 @@
|
|||||||
use std::cmp::Reverse;
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use data_encoding::HEXLOWER;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
|
|
||||||
use crate::cord01::{
|
|
||||||
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;
|
|
||||||
pub const KIND_KICK: u16 = 3309;
|
|
||||||
pub const KIND_SNAPSHOT: u16 = 3312;
|
|
||||||
|
|
||||||
pub const MAX_SNAPSHOT_CHUNK: usize = 400;
|
|
||||||
pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const TAG_INVITE: &str = "invite";
|
|
||||||
const TAG_TARGET: &str = "p";
|
|
||||||
const TAG_SNAP: &str = "snap";
|
|
||||||
const TAG_CONTENT: &str = "content";
|
|
||||||
const CONTENT_JOIN: &str = "join";
|
|
||||||
const CONTENT_LEAVE: &str = "leave";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum GuestbookEntry {
|
|
||||||
Join {
|
|
||||||
member: PublicKey,
|
|
||||||
at_ms: u64,
|
|
||||||
/// The `(creator, label)` an invite attributed the join to.
|
|
||||||
invited_by: Option<(String, String)>,
|
|
||||||
},
|
|
||||||
Leave {
|
|
||||||
member: PublicKey,
|
|
||||||
at_ms: u64,
|
|
||||||
},
|
|
||||||
Kick {
|
|
||||||
actor: PublicKey,
|
|
||||||
target: PublicKey,
|
|
||||||
at_ms: u64,
|
|
||||||
citation: Option<AuthorityCitation>,
|
|
||||||
},
|
|
||||||
Snapshot {
|
|
||||||
refounder: PublicKey,
|
|
||||||
members: Vec<PublicKey>,
|
|
||||||
snapshot_id: [u8; 32],
|
|
||||||
chunk: (u32, u32),
|
|
||||||
at_ms: u64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct GuestbookRumor {
|
|
||||||
pub id: EventId,
|
|
||||||
pub author: PublicKey,
|
|
||||||
pub kind: Kind,
|
|
||||||
pub at_ms: u64,
|
|
||||||
pub entry: GuestbookEntry,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum MemberState {
|
|
||||||
Joined {
|
|
||||||
at_ms: u64,
|
|
||||||
invited_by: Option<(String, String)>,
|
|
||||||
},
|
|
||||||
Left {
|
|
||||||
at_ms: u64,
|
|
||||||
},
|
|
||||||
Kicked {
|
|
||||||
at_ms: u64,
|
|
||||||
actor: PublicKey,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_join(
|
|
||||||
member: PublicKey,
|
|
||||||
invited_by: Option<(&str, &str)>,
|
|
||||||
at_ms: u64,
|
|
||||||
) -> UnsignedEvent {
|
|
||||||
let mut tags = Vec::new();
|
|
||||||
|
|
||||||
if let Some((creator, label)) = invited_by {
|
|
||||||
tags.push(Tag::custom(TAG_INVITE, [creator, label]));
|
|
||||||
}
|
|
||||||
|
|
||||||
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_JOIN, tags, at_ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent {
|
|
||||||
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_LEAVE, Vec::new(), at_ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_kick(
|
|
||||||
actor: PublicKey,
|
|
||||||
target: &PublicKey,
|
|
||||||
citation: Option<&AuthorityCitation>,
|
|
||||||
at_ms: u64,
|
|
||||||
) -> UnsignedEvent {
|
|
||||||
let mut tags = vec![Tag::custom(TAG_TARGET, [target.to_hex()])];
|
|
||||||
|
|
||||||
if let Some(citation) = citation {
|
|
||||||
tags.push(citation_tag(citation));
|
|
||||||
}
|
|
||||||
|
|
||||||
build_rumor_ms(KIND_KICK, actor, "", tags, at_ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_snapshot_chunks(
|
|
||||||
refounder: PublicKey,
|
|
||||||
members: &[PublicKey],
|
|
||||||
snapshot_id: [u8; 32],
|
|
||||||
at_ms: u64,
|
|
||||||
) -> Vec<UnsignedEvent> {
|
|
||||||
let chunks: Vec<&[PublicKey]> = members.chunks(MAX_SNAPSHOT_CHUNK).collect();
|
|
||||||
let total = chunks.len() as u32;
|
|
||||||
|
|
||||||
chunks
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, chunk)| {
|
|
||||||
let hex: Vec<String> = chunk.iter().map(PublicKey::to_hex).collect();
|
|
||||||
let content = format!(
|
|
||||||
"[{}]",
|
|
||||||
hex.iter()
|
|
||||||
.map(|member| format!("\"{member}\""))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",")
|
|
||||||
);
|
|
||||||
let tags = vec![Tag::custom(
|
|
||||||
TAG_SNAP,
|
|
||||||
[
|
|
||||||
HEXLOWER.encode(&snapshot_id),
|
|
||||||
(index as u32 + 1).to_string(),
|
|
||||||
total.to_string(),
|
|
||||||
],
|
|
||||||
)];
|
|
||||||
|
|
||||||
build_rumor_ms(KIND_SNAPSHOT, refounder, &content, tags, at_ms)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn seal_rumor<S>(
|
|
||||||
rumor: &UnsignedEvent,
|
|
||||||
group: &GroupKey,
|
|
||||||
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).await?;
|
|
||||||
|
|
||||||
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open(
|
|
||||||
wrap: &Event,
|
|
||||||
group: &GroupKey,
|
|
||||||
) -> Result<(OpenedStream, GuestbookRumor), GuestbookError> {
|
|
||||||
let opened = open_wrap(wrap, group)?;
|
|
||||||
|
|
||||||
if opened.seal_form != SealForm::Encrypted {
|
|
||||||
return Err(GuestbookError::NotEncryptedSealed);
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry = entry_of(&opened)?;
|
|
||||||
let rumor = GuestbookRumor {
|
|
||||||
id: opened.rumor_id,
|
|
||||||
author: opened.author,
|
|
||||||
kind: opened.rumor.kind,
|
|
||||||
at_ms: opened.at_ms,
|
|
||||||
entry,
|
|
||||||
};
|
|
||||||
|
|
||||||
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_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();
|
|
||||||
let horizon = now_ms.saturating_add(MAX_FUTURE_SKEW_MS);
|
|
||||||
|
|
||||||
for rumor in rumors {
|
|
||||||
if rumor.at_ms > horizon {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match &rumor.entry {
|
|
||||||
GuestbookEntry::Join {
|
|
||||||
member,
|
|
||||||
at_ms,
|
|
||||||
invited_by,
|
|
||||||
} => offer(
|
|
||||||
&mut states,
|
|
||||||
*member,
|
|
||||||
*at_ms,
|
|
||||||
rumor.id,
|
|
||||||
MemberState::Joined {
|
|
||||||
at_ms: *at_ms,
|
|
||||||
invited_by: invited_by.clone(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GuestbookEntry::Leave { member, at_ms } => offer(
|
|
||||||
&mut states,
|
|
||||||
*member,
|
|
||||||
*at_ms,
|
|
||||||
rumor.id,
|
|
||||||
MemberState::Left { at_ms: *at_ms },
|
|
||||||
),
|
|
||||||
GuestbookEntry::Kick {
|
|
||||||
actor,
|
|
||||||
target,
|
|
||||||
at_ms,
|
|
||||||
citation,
|
|
||||||
} => {
|
|
||||||
if !can_kick(actor, target, citation.as_ref()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
offer(
|
|
||||||
&mut states,
|
|
||||||
*target,
|
|
||||||
*at_ms,
|
|
||||||
rumor.id,
|
|
||||||
MemberState::Kicked {
|
|
||||||
at_ms: *at_ms,
|
|
||||||
actor: *actor,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
GuestbookEntry::Snapshot {
|
|
||||||
refounder,
|
|
||||||
members,
|
|
||||||
at_ms,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if !snapshot_authorities.contains(refounder) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for member in members {
|
|
||||||
offer(
|
|
||||||
&mut states,
|
|
||||||
*member,
|
|
||||||
*at_ms,
|
|
||||||
rumor.id,
|
|
||||||
MemberState::Joined {
|
|
||||||
at_ms: *at_ms,
|
|
||||||
invited_by: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
states
|
|
||||||
.into_iter()
|
|
||||||
.map(|(member, (_, _, state))| (member, state))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn complete_memberlist(
|
|
||||||
coalesced: &BTreeMap<PublicKey, MemberState>,
|
|
||||||
observed: &BTreeMap<PublicKey, u64>,
|
|
||||||
granted: &BTreeSet<PublicKey>,
|
|
||||||
banned: &BTreeSet<PublicKey>,
|
|
||||||
banned_at: &BTreeMap<PublicKey, u64>,
|
|
||||||
) -> BTreeSet<PublicKey> {
|
|
||||||
let mut candidates: BTreeSet<&PublicKey> = coalesced.keys().collect();
|
|
||||||
candidates.extend(observed.keys());
|
|
||||||
candidates.extend(granted.iter());
|
|
||||||
|
|
||||||
let mut members = BTreeSet::new();
|
|
||||||
|
|
||||||
for member in candidates {
|
|
||||||
let mut inclusion = observed.get(member).copied();
|
|
||||||
|
|
||||||
if let Some(state) = coalesced.get(member) {
|
|
||||||
match state {
|
|
||||||
MemberState::Joined { at_ms, .. } => {
|
|
||||||
inclusion = Some(inclusion.map_or(*at_ms, |seen| seen.max(*at_ms)));
|
|
||||||
}
|
|
||||||
MemberState::Left { .. } | MemberState::Kicked { .. } => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if inclusion.is_none() && granted.contains(member) {
|
|
||||||
inclusion = Some(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut exclusion = match coalesced.get(member) {
|
|
||||||
Some(MemberState::Left { at_ms }) | Some(MemberState::Kicked { at_ms, .. }) => {
|
|
||||||
Some(*at_ms)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if banned.contains(member) {
|
|
||||||
exclusion = Some(match banned_at.get(member) {
|
|
||||||
Some(at_ms) => exclusion.map_or(*at_ms, |seen| seen.max(*at_ms)),
|
|
||||||
None => u64::MAX,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(inclusion) = inclusion
|
|
||||||
&& exclusion.is_none_or(|exclusion| inclusion > exclusion)
|
|
||||||
{
|
|
||||||
members.insert(*member);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
members
|
|
||||||
}
|
|
||||||
|
|
||||||
fn offer(
|
|
||||||
states: &mut BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)>,
|
|
||||||
member: PublicKey,
|
|
||||||
at_ms: u64,
|
|
||||||
id: EventId,
|
|
||||||
state: MemberState,
|
|
||||||
) {
|
|
||||||
let candidate = (at_ms, Reverse(id));
|
|
||||||
|
|
||||||
if let Some(existing) = states.get(&member)
|
|
||||||
&& (existing.0, existing.1) >= candidate
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
states.insert(member, (at_ms, Reverse(id), state));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_guestbook_kind(kind: u16) -> bool {
|
|
||||||
matches!(kind, KIND_JOIN_LEAVE | KIND_KICK | KIND_SNAPSHOT)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn entry_of(opened: &OpenedStream) -> Result<GuestbookEntry, GuestbookError> {
|
|
||||||
let rumor = &opened.rumor;
|
|
||||||
let author = opened.author;
|
|
||||||
let at_ms = opened.at_ms;
|
|
||||||
|
|
||||||
match rumor.kind.as_u16() {
|
|
||||||
KIND_JOIN_LEAVE => match rumor.content.as_str() {
|
|
||||||
CONTENT_JOIN => Ok(GuestbookEntry::Join {
|
|
||||||
member: author,
|
|
||||||
at_ms,
|
|
||||||
invited_by: invite_of(rumor),
|
|
||||||
}),
|
|
||||||
CONTENT_LEAVE => Ok(GuestbookEntry::Leave {
|
|
||||||
member: author,
|
|
||||||
at_ms,
|
|
||||||
}),
|
|
||||||
_ => Err(GuestbookError::BadTag(TAG_CONTENT)),
|
|
||||||
},
|
|
||||||
KIND_KICK => Ok(GuestbookEntry::Kick {
|
|
||||||
actor: author,
|
|
||||||
target: tagged_pubkey(rumor, TAG_TARGET)?,
|
|
||||||
at_ms,
|
|
||||||
citation: optional_citation(rumor)?,
|
|
||||||
}),
|
|
||||||
KIND_SNAPSHOT => {
|
|
||||||
let (snapshot_id, chunk) = snapshot_of(rumor)?;
|
|
||||||
let members = members_of(&rumor.content)?;
|
|
||||||
|
|
||||||
Ok(GuestbookEntry::Snapshot {
|
|
||||||
refounder: author,
|
|
||||||
members,
|
|
||||||
snapshot_id,
|
|
||||||
chunk,
|
|
||||||
at_ms,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
other => Err(GuestbookError::UnknownKind(other)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invite_of(rumor: &UnsignedEvent) -> Option<(String, String)> {
|
|
||||||
rumor.tags.iter().find_map(|candidate| {
|
|
||||||
let fields = candidate.as_slice();
|
|
||||||
|
|
||||||
(fields.len() >= 3 && fields[0] == TAG_INVITE)
|
|
||||||
.then(|| (fields[1].clone(), fields[2].clone()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn members_of(content: &str) -> Result<Vec<PublicKey>, GuestbookError> {
|
|
||||||
let entries: Vec<String> =
|
|
||||||
serde_json::from_str(content).map_err(|_| GuestbookError::BadTag(TAG_CONTENT))?;
|
|
||||||
|
|
||||||
if entries.len() > MAX_SNAPSHOT_CHUNK {
|
|
||||||
return Err(GuestbookError::BadTag(TAG_SNAP));
|
|
||||||
}
|
|
||||||
|
|
||||||
entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| pubkey(entry, TAG_CONTENT))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), GuestbookError> {
|
|
||||||
let fields = required(rumor, TAG_SNAP)?;
|
|
||||||
|
|
||||||
if fields.len() != 4 {
|
|
||||||
return Err(GuestbookError::BadTag(TAG_SNAP));
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot_id = decode_hex_32(&fields[1]).map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
|
|
||||||
let index = decimal(&fields[2])?;
|
|
||||||
let total = decimal(&fields[3])?;
|
|
||||||
|
|
||||||
if index == 0 || index > total {
|
|
||||||
return Err(GuestbookError::BadTag(TAG_SNAP));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((snapshot_id, (index, total)))
|
|
||||||
}
|
|
||||||
|
|
||||||
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 tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
|
|
||||||
pubkey(value(required(rumor, name)?, name)?, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::cord01::{StreamError, build_rumor_secs};
|
|
||||||
use crate::cord04::TAG_CITATION;
|
|
||||||
use crate::derive::guestbook_group_key;
|
|
||||||
use crate::{CommunityId, Epoch};
|
|
||||||
|
|
||||||
const ROOT: [u8; 32] = [0x5au8; 32];
|
|
||||||
const AT: u64 = 1_700_000_000_000;
|
|
||||||
|
|
||||||
fn community() -> CommunityId {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn citation() -> AuthorityCitation {
|
|
||||||
AuthorityCitation {
|
|
||||||
entity: [0x33u8; 32],
|
|
||||||
version: 1,
|
|
||||||
hash: [0x44u8; 32],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
|
|
||||||
let wrap = smol::block_on(seal_rumor(rumor, &group(), author))
|
|
||||||
.expect("seals")
|
|
||||||
.0;
|
|
||||||
|
|
||||||
open(&wrap, &group()).expect("opens").1
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn join_leave_kick_and_snapshot_converge_to_one_memberlist() {
|
|
||||||
let alice = Keys::generate();
|
|
||||||
let bob = Keys::generate();
|
|
||||||
let carol = Keys::generate();
|
|
||||||
let dave = Keys::generate();
|
|
||||||
let frank = Keys::generate();
|
|
||||||
let grace = Keys::generate();
|
|
||||||
let owner = Keys::generate();
|
|
||||||
|
|
||||||
let survivors: Vec<PublicKey> = (0..401).map(|_| Keys::generate().public_key()).collect();
|
|
||||||
|
|
||||||
let mut rumors = vec![
|
|
||||||
publish(
|
|
||||||
&build_join(
|
|
||||||
alice.public_key(),
|
|
||||||
Some((&"ab".repeat(32), "Reddit")),
|
|
||||||
AT + 1_000,
|
|
||||||
),
|
|
||||||
&alice,
|
|
||||||
),
|
|
||||||
publish(&build_join(bob.public_key(), None, AT + 2_000), &bob),
|
|
||||||
publish(&build_leave(bob.public_key(), AT + 3_000), &bob),
|
|
||||||
publish(&build_join(dave.public_key(), None, AT + 4_000), &dave),
|
|
||||||
publish(
|
|
||||||
&build_kick(
|
|
||||||
carol.public_key(),
|
|
||||||
&dave.public_key(),
|
|
||||||
Some(&citation()),
|
|
||||||
AT + 5_000,
|
|
||||||
),
|
|
||||||
&carol,
|
|
||||||
),
|
|
||||||
publish(&build_join(frank.public_key(), None, AT + 7_000), &frank),
|
|
||||||
];
|
|
||||||
|
|
||||||
let snapshot_id = "77".repeat(32);
|
|
||||||
let chunks =
|
|
||||||
build_snapshot_chunks(carol.public_key(), &survivors, [0x77u8; 32], AT + 6_000);
|
|
||||||
assert_eq!(chunks.len(), 2, "401 survivors chunk into two events");
|
|
||||||
for (index, chunk) in chunks.iter().enumerate() {
|
|
||||||
assert!(chunk.tags.iter().any(|tag| tag.as_slice()
|
|
||||||
== [
|
|
||||||
TAG_SNAP,
|
|
||||||
snapshot_id.as_str(),
|
|
||||||
&(index + 1).to_string(),
|
|
||||||
"2"
|
|
||||||
]));
|
|
||||||
rumors.push(publish(chunk, &carol));
|
|
||||||
}
|
|
||||||
|
|
||||||
let can_kick =
|
|
||||||
|actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| {
|
|
||||||
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
|
|
||||||
};
|
|
||||||
|
|
||||||
let states = coalesce(&rumors, AT + 8_000, &refounders(&[&carol]), can_kick);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
states.get(&alice.public_key()),
|
|
||||||
Some(&MemberState::Joined {
|
|
||||||
at_ms: AT + 1_000,
|
|
||||||
invited_by: Some(("ab".repeat(32), "Reddit".to_owned())),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
states.get(&bob.public_key()),
|
|
||||||
Some(&MemberState::Left { at_ms: AT + 3_000 })
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
states.get(&dave.public_key()),
|
|
||||||
Some(&MemberState::Kicked {
|
|
||||||
at_ms: AT + 5_000,
|
|
||||||
actor: carol.public_key(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
survivors
|
|
||||||
.iter()
|
|
||||||
.all(|member| matches!(states.get(member), Some(MemberState::Joined { .. }))),
|
|
||||||
"every chunk seeds its own members"
|
|
||||||
);
|
|
||||||
|
|
||||||
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
|
|
||||||
assert_eq!(
|
|
||||||
coalesce(&reversed, AT + 8_000, &refounders(&[&carol]), can_kick),
|
|
||||||
states,
|
|
||||||
"arrival order cannot change the fold"
|
|
||||||
);
|
|
||||||
|
|
||||||
let observed = BTreeMap::from([
|
|
||||||
(bob.public_key(), AT + 9_000),
|
|
||||||
(carol.public_key(), AT + 5_000),
|
|
||||||
]);
|
|
||||||
let granted = BTreeSet::from([grace.public_key()]);
|
|
||||||
let banned = BTreeSet::from([frank.public_key()]);
|
|
||||||
let banned_at = BTreeMap::from([(frank.public_key(), AT + 8_000)]);
|
|
||||||
|
|
||||||
let members = complete_memberlist(&states, &observed, &granted, &banned, &banned_at);
|
|
||||||
|
|
||||||
let mut expected = BTreeSet::from([
|
|
||||||
alice.public_key(),
|
|
||||||
bob.public_key(),
|
|
||||||
carol.public_key(),
|
|
||||||
grace.public_key(),
|
|
||||||
]);
|
|
||||||
expected.extend(survivors.iter().copied());
|
|
||||||
|
|
||||||
assert_eq!(members, expected);
|
|
||||||
assert!(
|
|
||||||
!members.contains(&dave.public_key()),
|
|
||||||
"a kicked member is out"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!members.contains(&frank.public_key()),
|
|
||||||
"a ban wins over a later join"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_kick_or_snapshot_without_authority_is_dropped() {
|
|
||||||
let moderator = Keys::generate();
|
|
||||||
let outsider = Keys::generate();
|
|
||||||
let owner = Keys::generate();
|
|
||||||
let kicked = Keys::generate();
|
|
||||||
let uncited = Keys::generate();
|
|
||||||
let unranked = Keys::generate();
|
|
||||||
let refounder = Keys::generate();
|
|
||||||
let impostor = Keys::generate();
|
|
||||||
let seeded = Keys::generate();
|
|
||||||
let smuggled = Keys::generate();
|
|
||||||
|
|
||||||
let can_kick = |actor: &PublicKey,
|
|
||||||
target: &PublicKey,
|
|
||||||
citation: Option<&AuthorityCitation>| {
|
|
||||||
citation.is_some() && actor == &moderator.public_key() && target != &owner.public_key()
|
|
||||||
};
|
|
||||||
|
|
||||||
let rumors = vec![
|
|
||||||
publish(
|
|
||||||
&build_kick(
|
|
||||||
moderator.public_key(),
|
|
||||||
&kicked.public_key(),
|
|
||||||
Some(&citation()),
|
|
||||||
AT,
|
|
||||||
),
|
|
||||||
&moderator,
|
|
||||||
),
|
|
||||||
publish(
|
|
||||||
&build_kick(moderator.public_key(), &uncited.public_key(), None, AT),
|
|
||||||
&moderator,
|
|
||||||
),
|
|
||||||
publish(
|
|
||||||
&build_kick(
|
|
||||||
outsider.public_key(),
|
|
||||||
&unranked.public_key(),
|
|
||||||
Some(&citation()),
|
|
||||||
AT,
|
|
||||||
),
|
|
||||||
&outsider,
|
|
||||||
),
|
|
||||||
publish(
|
|
||||||
&build_kick(
|
|
||||||
moderator.public_key(),
|
|
||||||
&owner.public_key(),
|
|
||||||
Some(&citation()),
|
|
||||||
AT,
|
|
||||||
),
|
|
||||||
&moderator,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
let states = coalesce(&rumors, AT + 1_000, &BTreeSet::new(), can_kick);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
states.get(&kicked.public_key()),
|
|
||||||
Some(&MemberState::Kicked {
|
|
||||||
at_ms: AT,
|
|
||||||
actor: moderator.public_key(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!states.contains_key(&uncited.public_key()),
|
|
||||||
"a kick cites the Grant it acts under"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!states.contains_key(&unranked.public_key()),
|
|
||||||
"a kick needs KICK"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!states.contains_key(&owner.public_key()),
|
|
||||||
"nobody kicks the owner"
|
|
||||||
);
|
|
||||||
|
|
||||||
let by_refounder = build_snapshot_chunks(
|
|
||||||
refounder.public_key(),
|
|
||||||
&[seeded.public_key()],
|
|
||||||
[0x77u8; 32],
|
|
||||||
AT,
|
|
||||||
)
|
|
||||||
.remove(0);
|
|
||||||
let by_impostor = build_snapshot_chunks(
|
|
||||||
impostor.public_key(),
|
|
||||||
&[smuggled.public_key()],
|
|
||||||
[0x88u8; 32],
|
|
||||||
AT,
|
|
||||||
)
|
|
||||||
.remove(0);
|
|
||||||
|
|
||||||
for authority in [BTreeSet::new(), refounders(&[&refounder])] {
|
|
||||||
let states = coalesce(
|
|
||||||
&[
|
|
||||||
publish(&by_refounder, &refounder),
|
|
||||||
publish(&by_impostor, &impostor),
|
|
||||||
],
|
|
||||||
AT + 1_000,
|
|
||||||
&authority,
|
|
||||||
|_, _, _| true,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
states.contains_key(&seeded.public_key()),
|
|
||||||
!authority.is_empty(),
|
|
||||||
"only a known refounder seeds, and there is no owner fallback"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!states.contains_key(&smuggled.public_key()),
|
|
||||||
"a foreign snapshot never seeds"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_future_entry_a_bad_ms_and_a_malformed_snapshot_are_dropped() {
|
|
||||||
let member = Keys::generate();
|
|
||||||
let moderator = Keys::generate();
|
|
||||||
let target = Keys::generate();
|
|
||||||
|
|
||||||
let future = publish(
|
|
||||||
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS + 1),
|
|
||||||
&member,
|
|
||||||
);
|
|
||||||
let horizon = publish(
|
|
||||||
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS),
|
|
||||||
&member,
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
coalesce(&[future], AT, &BTreeSet::new(), |_, _, _| true).is_empty(),
|
|
||||||
"an entry more than an hour ahead is dropped"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
coalesce(&[horizon], AT, &BTreeSet::new(), |_, _, _| true).len(),
|
|
||||||
1,
|
|
||||||
"the horizon itself is skew, not forgery"
|
|
||||||
);
|
|
||||||
|
|
||||||
let bad_ms = build_rumor_secs(
|
|
||||||
KIND_JOIN_LEAVE,
|
|
||||||
member.public_key(),
|
|
||||||
CONTENT_JOIN,
|
|
||||||
vec![Tag::custom("ms", ["1000"])],
|
|
||||||
AT / 1000,
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
open(
|
|
||||||
&smol::block_on(seal_rumor(&bad_ms, &group(), &member))
|
|
||||||
.expect("seals")
|
|
||||||
.0,
|
|
||||||
&group()
|
|
||||||
),
|
|
||||||
Err(GuestbookError::Stream(StreamError::BadMs))
|
|
||||||
));
|
|
||||||
|
|
||||||
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
|
|
||||||
assert!(matches!(
|
|
||||||
open(
|
|
||||||
&smol::block_on(seal_rumor(&bad_verb, &group(), &member))
|
|
||||||
.expect("seals")
|
|
||||||
.0,
|
|
||||||
&group()
|
|
||||||
),
|
|
||||||
Err(GuestbookError::BadTag(TAG_CONTENT))
|
|
||||||
));
|
|
||||||
|
|
||||||
let ambiguous = build_rumor_ms(
|
|
||||||
KIND_KICK,
|
|
||||||
moderator.public_key(),
|
|
||||||
"",
|
|
||||||
vec![
|
|
||||||
Tag::custom(TAG_TARGET, [target.public_key().to_hex()]),
|
|
||||||
citation_tag(&citation()),
|
|
||||||
citation_tag(&citation()),
|
|
||||||
],
|
|
||||||
AT,
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
open(
|
|
||||||
&smol::block_on(seal_rumor(&ambiguous, &group(), &moderator))
|
|
||||||
.expect("seals")
|
|
||||||
.0,
|
|
||||||
&group()
|
|
||||||
),
|
|
||||||
Err(GuestbookError::DuplicateTag(TAG_CITATION))
|
|
||||||
));
|
|
||||||
|
|
||||||
for fields in [
|
|
||||||
vec![snapshot_id(), "0".to_owned(), "2".to_owned()],
|
|
||||||
vec![snapshot_id(), "3".to_owned(), "2".to_owned()],
|
|
||||||
vec![snapshot_id(), "1".to_owned()],
|
|
||||||
] {
|
|
||||||
let rumor = build_rumor_ms(
|
|
||||||
KIND_SNAPSHOT,
|
|
||||||
moderator.public_key(),
|
|
||||||
"[]",
|
|
||||||
vec![Tag::custom(TAG_SNAP, fields)],
|
|
||||||
AT,
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
open(
|
|
||||||
&smol::block_on(seal_rumor(&rumor, &group(), &moderator))
|
|
||||||
.expect("seals")
|
|
||||||
.0,
|
|
||||||
&group()
|
|
||||||
),
|
|
||||||
Err(GuestbookError::BadTag(TAG_SNAP))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot_id() -> String {
|
|
||||||
"ab".repeat(32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,555 +0,0 @@
|
|||||||
pub mod pins;
|
|
||||||
pub mod roles;
|
|
||||||
|
|
||||||
use std::cmp::Reverse;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
use data_encoding::HEXLOWER;
|
|
||||||
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use crate::cord01::build_rumor_secs;
|
|
||||||
use crate::decode_hex_32;
|
|
||||||
|
|
||||||
pub const KIND_CONTROL: u16 = 3308;
|
|
||||||
|
|
||||||
const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";
|
|
||||||
|
|
||||||
/// Entity types an edition can address.
|
|
||||||
pub mod vsk {
|
|
||||||
pub const COMMUNITY_METADATA: &str = "0";
|
|
||||||
pub const ROLE: &str = "1";
|
|
||||||
pub const CHANNEL_METADATA: &str = "2";
|
|
||||||
pub const GRANT: &str = "3";
|
|
||||||
pub const BANLIST: &str = "4";
|
|
||||||
pub const INVITE_LIVE: &str = "6";
|
|
||||||
pub const INVITE_LINKS: &str = "8";
|
|
||||||
pub const INVITE_REVOKED: &str = "9";
|
|
||||||
pub const DISSOLVED: &str = "10";
|
|
||||||
pub const PINS: &str = "11";
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const TAG_SUBKIND: &str = "vsk";
|
|
||||||
pub const TAG_CITATION: &str = "vac";
|
|
||||||
|
|
||||||
const TAG_ENTITY: &str = "eid";
|
|
||||||
const TAG_VERSION: &str = "ev";
|
|
||||||
const TAG_PREV: &str = "ep";
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum EditionError {
|
|
||||||
BadKind(u16),
|
|
||||||
BadField(&'static str),
|
|
||||||
Duplicate(&'static str),
|
|
||||||
Missing(&'static str),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for EditionError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
EditionError::BadKind(kind) => write!(f, "not an edition kind: {kind}"),
|
|
||||||
EditionError::BadField(name) => write!(f, "malformed edition field: {name}"),
|
|
||||||
EditionError::Duplicate(name) => write!(f, "duplicate edition field: {name}"),
|
|
||||||
EditionError::Missing(name) => write!(f, "missing edition field: {name}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for EditionError {}
|
|
||||||
|
|
||||||
/// A `vac`: the Grant edition an actor claims rank under, pinned by coordinate, version and hash.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct AuthorityCitation {
|
|
||||||
pub entity: [u8; 32],
|
|
||||||
pub version: u64,
|
|
||||||
pub hash: [u8; 32],
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ParsedEdition {
|
|
||||||
pub author: PublicKey,
|
|
||||||
pub subkind: String,
|
|
||||||
pub entity: [u8; 32],
|
|
||||||
pub version: u64,
|
|
||||||
pub prev: Option<[u8; 32]>,
|
|
||||||
pub citation: Option<AuthorityCitation>,
|
|
||||||
pub content: String,
|
|
||||||
pub self_hash: [u8; 32],
|
|
||||||
pub rumor_id: EventId,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct EditionFields<'a> {
|
|
||||||
pub author: PublicKey,
|
|
||||||
pub subkind: &'a str,
|
|
||||||
pub entity: [u8; 32],
|
|
||||||
pub version: u64,
|
|
||||||
pub prev: Option<[u8; 32]>,
|
|
||||||
pub citation: Option<AuthorityCitation>,
|
|
||||||
pub content: &'a str,
|
|
||||||
pub at_secs: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn signing_bytes(
|
|
||||||
entity: &[u8; 32],
|
|
||||||
version: u64,
|
|
||||||
prev: Option<&[u8; 32]>,
|
|
||||||
content: &[u8],
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let mut bytes =
|
|
||||||
Vec::with_capacity(8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + content.len());
|
|
||||||
|
|
||||||
bytes.extend_from_slice(&(EDITION_LABEL.len() as u64).to_be_bytes());
|
|
||||||
bytes.extend_from_slice(EDITION_LABEL);
|
|
||||||
bytes.extend_from_slice(entity);
|
|
||||||
bytes.extend_from_slice(&version.to_be_bytes());
|
|
||||||
|
|
||||||
match prev {
|
|
||||||
Some(prev) => {
|
|
||||||
bytes.push(1);
|
|
||||||
bytes.extend_from_slice(prev);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
bytes.push(0);
|
|
||||||
bytes.extend_from_slice(&[0u8; 32]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bytes.extend_from_slice(&(content.len() as u64).to_be_bytes());
|
|
||||||
bytes.extend_from_slice(content);
|
|
||||||
bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
fn edition_hash(
|
|
||||||
entity: &[u8; 32],
|
|
||||||
version: u64,
|
|
||||||
prev: Option<&[u8; 32]>,
|
|
||||||
content: &[u8],
|
|
||||||
) -> [u8; 32] {
|
|
||||||
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn citation_tag(citation: &AuthorityCitation) -> Tag {
|
|
||||||
Tag::custom(
|
|
||||||
TAG_CITATION,
|
|
||||||
[
|
|
||||||
HEXLOWER.encode(&citation.entity),
|
|
||||||
citation.version.to_string(),
|
|
||||||
HEXLOWER.encode(&citation.hash),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn citation_from(fields: &[String]) -> Option<AuthorityCitation> {
|
|
||||||
if fields.len() != 4 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(AuthorityCitation {
|
|
||||||
entity: hex32(&fields[1], TAG_CITATION).ok()?,
|
|
||||||
version: canonical_decimal(&fields[2])?,
|
|
||||||
hash: hex32(&fields[3], TAG_CITATION).ok()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
|
|
||||||
let mut tags = vec![
|
|
||||||
Tag::custom(TAG_SUBKIND, [fields.subkind]),
|
|
||||||
Tag::custom(TAG_ENTITY, [HEXLOWER.encode(&fields.entity)]),
|
|
||||||
Tag::custom(TAG_VERSION, [fields.version.to_string()]),
|
|
||||||
];
|
|
||||||
|
|
||||||
if let Some(prev) = fields.prev {
|
|
||||||
tags.push(Tag::custom(TAG_PREV, [HEXLOWER.encode(&prev)]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(citation) = fields.citation {
|
|
||||||
tags.push(citation_tag(&citation));
|
|
||||||
}
|
|
||||||
|
|
||||||
build_rumor_secs(
|
|
||||||
KIND_CONTROL,
|
|
||||||
fields.author,
|
|
||||||
fields.content,
|
|
||||||
tags,
|
|
||||||
fields.at_secs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError> {
|
|
||||||
let kind = rumor.kind.as_u16();
|
|
||||||
|
|
||||||
if kind != KIND_CONTROL {
|
|
||||||
return Err(EditionError::BadKind(kind));
|
|
||||||
}
|
|
||||||
|
|
||||||
let subkind = value(rumor, TAG_SUBKIND)?
|
|
||||||
.ok_or(EditionError::Missing(TAG_SUBKIND))?
|
|
||||||
.to_owned();
|
|
||||||
|
|
||||||
if canonical_decimal(&subkind).is_none() {
|
|
||||||
return Err(EditionError::BadField(TAG_SUBKIND));
|
|
||||||
}
|
|
||||||
|
|
||||||
let entity = hex32(
|
|
||||||
value(rumor, TAG_ENTITY)?.ok_or(EditionError::Missing(TAG_ENTITY))?,
|
|
||||||
TAG_ENTITY,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let version =
|
|
||||||
canonical_decimal(value(rumor, TAG_VERSION)?.ok_or(EditionError::Missing(TAG_VERSION))?)
|
|
||||||
.ok_or(EditionError::BadField(TAG_VERSION))?;
|
|
||||||
|
|
||||||
let prev = match value(rumor, TAG_PREV)? {
|
|
||||||
Some(raw) => Some(hex32(raw, TAG_PREV)?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let citation = match fields(rumor, TAG_CITATION)? {
|
|
||||||
Some(fields) => Some(citation_from(fields).ok_or(EditionError::BadField(TAG_CITATION))?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let self_hash = edition_hash(&entity, version, prev.as_ref(), rumor.content.as_bytes());
|
|
||||||
|
|
||||||
Ok(ParsedEdition {
|
|
||||||
author: rumor.pubkey,
|
|
||||||
subkind,
|
|
||||||
entity,
|
|
||||||
version,
|
|
||||||
prev,
|
|
||||||
citation,
|
|
||||||
content: rumor.content.clone(),
|
|
||||||
self_hash,
|
|
||||||
rumor_id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct EditionMeta {
|
|
||||||
pub version: u64,
|
|
||||||
pub self_hash: [u8; 32],
|
|
||||||
pub prev: Option<[u8; 32]>,
|
|
||||||
pub tiebreak_id: EventId,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&ParsedEdition> for EditionMeta {
|
|
||||||
fn from(edition: &ParsedEdition) -> Self {
|
|
||||||
Self {
|
|
||||||
version: edition.version,
|
|
||||||
self_hash: edition.self_hash,
|
|
||||||
prev: edition.prev,
|
|
||||||
tiebreak_id: edition.rumor_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
struct FoldResult {
|
|
||||||
pub head: Option<usize>,
|
|
||||||
pub gap: bool,
|
|
||||||
pub anchored: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The highest version whose chain is intact, given a held floor.
|
|
||||||
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() {
|
|
||||||
if edition.version < floor {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match by_version.get(&edition.version) {
|
|
||||||
Some(¤t) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
|
|
||||||
_ => {
|
|
||||||
by_version.insert(edition.version, index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
|
|
||||||
return FoldResult::default();
|
|
||||||
};
|
|
||||||
|
|
||||||
let lowest = editions[lowest_index];
|
|
||||||
|
|
||||||
let anchored = if floor == 0 {
|
|
||||||
lowest_version == 1 && lowest.prev.is_none()
|
|
||||||
} else if lowest_version == floor {
|
|
||||||
floor_hash == Some(&lowest.self_hash)
|
|
||||||
} else if lowest_version == floor + 1 {
|
|
||||||
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut head = Some(lowest_index);
|
|
||||||
let mut gap = !anchored;
|
|
||||||
let mut previous_version = lowest_version;
|
|
||||||
let mut previous_hash = lowest.self_hash;
|
|
||||||
|
|
||||||
for (&version, &index) in by_version.range(lowest_version + 1..) {
|
|
||||||
let edition = editions[index];
|
|
||||||
|
|
||||||
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
|
|
||||||
head = Some(index);
|
|
||||||
previous_version = version;
|
|
||||||
previous_hash = edition.self_hash;
|
|
||||||
} else {
|
|
||||||
gap = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FoldResult {
|
|
||||||
head,
|
|
||||||
gap,
|
|
||||||
anchored,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The highest version overall, ignoring contiguity.
|
|
||||||
fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
|
||||||
editions
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.min_by_key(|(_, edition)| (Reverse(edition.version), edition.tiebreak_id))
|
|
||||||
.map(|(index, _)| index)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub struct HeadSelection {
|
|
||||||
pub head: Option<usize>,
|
|
||||||
pub gap: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The head to prefer for one entity, given what this client already committed to.
|
|
||||||
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection {
|
|
||||||
let Some(floor) = floor else {
|
|
||||||
return HeadSelection {
|
|
||||||
head: bootstrap_head(editions),
|
|
||||||
gap: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
let anchored = fold(editions, floor.version, Some(&floor.self_hash));
|
|
||||||
|
|
||||||
if anchored.anchored {
|
|
||||||
return HeadSelection {
|
|
||||||
head: anchored.head,
|
|
||||||
gap: anchored.gap,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if anchored.head.is_none() && !anchored.gap {
|
|
||||||
return HeadSelection::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
let fork = editions
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, edition)| edition.version == floor.version)
|
|
||||||
.min_by_key(|(_, edition)| edition.tiebreak_id);
|
|
||||||
|
|
||||||
let winner = match fork {
|
|
||||||
Some((_, edition))
|
|
||||||
if edition.self_hash != floor.self_hash && edition.tiebreak_id < floor.rumor_id =>
|
|
||||||
{
|
|
||||||
edition.self_hash
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return HeadSelection {
|
|
||||||
head: None,
|
|
||||||
gap: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let refolded = fold(editions, floor.version, Some(&winner));
|
|
||||||
|
|
||||||
if refolded.anchored {
|
|
||||||
HeadSelection {
|
|
||||||
head: refolded.head,
|
|
||||||
gap: refolded.gap,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HeadSelection {
|
|
||||||
head: None,
|
|
||||||
gap: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A committed head, and the refuse-downgrade floor a later fold is judged against.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct EntityHead {
|
|
||||||
pub entity: [u8; 32],
|
|
||||||
pub version: u64,
|
|
||||||
pub self_hash: [u8; 32],
|
|
||||||
pub rumor_id: EventId,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&ParsedEdition> for EntityHead {
|
|
||||||
fn from(edition: &ParsedEdition) -> Self {
|
|
||||||
Self {
|
|
||||||
entity: edition.entity,
|
|
||||||
version: edition.version,
|
|
||||||
self_hash: edition.self_hash,
|
|
||||||
rumor_id: edition.rumor_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every entity's committed head, keyed by coordinate.
|
|
||||||
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
|
|
||||||
|
|
||||||
pub(crate) fn canonical_decimal(raw: &str) -> Option<u64> {
|
|
||||||
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if raw.len() > 1 && raw.starts_with('0') {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
raw.parse().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hex32(raw: &str, name: &'static str) -> Result<[u8; 32], EditionError> {
|
|
||||||
decode_hex_32(raw).map_err(|_| EditionError::BadField(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fields<'a>(
|
|
||||||
rumor: &'a UnsignedEvent,
|
|
||||||
name: &'static str,
|
|
||||||
) -> Result<Option<&'a [String]>, EditionError> {
|
|
||||||
let mut found: Option<&[String]> = None;
|
|
||||||
|
|
||||||
for tag in rumor.tags.iter() {
|
|
||||||
let tag_fields = tag.as_slice();
|
|
||||||
|
|
||||||
if tag_fields.first().map(String::as_str) != Some(name) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if found.is_some() {
|
|
||||||
return Err(EditionError::Duplicate(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
found = Some(tag_fields);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(found)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value<'a>(
|
|
||||||
rumor: &'a UnsignedEvent,
|
|
||||||
name: &'static str,
|
|
||||||
) -> Result<Option<&'a str>, EditionError> {
|
|
||||||
match fields(rumor, name)? {
|
|
||||||
Some(fields) if fields.len() == 2 => Ok(Some(fields[1].as_str())),
|
|
||||||
Some(_) => Err(EditionError::BadField(name)),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn meta(version: u64, prev: Option<[u8; 32]>, hash: u8, tiebreak: u8) -> EditionMeta {
|
|
||||||
EditionMeta {
|
|
||||||
version,
|
|
||||||
self_hash: [hash; 32],
|
|
||||||
prev,
|
|
||||||
tiebreak_id: EventId::from_byte_array([tiebreak; 32]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn head(version: u64, hash: u8, rumor: u8) -> EntityHead {
|
|
||||||
EntityHead {
|
|
||||||
entity: [0x11; 32],
|
|
||||||
version,
|
|
||||||
self_hash: [hash; 32],
|
|
||||||
rumor_id: EventId::from_byte_array([rumor; 32]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fold_picks_the_head_from_the_chain_and_the_floor() {
|
|
||||||
let chain = [
|
|
||||||
meta(1, None, 0xa1, 1),
|
|
||||||
meta(2, Some([0xa1; 32]), 0xa2, 2),
|
|
||||||
meta(3, Some([0xa2; 32]), 0xa3, 3),
|
|
||||||
];
|
|
||||||
|
|
||||||
let folded = fold(&chain, 0, None);
|
|
||||||
assert_eq!(folded.head, Some(2));
|
|
||||||
assert!(!folded.gap && folded.anchored);
|
|
||||||
|
|
||||||
// A missing link stops the walk at the last contiguous edition.
|
|
||||||
let gapped = fold(&[chain[0], chain[2]], 0, None);
|
|
||||||
assert_eq!(gapped.head, Some(0));
|
|
||||||
assert!(gapped.gap && gapped.anchored);
|
|
||||||
|
|
||||||
// Everything below the held floor is a stale relay, not a gap.
|
|
||||||
let stale = fold(&chain[..2], 3, Some(&[0xa3; 32]));
|
|
||||||
assert_eq!(stale.head, None);
|
|
||||||
assert!(!stale.gap && !stale.anchored);
|
|
||||||
|
|
||||||
// A fork at a version breaks on the lower inner rumor id, and the chain resumes.
|
|
||||||
let fork = [meta(1, None, 0xb1, 9), meta(1, None, 0xa1, 1)];
|
|
||||||
assert_eq!(
|
|
||||||
fold(&fork, 0, None).head,
|
|
||||||
Some(1),
|
|
||||||
"the lower rumor id wins"
|
|
||||||
);
|
|
||||||
let forked = [fork[0], fork[1], chain[1], chain[2]];
|
|
||||||
assert_eq!(fold(&forked, 0, None).head, Some(3));
|
|
||||||
|
|
||||||
// A re-wrap onto the head we hold is the legitimate case; one whose `prev` no
|
|
||||||
// longer resolves is a withholding.
|
|
||||||
let rewrapped = meta(5, Some([0x99; 32]), 0xc5, 5);
|
|
||||||
assert_eq!(
|
|
||||||
fold_head(&[rewrapped], Some(&head(4, 0x99, 4))).head,
|
|
||||||
Some(0)
|
|
||||||
);
|
|
||||||
let dangling = meta(5, Some([0x88; 32]), 0xc5, 5);
|
|
||||||
let refused = fold_head(&[dangling], Some(&head(4, 0x99, 4)));
|
|
||||||
assert_eq!(refused.head, None);
|
|
||||||
assert!(refused.gap);
|
|
||||||
|
|
||||||
// A bootstrap takes it anyway: a compaction would leave a joiner with nothing.
|
|
||||||
assert_eq!(bootstrap_head(&[dangling]), Some(0));
|
|
||||||
assert_eq!(fold_head(&[dangling], None).head, Some(0));
|
|
||||||
|
|
||||||
// A fork at the floor's own version converges to the lower rumor id when that is
|
|
||||||
// genuinely earlier than what we hold, and the chain above it re-anchors.
|
|
||||||
let forked = [
|
|
||||||
meta(2, Some([0xa1; 32]), 0xb2, 3),
|
|
||||||
meta(3, Some([0xb2; 32]), 0xb3, 4),
|
|
||||||
];
|
|
||||||
let converged = fold_head(&forked, Some(&head(2, 0xaa, 9)));
|
|
||||||
assert_eq!(converged.head, Some(1));
|
|
||||||
assert!(!converged.gap);
|
|
||||||
|
|
||||||
// A fork that is not earlier than the held head is refused.
|
|
||||||
assert_eq!(fold_head(&forked, Some(&head(2, 0xaa, 2))).head, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn edition_hash_matches_the_cross_client_vector() {
|
|
||||||
let entity = [0x11u8; 32];
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
HEXLOWER.encode(&edition_hash(&entity, 1, None, b"hello")),
|
|
||||||
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The golden vector only exercises the absent-prev encoding, so pin the flag.
|
|
||||||
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
|
|
||||||
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,819 +0,0 @@
|
|||||||
use std::fmt;
|
|
||||||
|
|
||||||
use chacha20::ChaCha20;
|
|
||||||
use chacha20::cipher::{KeyIvInit, StreamCipher};
|
|
||||||
use data_encoding::{BASE64, HEXLOWER};
|
|
||||||
use hkdf::Hkdf;
|
|
||||||
use hmac::{Hmac, Mac};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::Sha256;
|
|
||||||
|
|
||||||
use crate::cord01::{self, OpenedStream, SealForm, resolve_ms_strict};
|
|
||||||
use crate::cord03::{ChatAction, ChatRumor, KIND_COMMENT, KIND_EDIT, KIND_MESSAGE};
|
|
||||||
use crate::cord04::canonical_decimal;
|
|
||||||
use crate::{ChannelId, Epoch, Extra, GroupKey, decode_hex_lower};
|
|
||||||
|
|
||||||
pub const PIN_MAX_ENTRIES: usize = 25;
|
|
||||||
pub const PIN_MAX_CONTENT_BYTES: usize = 32_768;
|
|
||||||
|
|
||||||
/// The serialized disclosure: `chacha_key[32] || chacha_nonce[12] || hmac_key[32]`.
|
|
||||||
pub const MESSAGE_KEYS_BYTES: usize = 76;
|
|
||||||
|
|
||||||
const TAG_CHANNEL: &str = "channel";
|
|
||||||
const TAG_EPOCH: &str = "epoch";
|
|
||||||
const TAG_TARGET: &str = "e";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum PinError {
|
|
||||||
NotEncryptedSeal,
|
|
||||||
BadPayload,
|
|
||||||
Unverifiable,
|
|
||||||
Unreadable,
|
|
||||||
TooManyEntries,
|
|
||||||
Oversize(usize),
|
|
||||||
Seal(String),
|
|
||||||
Encode(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for PinError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
PinError::NotEncryptedSeal => write!(f, "pin requires an encrypted seal"),
|
|
||||||
PinError::BadPayload => write!(f, "the seal payload does not open"),
|
|
||||||
PinError::Unverifiable => write!(f, "the entry would not verify"),
|
|
||||||
PinError::Unreadable => {
|
|
||||||
write!(f, "refusing to publish a pin list this client cannot read")
|
|
||||||
}
|
|
||||||
PinError::TooManyEntries => write!(f, "pin list exceeds {PIN_MAX_ENTRIES} entries"),
|
|
||||||
PinError::Oversize(len) => {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"pin list content is {len} bytes (cap {PIN_MAX_CONTENT_BYTES})"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
PinError::Seal(error) => write!(f, "seal: {error}"),
|
|
||||||
PinError::Encode(error) => write!(f, "encode: {error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for PinError {}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct MessageKeys {
|
|
||||||
chacha_key: [u8; 32],
|
|
||||||
chacha_nonce: [u8; 12],
|
|
||||||
hmac_key: [u8; 32],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MessageKeys {
|
|
||||||
pub fn to_hex(&self) -> String {
|
|
||||||
let mut packed = [0u8; MESSAGE_KEYS_BYTES];
|
|
||||||
packed[0..32].copy_from_slice(&self.chacha_key);
|
|
||||||
packed[32..44].copy_from_slice(&self.chacha_nonce);
|
|
||||||
packed[44..76].copy_from_slice(&self.hmac_key);
|
|
||||||
HEXLOWER.encode(&packed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_hex(value: &str) -> Option<Self> {
|
|
||||||
let bytes = decode_hex_lower::<MESSAGE_KEYS_BYTES>(value).ok()?;
|
|
||||||
|
|
||||||
Some(Self {
|
|
||||||
chacha_key: bytes[0..32].try_into().ok()?,
|
|
||||||
chacha_nonce: bytes[32..44].try_into().ok()?,
|
|
||||||
hmac_key: bytes[44..76].try_into().ok()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn derive(conversation_key: &[u8; 32], nonce: &[u8]) -> Option<Self> {
|
|
||||||
let hkdf = Hkdf::<Sha256>::from_prk(conversation_key).ok()?;
|
|
||||||
let mut key_material = [0u8; MESSAGE_KEYS_BYTES];
|
|
||||||
hkdf.expand(nonce, &mut key_material).ok()?;
|
|
||||||
|
|
||||||
Some(Self {
|
|
||||||
chacha_key: key_material[0..32].try_into().ok()?,
|
|
||||||
chacha_nonce: key_material[32..44].try_into().ok()?,
|
|
||||||
hmac_key: key_material[44..76].try_into().ok()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Payload {
|
|
||||||
nonce: [u8; 32],
|
|
||||||
ciphertext: Vec<u8>,
|
|
||||||
mac: [u8; 32],
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_payload(payload: &str) -> Option<Payload> {
|
|
||||||
let data = BASE64.decode(payload.as_bytes()).ok()?;
|
|
||||||
|
|
||||||
if data.len() < 99 || data[0] != 2 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mac_at = data.len() - 32;
|
|
||||||
|
|
||||||
Some(Payload {
|
|
||||||
nonce: data[1..33].try_into().ok()?,
|
|
||||||
ciphertext: data[33..mac_at].to_vec(),
|
|
||||||
mac: data[mac_at..].try_into().ok()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn disclose_keys(payload: &str, conversation_key: &[u8; 32]) -> Option<MessageKeys> {
|
|
||||||
let decoded = decode_payload(payload)?;
|
|
||||||
MessageKeys::derive(conversation_key, &decoded.nonce)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_payload(payload: &str, keys: &MessageKeys) -> Option<String> {
|
|
||||||
let decoded = decode_payload(payload)?;
|
|
||||||
|
|
||||||
let mut mac = Hmac::<Sha256>::new_from_slice(&keys.hmac_key).ok()?;
|
|
||||||
mac.update(&decoded.nonce);
|
|
||||||
mac.update(&decoded.ciphertext);
|
|
||||||
mac.verify_slice(&decoded.mac).ok()?;
|
|
||||||
|
|
||||||
let mut padded = decoded.ciphertext;
|
|
||||||
let mut cipher = ChaCha20::new((&keys.chacha_key).into(), (&keys.chacha_nonce).into());
|
|
||||||
cipher.apply_keystream(&mut padded);
|
|
||||||
|
|
||||||
unpad(&padded)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unpad(padded: &[u8]) -> Option<String> {
|
|
||||||
let (len, prefix) = plaintext_length(padded)?;
|
|
||||||
let unpadded = padded.get(prefix..prefix.checked_add(len)?)?;
|
|
||||||
|
|
||||||
if len < 1 || padded.len() != prefix.checked_add(padded_len(len)?)? {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
String::from_utf8(unpadded.to_vec()).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn plaintext_length(padded: &[u8]) -> Option<(usize, usize)> {
|
|
||||||
let short = u16::from_be_bytes(padded.get(..2)?.try_into().ok()?);
|
|
||||||
|
|
||||||
if short != 0 {
|
|
||||||
return Some((short as usize, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
let long = u32::from_be_bytes(padded.get(2..6)?.try_into().ok()?);
|
|
||||||
|
|
||||||
if long < 65_536 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some((long as usize, 6))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn padded_len(len: usize) -> Option<usize> {
|
|
||||||
if len < 1 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if len <= 32 {
|
|
||||||
return Some(32);
|
|
||||||
}
|
|
||||||
|
|
||||||
let next_power = 1usize.checked_shl(usize::BITS - (len - 1).leading_zeros())?;
|
|
||||||
let chunk = if next_power <= 256 {
|
|
||||||
32
|
|
||||||
} else {
|
|
||||||
next_power / 8
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(chunk * ((len - 1) / chunk + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct PinEditBundle {
|
|
||||||
pub seal: Event,
|
|
||||||
pub keys: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct PinEntry {
|
|
||||||
pub seal: Event,
|
|
||||||
pub keys: String,
|
|
||||||
/// An unverifiable locator hint; a mismatch is expected and never fatal.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub wrap: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub edit: Option<PinEditBundle>,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub extra: Extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct EditedContent {
|
|
||||||
pub content: String,
|
|
||||||
pub at_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct VerifiedPin {
|
|
||||||
pub rumor_id: EventId,
|
|
||||||
pub author: PublicKey,
|
|
||||||
pub kind: u16,
|
|
||||||
pub content: String,
|
|
||||||
pub tags: Tags,
|
|
||||||
pub epoch: Epoch,
|
|
||||||
pub at_ms: u64,
|
|
||||||
pub created_at: u64,
|
|
||||||
pub wrap: Option<String>,
|
|
||||||
pub edited: Option<EditedContent>,
|
|
||||||
pub entry: PinEntry,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct ReadPinList {
|
|
||||||
pub entries: Vec<PinEntry>,
|
|
||||||
pub sealed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_entry(
|
|
||||||
opened: &OpenedStream,
|
|
||||||
group: &GroupKey,
|
|
||||||
channel: &ChannelId,
|
|
||||||
) -> Result<PinEntry, PinError> {
|
|
||||||
let keys = disclosed_keys(opened, group)?;
|
|
||||||
|
|
||||||
let entry = PinEntry {
|
|
||||||
seal: opened.seal.clone(),
|
|
||||||
keys: keys.to_hex(),
|
|
||||||
wrap: Some(opened.wrapper_id.to_hex()),
|
|
||||||
edit: None,
|
|
||||||
extra: Extra::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if verify_entry(&entry, channel).is_none() {
|
|
||||||
return Err(PinError::Unverifiable);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_edit_bundle(
|
|
||||||
edit: &OpenedStream,
|
|
||||||
group: &GroupKey,
|
|
||||||
original: &VerifiedPin,
|
|
||||||
channel: &ChannelId,
|
|
||||||
) -> Result<PinEditBundle, PinError> {
|
|
||||||
let bundle = PinEditBundle {
|
|
||||||
seal: edit.seal.clone(),
|
|
||||||
keys: disclosed_keys(edit, group)?.to_hex(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if verify_edit_bundle(&bundle, &original.author, &original.rumor_id, channel).is_none() {
|
|
||||||
return Err(PinError::Unverifiable);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(bundle)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_proven_edit(
|
|
||||||
entry: &PinEntry,
|
|
||||||
edit: &OpenedStream,
|
|
||||||
group: &GroupKey,
|
|
||||||
channel: &ChannelId,
|
|
||||||
) -> PinEntry {
|
|
||||||
let Some(original) = verify_entry(entry, channel) else {
|
|
||||||
return entry.clone();
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(bundle) = build_edit_bundle(edit, group, &original, channel) else {
|
|
||||||
return entry.clone();
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut refreshed = entry.clone();
|
|
||||||
refreshed.edit = Some(bundle);
|
|
||||||
refreshed
|
|
||||||
}
|
|
||||||
|
|
||||||
fn disclosed_keys(opened: &OpenedStream, group: &GroupKey) -> Result<MessageKeys, PinError> {
|
|
||||||
if opened.seal_form != SealForm::Encrypted {
|
|
||||||
return Err(PinError::NotEncryptedSeal);
|
|
||||||
}
|
|
||||||
|
|
||||||
let conversation: [u8; 32] = group
|
|
||||||
.conversation()
|
|
||||||
.as_bytes()
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| PinError::BadPayload)?;
|
|
||||||
|
|
||||||
let keys = disclose_keys(&opened.seal.content, &conversation).ok_or(PinError::BadPayload)?;
|
|
||||||
|
|
||||||
if open_payload(&opened.seal.content, &keys).is_none() {
|
|
||||||
return Err(PinError::BadPayload);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify_entry(entry: &PinEntry, channel: &ChannelId) -> Option<VerifiedPin> {
|
|
||||||
let seal = &entry.seal;
|
|
||||||
|
|
||||||
if seal.kind.as_u16() != cord01::KIND_SEAL_ENCRYPTED || seal.verify().is_err() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let keys = MessageKeys::from_hex(&entry.keys)?;
|
|
||||||
let plaintext = open_payload(&seal.content, &keys)?;
|
|
||||||
let rumor = UnsignedEvent::from_json(&plaintext).ok()?;
|
|
||||||
|
|
||||||
// NIP-59's impersonation check: the renderer shows the rumor's fields.
|
|
||||||
if rumor.pubkey != seal.pubkey {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let kind = rumor.kind.as_u16();
|
|
||||||
|
|
||||||
if kind != KIND_MESSAGE && kind != KIND_COMMENT {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORD-01's binding, restated: a keyholder must not pin a message into another Channel's list.
|
|
||||||
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let epoch = Epoch(canonical_decimal(tag_value(&rumor, TAG_EPOCH)?)?);
|
|
||||||
|
|
||||||
// Recomputed from the decrypted bytes; a claimed `id` is never trusted.
|
|
||||||
rumor.verify_id().ok()?;
|
|
||||||
let rumor_id = rumor.compute_id();
|
|
||||||
|
|
||||||
let edited = entry
|
|
||||||
.edit
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|bundle| verify_edit_bundle(bundle, &rumor.pubkey, &rumor_id, channel));
|
|
||||||
|
|
||||||
Some(VerifiedPin {
|
|
||||||
author: rumor.pubkey,
|
|
||||||
content: edited
|
|
||||||
.as_ref()
|
|
||||||
.map_or_else(|| rumor.content.clone(), |edited| edited.content.clone()),
|
|
||||||
epoch,
|
|
||||||
at_ms: resolve_ms_strict(&rumor).ok()?,
|
|
||||||
created_at: rumor.created_at.as_secs(),
|
|
||||||
tags: rumor.tags.clone(),
|
|
||||||
wrap: entry.wrap.clone(),
|
|
||||||
edited,
|
|
||||||
entry: entry.clone(),
|
|
||||||
kind,
|
|
||||||
rumor_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_edit_bundle(
|
|
||||||
bundle: &PinEditBundle,
|
|
||||||
original_author: &PublicKey,
|
|
||||||
original_id: &EventId,
|
|
||||||
channel: &ChannelId,
|
|
||||||
) -> Option<EditedContent> {
|
|
||||||
let seal = &bundle.seal;
|
|
||||||
|
|
||||||
// Checkable before any crypto: nobody else may revise another member's words.
|
|
||||||
if seal.kind.as_u16() != cord01::KIND_SEAL_ENCRYPTED || seal.pubkey != *original_author {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if seal.verify().is_err() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let keys = MessageKeys::from_hex(&bundle.keys)?;
|
|
||||||
let plaintext = open_payload(&seal.content, &keys)?;
|
|
||||||
let rumor = UnsignedEvent::from_json(&plaintext).ok()?;
|
|
||||||
|
|
||||||
if rumor.pubkey != seal.pubkey || rumor.kind.as_u16() != KIND_EDIT {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if tag_value(&rumor, TAG_TARGET)? != original_id.to_hex() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
rumor.verify_id().ok()?;
|
|
||||||
|
|
||||||
Some(EditedContent {
|
|
||||||
content: rumor.content.clone(),
|
|
||||||
at_ms: resolve_ms_strict(&rumor).ok()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tag_value<'a>(rumor: &'a UnsignedEvent, name: &str) -> Option<&'a str> {
|
|
||||||
rumor
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find(|tag| tag.as_slice().first().map(String::as_str) == Some(name))
|
|
||||||
.and_then(|tag| tag.as_slice().get(1))
|
|
||||||
.map(String::as_str)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct PlainForm {
|
|
||||||
entries: Vec<PinEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn publishable(
|
|
||||||
read: &ReadPinList,
|
|
||||||
private: bool,
|
|
||||||
group: &GroupKey,
|
|
||||||
epoch: Epoch,
|
|
||||||
) -> Result<String, PinError> {
|
|
||||||
if read.sealed {
|
|
||||||
return Err(PinError::Unreadable);
|
|
||||||
}
|
|
||||||
|
|
||||||
if private {
|
|
||||||
serialize_sealed(&read.entries, group, epoch)
|
|
||||||
} else {
|
|
||||||
serialize_public(&read.entries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_public(entries: &[PinEntry]) -> Result<String, PinError> {
|
|
||||||
let content = encode_form(entries)?;
|
|
||||||
check_caps(entries.len(), &content)?;
|
|
||||||
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_sealed(
|
|
||||||
entries: &[PinEntry],
|
|
||||||
group: &GroupKey,
|
|
||||||
epoch: Epoch,
|
|
||||||
) -> Result<String, PinError> {
|
|
||||||
if entries.len() > PIN_MAX_ENTRIES {
|
|
||||||
return Err(PinError::TooManyEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
let inner = encode_form(entries)?;
|
|
||||||
let sealed = cord01::seal_bytes(group.conversation(), inner.as_bytes())
|
|
||||||
.map_err(|error| PinError::Seal(error.to_string()))?;
|
|
||||||
let content = serde_json::json!({ "epoch": epoch.to_string(), "sealed": sealed }).to_string();
|
|
||||||
|
|
||||||
check_caps(entries.len(), &content)?;
|
|
||||||
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_form(entries: &[PinEntry]) -> Result<String, PinError> {
|
|
||||||
serde_json::to_string(&PlainForm {
|
|
||||||
entries: entries.to_vec(),
|
|
||||||
})
|
|
||||||
.map_err(|error| PinError::Encode(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_caps(count: usize, content: &str) -> Result<(), PinError> {
|
|
||||||
if count > PIN_MAX_ENTRIES {
|
|
||||||
return Err(PinError::TooManyEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
if content.len() > PIN_MAX_CONTENT_BYTES {
|
|
||||||
return Err(PinError::Oversize(content.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_list(content: &str, unseal: impl Fn(Epoch) -> Option<GroupKey>) -> ReadPinList {
|
|
||||||
const EMPTY: ReadPinList = ReadPinList {
|
|
||||||
entries: Vec::new(),
|
|
||||||
sealed: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if content.len() > PIN_MAX_CONTENT_BYTES {
|
|
||||||
return EMPTY;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
|
|
||||||
return EMPTY;
|
|
||||||
};
|
|
||||||
|
|
||||||
if value.get("entries").is_some() {
|
|
||||||
return match serde_json::from_value::<PlainForm>(value) {
|
|
||||||
Ok(form) if form.entries.len() <= PIN_MAX_ENTRIES => ReadPinList {
|
|
||||||
entries: form.entries,
|
|
||||||
sealed: false,
|
|
||||||
},
|
|
||||||
_ => EMPTY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let (Some(epoch), Some(sealed)) = (
|
|
||||||
value.get("epoch").and_then(serde_json::Value::as_str),
|
|
||||||
value.get("sealed").and_then(serde_json::Value::as_str),
|
|
||||||
) else {
|
|
||||||
return EMPTY;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(epoch) = canonical_decimal(epoch) else {
|
|
||||||
return EMPTY;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(group) = unseal(Epoch(epoch)) else {
|
|
||||||
return ReadPinList {
|
|
||||||
sealed: true,
|
|
||||||
..EMPTY
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(inner) = cord01::open_bytes(group.conversation(), sealed) else {
|
|
||||||
return EMPTY;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(form) = serde_json::from_slice::<PlainForm>(&inner) else {
|
|
||||||
return EMPTY;
|
|
||||||
};
|
|
||||||
|
|
||||||
if form.entries.len() > PIN_MAX_ENTRIES {
|
|
||||||
return EMPTY;
|
|
||||||
}
|
|
||||||
|
|
||||||
ReadPinList {
|
|
||||||
entries: form.entries,
|
|
||||||
sealed: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn killed_by(pin: &VerifiedPin, delete: &ChatRumor) -> bool {
|
|
||||||
delete.author == pin.author
|
|
||||||
&& matches!(&delete.action, ChatAction::Delete { target, .. } if *target == pin.rumor_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use nostr::nips::nip44::v2::{self, ConversationKey};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::cord03::{ChatRumor, build_delete, build_edit, build_message, open, seal_rumor};
|
|
||||||
use crate::derive::channel_group_key;
|
|
||||||
|
|
||||||
const AT_MS: u64 = 1_700_000_000_000;
|
|
||||||
const SECRET: [u8; 32] = [0x21u8; 32];
|
|
||||||
|
|
||||||
fn channel() -> ChannelId {
|
|
||||||
ChannelId::from_bytes([0xabu8; 32])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn group() -> GroupKey {
|
|
||||||
channel_group_key(&SECRET, &channel(), Epoch(0)).expect("derives")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn conversation() -> ConversationKey {
|
|
||||||
*group().conversation()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A real message through the production seal/open pipeline, as a pinner sees it.
|
|
||||||
fn sealed_message(author: &Keys, text: &str, at_ms: u64) -> (OpenedStream, ChatRumor) {
|
|
||||||
let rumor = build_message(
|
|
||||||
author.public_key(),
|
|
||||||
&channel(),
|
|
||||||
Epoch(0),
|
|
||||||
text,
|
|
||||||
None,
|
|
||||||
at_ms,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group(), author, false)).expect("seals");
|
|
||||||
|
|
||||||
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn entry_for(author: &Keys, text: &str) -> (PinEntry, OpenedStream) {
|
|
||||||
let (opened, _) = sealed_message(author, text, AT_MS);
|
|
||||||
let entry = build_entry(&opened, &group(), &channel()).expect("builds");
|
|
||||||
(entry, opened)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn some(entries: Vec<PinEntry>) -> ReadPinList {
|
|
||||||
ReadPinList {
|
|
||||||
entries,
|
|
||||||
sealed: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The load-bearing primitive: the reproduction must open what nostr's own
|
|
||||||
/// encryption produced, through the disclosure alone.
|
|
||||||
#[test]
|
|
||||||
fn a_disclosure_opens_its_message_and_nothing_else() {
|
|
||||||
let nonce = [0x5au8; 32];
|
|
||||||
let disclosure =
|
|
||||||
MessageKeys::derive(conversation().as_bytes().try_into().expect("32"), &nonce)
|
|
||||||
.expect("derives");
|
|
||||||
|
|
||||||
for text in ["a", "hello world", &"padding boundary ".repeat(40)] {
|
|
||||||
let raw = v2::encrypt_to_bytes_with_nonce(&conversation(), text.as_bytes(), nonce)
|
|
||||||
.expect("encrypts");
|
|
||||||
let payload = BASE64.encode(&raw);
|
|
||||||
|
|
||||||
assert_eq!(open_payload(&payload, &disclosure).as_deref(), Some(text));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Another nonce discloses different keys, which open nothing else.
|
|
||||||
let other = v2::encrypt_to_bytes_with_nonce(&conversation(), b"second", [0x99u8; 32])
|
|
||||||
.expect("encrypts");
|
|
||||||
assert!(open_payload(&BASE64.encode(&other), &disclosure).is_none());
|
|
||||||
|
|
||||||
let hex = disclosure.to_hex();
|
|
||||||
assert_eq!(
|
|
||||||
MessageKeys::from_hex(&hex).map(|keys| keys.to_hex()),
|
|
||||||
Some(hex.clone())
|
|
||||||
);
|
|
||||||
assert!(MessageKeys::from_hex(&hex.to_uppercase()).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_built_entry_proves_its_author_and_cannot_cross_channels() {
|
|
||||||
let author = Keys::generate();
|
|
||||||
let (entry, opened) = entry_for(&author, "pin me");
|
|
||||||
let verified = verify_entry(&entry, &channel()).expect("verifies");
|
|
||||||
|
|
||||||
assert_eq!(verified.author, author.public_key());
|
|
||||||
assert_eq!(verified.content, "pin me");
|
|
||||||
assert_eq!(verified.rumor_id, opened.rumor_id);
|
|
||||||
assert_eq!(verified.at_ms, AT_MS);
|
|
||||||
assert_eq!(verified.epoch, Epoch(0));
|
|
||||||
|
|
||||||
// A keyholder must not be able to pin channel X's message into Y's list.
|
|
||||||
let foreign = ChannelId::from_bytes([0xcdu8; 32]);
|
|
||||||
assert!(verify_entry(&entry, &foreign).is_none());
|
|
||||||
|
|
||||||
// Tampered keys and a re-signed seal both fail.
|
|
||||||
let mut bad_keys = entry.clone();
|
|
||||||
bad_keys.keys = format!("00{}", &entry.keys[2..]);
|
|
||||||
assert!(verify_entry(&bad_keys, &channel()).is_none());
|
|
||||||
|
|
||||||
let mut forged = entry.clone();
|
|
||||||
forged.seal.pubkey = Keys::generate().public_key();
|
|
||||||
assert!(verify_entry(&forged, &channel()).is_none());
|
|
||||||
|
|
||||||
// A rumor carrying a claimed id that is not its own is refused.
|
|
||||||
let plaintext = cord01::open_bytes(&conversation(), &opened.seal.content).expect("opens");
|
|
||||||
let mut value: serde_json::Value = serde_json::from_slice(&plaintext).expect("json");
|
|
||||||
value["id"] = serde_json::Value::String("00".repeat(32));
|
|
||||||
|
|
||||||
let raw = v2::encrypt_to_bytes_with_nonce(
|
|
||||||
&conversation(),
|
|
||||||
value.to_string().as_bytes(),
|
|
||||||
[0x11u8; 32],
|
|
||||||
)
|
|
||||||
.expect("encrypts");
|
|
||||||
let content = BASE64.encode(&raw);
|
|
||||||
let seal = EventBuilder::new(Kind::Custom(cord01::KIND_SEAL_ENCRYPTED), &content)
|
|
||||||
.custom_created_at(opened.seal.created_at)
|
|
||||||
.finalize(&author)
|
|
||||||
.expect("signs");
|
|
||||||
|
|
||||||
let lying = PinEntry {
|
|
||||||
keys: disclose_keys(&content, conversation().as_bytes().try_into().expect("32"))
|
|
||||||
.expect("discloses")
|
|
||||||
.to_hex(),
|
|
||||||
seal,
|
|
||||||
wrap: None,
|
|
||||||
edit: None,
|
|
||||||
extra: Extra::default(),
|
|
||||||
};
|
|
||||||
assert!(verify_entry(&lying, &channel()).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_proven_edit_replaces_the_words_and_a_stranger_cannot_revise() {
|
|
||||||
let author = Keys::generate();
|
|
||||||
let (entry, original) = entry_for(&author, "teh typo");
|
|
||||||
|
|
||||||
let edit = build_edit(
|
|
||||||
author.public_key(),
|
|
||||||
&channel(),
|
|
||||||
Epoch(0),
|
|
||||||
original.rumor_id,
|
|
||||||
"the typo, fixed",
|
|
||||||
AT_MS + 5_000,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
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());
|
|
||||||
let verified = verify_entry(&refreshed, &channel()).expect("verifies");
|
|
||||||
assert_eq!(verified.content, "the typo, fixed");
|
|
||||||
assert_eq!(verified.edited.expect("edited").at_ms, AT_MS + 5_000);
|
|
||||||
|
|
||||||
// A stranger's edit of the same message never attaches.
|
|
||||||
let stranger = Keys::generate();
|
|
||||||
let hijack = build_edit(
|
|
||||||
stranger.public_key(),
|
|
||||||
&channel(),
|
|
||||||
Epoch(0),
|
|
||||||
original.rumor_id,
|
|
||||||
"hijacked",
|
|
||||||
AT_MS + 6_000,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
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());
|
|
||||||
assert!(unchanged.edit.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn both_list_forms_round_trip_and_obey_their_caps() {
|
|
||||||
let author = Keys::generate();
|
|
||||||
let (entry, _) = entry_for(&author, "hello");
|
|
||||||
|
|
||||||
let public =
|
|
||||||
publishable(&some(vec![entry.clone()]), false, &group(), Epoch(0)).expect("publishes");
|
|
||||||
let read = read_list(&public, |_| None);
|
|
||||||
assert!(!read.sealed);
|
|
||||||
assert_eq!(read.entries.len(), 1);
|
|
||||||
assert!(verify_entry(&read.entries[0], &channel()).is_some());
|
|
||||||
|
|
||||||
// A sealed list stays dark without its key, lights with it, and a wrong
|
|
||||||
// key reads empty rather than panicking.
|
|
||||||
let at_epoch_4 = channel_group_key(&SECRET, &channel(), Epoch(4)).expect("derives");
|
|
||||||
let sealed = publishable(&some(vec![entry.clone()]), true, &at_epoch_4, Epoch(4))
|
|
||||||
.expect("publishes");
|
|
||||||
|
|
||||||
let dark = read_list(&sealed, |_| None);
|
|
||||||
assert!(dark.sealed && dark.entries.is_empty());
|
|
||||||
|
|
||||||
let lit = read_list(&sealed, |epoch| {
|
|
||||||
(epoch == Epoch(4))
|
|
||||||
.then(|| channel_group_key(&SECRET, &channel(), Epoch(4)).expect("derives"))
|
|
||||||
});
|
|
||||||
assert!(!lit.sealed);
|
|
||||||
assert!(verify_entry(&lit.entries[0], &channel()).is_some());
|
|
||||||
|
|
||||||
assert!(read_list(&sealed, |_| Some(group())).entries.is_empty());
|
|
||||||
|
|
||||||
// 26 entries: the writer refuses, and a hand-built violating edition
|
|
||||||
// reads as empty rather than forking the chain.
|
|
||||||
let many = vec![entry; PIN_MAX_ENTRIES + 1];
|
|
||||||
assert_eq!(
|
|
||||||
publishable(&some(many.clone()), false, &group(), Epoch(0)),
|
|
||||||
Err(PinError::TooManyEntries)
|
|
||||||
);
|
|
||||||
let violating = serde_json::json!({ "entries": many }).to_string();
|
|
||||||
assert!(read_list(&violating, |_| None).entries.is_empty());
|
|
||||||
|
|
||||||
// Garbage never panics and never reads as a list.
|
|
||||||
for bad in [
|
|
||||||
"",
|
|
||||||
"not json",
|
|
||||||
"[]",
|
|
||||||
"42",
|
|
||||||
r#"{"entries": 7}"#,
|
|
||||||
r#"{"epoch":"04","sealed":"y"}"#,
|
|
||||||
] {
|
|
||||||
let read = read_list(bad, |_| None);
|
|
||||||
assert!(read.entries.is_empty() && !read.sealed, "{bad}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_dark_list_is_never_reformed_and_only_the_author_kills_a_pin() {
|
|
||||||
let author = Keys::generate();
|
|
||||||
let (entry, _) = entry_for(&author, "delete me later");
|
|
||||||
|
|
||||||
let dark = ReadPinList {
|
|
||||||
entries: vec![entry.clone()],
|
|
||||||
sealed: true,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
publishable(&dark, false, &group(), Epoch(0)),
|
|
||||||
Err(PinError::Unreadable)
|
|
||||||
);
|
|
||||||
|
|
||||||
let verified = verify_entry(&entry, &channel()).expect("verifies");
|
|
||||||
|
|
||||||
for author_keys in [&author, &Keys::generate()] {
|
|
||||||
let delete = build_delete(
|
|
||||||
author_keys.public_key(),
|
|
||||||
&channel(),
|
|
||||||
Epoch(0),
|
|
||||||
verified.rumor_id,
|
|
||||||
Some(KIND_MESSAGE),
|
|
||||||
None,
|
|
||||||
AT_MS + 1_000,
|
|
||||||
);
|
|
||||||
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!(
|
|
||||||
killed_by(&verified, &rumor),
|
|
||||||
author_keys.public_key() == author.public_key()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,977 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use std::collections::btree_map::Entry;
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
use data_encoding::BASE64URL_NOPAD;
|
|
||||||
use nostr::nips::nip01::Coordinate;
|
|
||||||
use nostr::nips::nip19::{Nip19, Nip19Coordinate};
|
|
||||||
use nostr::nips::nip44::v2::ConversationKey;
|
|
||||||
use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError};
|
|
||||||
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;
|
|
||||||
pub const KIND_INVITE_LIST: u16 = 13303;
|
|
||||||
pub const KIND_DIRECT_INVITE: u16 = 3313;
|
|
||||||
pub const FRAGMENT_VERSION: u8 = 4;
|
|
||||||
pub const MAX_BUNDLE_CHANNELS: usize = 256;
|
|
||||||
pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
|
|
||||||
pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40;
|
|
||||||
pub const MAX_INVITE_ENTRIES: usize = 64;
|
|
||||||
|
|
||||||
const FLAG_STOCK_SET: u8 = 0x01;
|
|
||||||
const INVITE_PATH: &str = "/invite/";
|
|
||||||
const TAG_IDENTIFIER: &str = "d";
|
|
||||||
const TAG_EXPIRATION: &str = "expiration";
|
|
||||||
|
|
||||||
const RELAY_DICT: [&str; 4] = [
|
|
||||||
"wss://jskitty.com/nostr",
|
|
||||||
"wss://asia.vectorapp.io/nostr",
|
|
||||||
"wss://relay.ditto.pub",
|
|
||||||
"wss://relay.dreamith.to",
|
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum InviteError {
|
|
||||||
Stream(StreamError),
|
|
||||||
Json(String),
|
|
||||||
BadHex(&'static str),
|
|
||||||
TooManyChannels(usize),
|
|
||||||
TooManyInvites(usize),
|
|
||||||
Oversize(usize),
|
|
||||||
Kind(u16),
|
|
||||||
EpochTooLarge(u64),
|
|
||||||
OwnerMismatch,
|
|
||||||
BadFragment(&'static str),
|
|
||||||
BadVersion(u8),
|
|
||||||
BadLink(&'static str),
|
|
||||||
BadEvent(&'static str),
|
|
||||||
Crypto(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for InviteError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
InviteError::Stream(error) => write!(f, "stream: {error}"),
|
|
||||||
InviteError::Json(error) => write!(f, "json: {error}"),
|
|
||||||
InviteError::BadHex(field) => write!(f, "{field} is not 32-byte lowercase hex"),
|
|
||||||
InviteError::TooManyChannels(count) => {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"bundle carries {count} channels (cap {MAX_BUNDLE_CHANNELS})"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
InviteError::TooManyInvites(count) => {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"invite list carries {count} entries (cap {MAX_INVITE_ENTRIES})"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
InviteError::Oversize(len) => {
|
|
||||||
write!(f, "invite list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})")
|
|
||||||
}
|
|
||||||
InviteError::Kind(kind) => write!(f, "not an invite list kind: {kind}"),
|
|
||||||
InviteError::EpochTooLarge(epoch) => write!(f, "epoch {epoch} out of range"),
|
|
||||||
InviteError::OwnerMismatch => {
|
|
||||||
write!(f, "bundle owner does not reproduce its community_id")
|
|
||||||
}
|
|
||||||
InviteError::BadFragment(why) => write!(f, "bad invite fragment: {why}"),
|
|
||||||
InviteError::BadVersion(version) => {
|
|
||||||
write!(f, "unsupported invite fragment version {version}")
|
|
||||||
}
|
|
||||||
InviteError::BadLink(why) => write!(f, "bad invite link: {why}"),
|
|
||||||
InviteError::BadEvent(why) => write!(f, "bad invite bundle event: {why}"),
|
|
||||||
InviteError::Crypto(error) => write!(f, "crypto: {error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for InviteError {}
|
|
||||||
|
|
||||||
impl From<StreamError> for InviteError {
|
|
||||||
fn from(error: StreamError) -> Self {
|
|
||||||
InviteError::Stream(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct ChannelGrant {
|
|
||||||
pub id: ChannelId,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub key: Option<String>,
|
|
||||||
pub epoch: Epoch,
|
|
||||||
#[serde(default)]
|
|
||||||
pub name: String,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub extra: Extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct CommunityInvite {
|
|
||||||
pub community_id: CommunityId,
|
|
||||||
pub owner: PublicKey,
|
|
||||||
pub owner_salt: String,
|
|
||||||
pub community_root: String,
|
|
||||||
pub root_epoch: Epoch,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub control_pk: Option<PublicKey>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub channels: Vec<ChannelGrant>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub relays: Vec<String>,
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub icon: Option<ImageRef>,
|
|
||||||
/// Unix **ms**: past it the preview still renders, joining refuses.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub expires_at: Option<u64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub creator_npub: Option<PublicKey>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub label: Option<String>,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub extra: Extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommunityInvite {
|
|
||||||
pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
|
|
||||||
let mut invite: Self =
|
|
||||||
serde_json::from_str(json).map_err(|error| InviteError::Json(error.to_string()))?;
|
|
||||||
|
|
||||||
if invite.channels.len() > MAX_BUNDLE_CHANNELS {
|
|
||||||
return Err(InviteError::TooManyChannels(invite.channels.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
invite.relays.truncate(MAX_RELAYS);
|
|
||||||
invite.validate()?;
|
|
||||||
|
|
||||||
Ok(invite)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate(&self) -> Result<(), InviteError> {
|
|
||||||
if self.channels.len() > MAX_BUNDLE_CHANNELS {
|
|
||||||
return Err(InviteError::TooManyChannels(self.channels.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
for epoch in std::iter::once(self.root_epoch).chain(self.channels.iter().map(|c| c.epoch)) {
|
|
||||||
if epoch.0 > MAX_BUNDLE_EPOCH {
|
|
||||||
return Err(InviteError::EpochTooLarge(epoch.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let owner_salt = hex32(&self.owner_salt, "owner_salt")?;
|
|
||||||
hex32(&self.community_root, "community_root")?;
|
|
||||||
|
|
||||||
for channel in &self.channels {
|
|
||||||
if let Some(key) = &channel.key {
|
|
||||||
hex32(key, "channel key")?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !verify_community_id(&self.community_id, &self.owner.to_bytes(), &owner_salt) {
|
|
||||||
return Err(InviteError::OwnerMismatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn expired(&self, now_ms: u64) -> bool {
|
|
||||||
self.expires_at.is_some_and(|expires| now_ms > expires)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum BundleState {
|
|
||||||
Live(Box<CommunityInvite>),
|
|
||||||
Revoked,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_bundle_event(
|
|
||||||
link_signer: &Keys,
|
|
||||||
invite: &CommunityInvite,
|
|
||||||
bundle_key: &[u8; 32],
|
|
||||||
) -> Result<Event, InviteError> {
|
|
||||||
invite.validate()?;
|
|
||||||
|
|
||||||
let json = serde_json::to_string(invite).map_err(json_error)?;
|
|
||||||
let content = seal_bundle(bundle_key, &json)?;
|
|
||||||
|
|
||||||
EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
|
|
||||||
.tags([empty_identifier(), subkind_tag(vsk::INVITE_LIVE)])
|
|
||||||
.finalize(link_signer)
|
|
||||||
.map_err(crypto_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
|
|
||||||
EventBuilder::new(Kind::Custom(KIND_BUNDLE), "")
|
|
||||||
.tags([empty_identifier(), subkind_tag(vsk::INVITE_REVOKED)])
|
|
||||||
.finalize(link_signer)
|
|
||||||
.map_err(crypto_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_bundle_event(
|
|
||||||
event: &Event,
|
|
||||||
expected_signer: &PublicKey,
|
|
||||||
bundle_key: &[u8; 32],
|
|
||||||
) -> Result<BundleState, InviteError> {
|
|
||||||
if event.kind.as_u16() != KIND_BUNDLE {
|
|
||||||
return Err(InviteError::BadEvent("wrong kind"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if event.pubkey != *expected_signer {
|
|
||||||
return Err(InviteError::BadEvent("author is not the link signer"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if first_tag(event, TAG_IDENTIFIER).is_some_and(|identifier| !identifier.is_empty()) {
|
|
||||||
return Err(InviteError::BadEvent(
|
|
||||||
"bundle is not at the link's coordinate",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
event
|
|
||||||
.verify()
|
|
||||||
.map_err(|_| InviteError::BadEvent("signature invalid"))?;
|
|
||||||
|
|
||||||
match first_tag(event, TAG_SUBKIND).as_deref() {
|
|
||||||
Some(vsk::INVITE_REVOKED) => return Ok(BundleState::Revoked),
|
|
||||||
Some(vsk::INVITE_LIVE) => {}
|
|
||||||
_ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = open_bundle(bundle_key, &event.content)?;
|
|
||||||
|
|
||||||
Ok(BundleState::Live(Box::new(
|
|
||||||
CommunityInvite::from_bundle_json(&json)?,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stock_relays() -> Vec<String> {
|
|
||||||
RELAY_DICT.iter().map(|relay| relay.to_string()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
|
|
||||||
let stock = relays == RELAY_DICT;
|
|
||||||
|
|
||||||
let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
|
|
||||||
bytes.push(FRAGMENT_VERSION);
|
|
||||||
|
|
||||||
if stock {
|
|
||||||
bytes.push(FLAG_STOCK_SET);
|
|
||||||
} else {
|
|
||||||
bytes.push(0x00);
|
|
||||||
|
|
||||||
let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
|
|
||||||
bytes.push(bounded.len() as u8);
|
|
||||||
|
|
||||||
for relay in bounded {
|
|
||||||
match dict_id(relay) {
|
|
||||||
Some(id) => bytes.push(id),
|
|
||||||
None => {
|
|
||||||
let (lead, literal) = match relay.strip_prefix("wss://") {
|
|
||||||
Some(host) => (0x00, host),
|
|
||||||
None => (0xff, relay.as_str()),
|
|
||||||
};
|
|
||||||
|
|
||||||
if literal.len() > u8::MAX as usize {
|
|
||||||
return Err(InviteError::BadFragment("relay too long"));
|
|
||||||
}
|
|
||||||
|
|
||||||
bytes.extend_from_slice(&[lead, literal.len() as u8]);
|
|
||||||
bytes.extend_from_slice(literal.as_bytes());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bytes.extend_from_slice(token);
|
|
||||||
|
|
||||||
Ok(BASE64URL_NOPAD.encode(&bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
|
|
||||||
let bytes = BASE64URL_NOPAD
|
|
||||||
.decode(fragment.trim().as_bytes())
|
|
||||||
.map_err(|_| InviteError::BadFragment("not base64url"))?;
|
|
||||||
|
|
||||||
let version = *bytes.first().ok_or(InviteError::BadFragment("truncated"))?;
|
|
||||||
|
|
||||||
if version != FRAGMENT_VERSION {
|
|
||||||
return Err(InviteError::BadVersion(version));
|
|
||||||
}
|
|
||||||
|
|
||||||
let flags = *bytes.get(1).ok_or(InviteError::BadFragment("truncated"))?;
|
|
||||||
|
|
||||||
let mut offset = 2;
|
|
||||||
let mut relays = Vec::new();
|
|
||||||
|
|
||||||
if flags & FLAG_STOCK_SET != 0 {
|
|
||||||
relays = stock_relays();
|
|
||||||
} else {
|
|
||||||
let count = *bytes
|
|
||||||
.get(offset)
|
|
||||||
.ok_or(InviteError::BadFragment("truncated"))? as usize;
|
|
||||||
offset += 1;
|
|
||||||
|
|
||||||
if count > MAX_BOOTSTRAP_RELAYS {
|
|
||||||
return Err(InviteError::BadFragment("too many bootstrap relays"));
|
|
||||||
}
|
|
||||||
|
|
||||||
for _ in 0..count {
|
|
||||||
let lead = *bytes
|
|
||||||
.get(offset)
|
|
||||||
.ok_or(InviteError::BadFragment("truncated"))?;
|
|
||||||
offset += 1;
|
|
||||||
|
|
||||||
if (1..=254).contains(&lead) {
|
|
||||||
if let Some(url) = dict_url(lead) {
|
|
||||||
relays.push(url.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let len = *bytes
|
|
||||||
.get(offset)
|
|
||||||
.ok_or(InviteError::BadFragment("truncated"))? as usize;
|
|
||||||
offset += 1;
|
|
||||||
|
|
||||||
let end = offset
|
|
||||||
.checked_add(len)
|
|
||||||
.ok_or(InviteError::BadFragment("truncated"))?;
|
|
||||||
|
|
||||||
let raw = bytes
|
|
||||||
.get(offset..end)
|
|
||||||
.ok_or(InviteError::BadFragment("truncated"))?;
|
|
||||||
|
|
||||||
let text = std::str::from_utf8(raw)
|
|
||||||
.map_err(|_| InviteError::BadFragment("relay is not utf8"))?;
|
|
||||||
|
|
||||||
relays.push(match lead {
|
|
||||||
0x00 => format!("wss://{text}"),
|
|
||||||
0xff => text.to_string(),
|
|
||||||
_ => return Err(InviteError::BadFragment("unknown relay lead byte")),
|
|
||||||
});
|
|
||||||
|
|
||||||
offset = end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let end = offset
|
|
||||||
.checked_add(TOKEN_LEN)
|
|
||||||
.ok_or(InviteError::BadFragment("truncated"))?;
|
|
||||||
|
|
||||||
let raw = bytes
|
|
||||||
.get(offset..end)
|
|
||||||
.ok_or(InviteError::BadFragment("truncated"))?;
|
|
||||||
|
|
||||||
if end != bytes.len() {
|
|
||||||
return Err(InviteError::BadFragment("trailing bytes"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut token = [0u8; TOKEN_LEN];
|
|
||||||
token.copy_from_slice(raw);
|
|
||||||
|
|
||||||
Ok((token, relays))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
|
|
||||||
let coordinate = Coordinate {
|
|
||||||
kind: Kind::Custom(KIND_BUNDLE),
|
|
||||||
public_key: *link_signer,
|
|
||||||
identifier: String::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Nip19::Coordinate(Nip19Coordinate {
|
|
||||||
coordinate,
|
|
||||||
relays: Vec::new(),
|
|
||||||
})
|
|
||||||
.to_bech32()
|
|
||||||
.map_err(|_| InviteError::BadLink("invalid naddr"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_invite_url(
|
|
||||||
base: &str,
|
|
||||||
link_signer: &PublicKey,
|
|
||||||
token: &[u8; TOKEN_LEN],
|
|
||||||
relays: &[String],
|
|
||||||
) -> Result<String, InviteError> {
|
|
||||||
let naddr = bundle_naddr(link_signer)?;
|
|
||||||
let fragment = encode_fragment(token, relays)?;
|
|
||||||
|
|
||||||
Ok(format!(
|
|
||||||
"{}{INVITE_PATH}{naddr}#{fragment}",
|
|
||||||
base.trim_end_matches('/')
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ParsedInviteLink {
|
|
||||||
/// The bundle coordinate's author.
|
|
||||||
pub link_signer: PublicKey,
|
|
||||||
pub token: [u8; TOKEN_LEN],
|
|
||||||
pub bootstrap_relays: Vec<String>,
|
|
||||||
/// The bare naddr as it appeared in the link, for the fetch.
|
|
||||||
pub naddr: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
|
|
||||||
let (locator, fragment) = input
|
|
||||||
.trim()
|
|
||||||
.split_once('#')
|
|
||||||
.ok_or(InviteError::BadLink("no fragment"))?;
|
|
||||||
|
|
||||||
if fragment.is_empty() {
|
|
||||||
return Err(InviteError::BadLink("empty fragment"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let naddr = match locator.find(INVITE_PATH) {
|
|
||||||
Some(index) => locator[index + INVITE_PATH.len()..].trim_end_matches('/'),
|
|
||||||
None => locator.trim_start_matches("nostr:"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let link_signer = signer_from_naddr(naddr)?;
|
|
||||||
let (token, bootstrap_relays) = decode_fragment(fragment)?;
|
|
||||||
|
|
||||||
Ok(ParsedInviteLink {
|
|
||||||
link_signer,
|
|
||||||
token,
|
|
||||||
bootstrap_relays,
|
|
||||||
naddr: naddr.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn build_direct_invite<S>(
|
|
||||||
inviter: &S,
|
|
||||||
recipient: &PublicKey,
|
|
||||||
invite: &CommunityInvite,
|
|
||||||
) -> Result<Event, InviteError>
|
|
||||||
where
|
|
||||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
|
|
||||||
{
|
|
||||||
invite.validate()?;
|
|
||||||
|
|
||||||
let json = serde_json::to_string(invite).map_err(json_error)?;
|
|
||||||
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()])];
|
|
||||||
|
|
||||||
if let Some(expires_at) = invite.expires_at {
|
|
||||||
tags.push(Tag::custom(
|
|
||||||
TAG_EXPIRATION,
|
|
||||||
[(expires_at / 1000).to_string()],
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
GiftWrapBuilder::new(*recipient, rumor)
|
|
||||||
.extra_tags(tags)
|
|
||||||
.finalize_async(inviter)
|
|
||||||
.await
|
|
||||||
.map_err(crypto_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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: &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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
unwrapped.sender,
|
|
||||||
CommunityInvite::from_bundle_json(&unwrapped.rumor.content)?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct InviteEntry {
|
|
||||||
/// The link's unlock secret, and its merge key.
|
|
||||||
pub token: String,
|
|
||||||
/// The `link_signer` secret: refreshing or retiring the bundle needs it.
|
|
||||||
pub signer_sk: String,
|
|
||||||
pub community_id: CommunityId,
|
|
||||||
pub url: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub label: Option<String>,
|
|
||||||
pub created_at: u64,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub expires_at: Option<u64>,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub extra: Extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct InviteTombstone {
|
|
||||||
pub token: String,
|
|
||||||
pub community_id: CommunityId,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub extra: Extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A creator's own link bookkeeping.
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct InviteList {
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub entries: Vec<InviteEntry>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub tombstones: Vec<InviteTombstone>,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub extra: Extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InviteList {
|
|
||||||
/// A tombstone beats an entry terminally, so a stale device can never resurrect a revoked link.
|
|
||||||
pub fn is_live(&self, token: &str) -> bool {
|
|
||||||
self.entries.iter().any(|entry| entry.token == token)
|
|
||||||
&& !self
|
|
||||||
.tombstones
|
|
||||||
.iter()
|
|
||||||
.any(|tombstone| tombstone.token == token)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fits(&self) -> Result<(), InviteError> {
|
|
||||||
if self.entries.len() > MAX_INVITE_ENTRIES {
|
|
||||||
return Err(InviteError::TooManyInvites(self.entries.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = serde_json::to_string(self).map_err(json_error)?;
|
|
||||||
|
|
||||||
if json.len() > NIP44_MAX_PLAINTEXT {
|
|
||||||
return Err(InviteError::Oversize(json.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList {
|
|
||||||
let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
|
|
||||||
|
|
||||||
for entry in held.entries.into_iter().chain(incoming.entries) {
|
|
||||||
match entries.entry(entry.token.clone()) {
|
|
||||||
Entry::Vacant(slot) => {
|
|
||||||
slot.insert(entry);
|
|
||||||
}
|
|
||||||
Entry::Occupied(mut slot) => {
|
|
||||||
let merged = merge_entry(slot.get(), &entry);
|
|
||||||
*slot.get_mut() = merged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
|
|
||||||
|
|
||||||
for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) {
|
|
||||||
match tombstones.entry(tombstone.token.clone()) {
|
|
||||||
Entry::Vacant(slot) => {
|
|
||||||
slot.insert(tombstone);
|
|
||||||
}
|
|
||||||
Entry::Occupied(mut slot) => {
|
|
||||||
if canonical(&tombstone) < canonical(slot.get()) {
|
|
||||||
*slot.get_mut() = tombstone;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut extra = held.extra;
|
|
||||||
union(&mut extra, incoming.extra);
|
|
||||||
|
|
||||||
InviteList {
|
|
||||||
entries: entries.into_values().collect(),
|
|
||||||
tombstones: tombstones.into_values().collect(),
|
|
||||||
extra,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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).await?;
|
|
||||||
|
|
||||||
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
|
|
||||||
.finalize_async(keys)
|
|
||||||
.await
|
|
||||||
.map_err(crypto_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
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).await?;
|
|
||||||
|
|
||||||
serde_json::from_str(&json).map_err(json_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An entry is immutable once minted, so two copies should agree.
|
|
||||||
fn merge_entry(held: &InviteEntry, incoming: &InviteEntry) -> InviteEntry {
|
|
||||||
let (winner, loser) = if canonical(incoming) < canonical(held) {
|
|
||||||
(incoming, held)
|
|
||||||
} else {
|
|
||||||
(held, incoming)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut merged = winner.clone();
|
|
||||||
union(&mut merged.extra, loser.extra.clone());
|
|
||||||
|
|
||||||
merged
|
|
||||||
}
|
|
||||||
|
|
||||||
fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
|
|
||||||
Ok(cord01::seal_bytes(
|
|
||||||
&ConversationKey::new(*bundle_key),
|
|
||||||
json.as_bytes(),
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
|
|
||||||
let plaintext = cord01::open_bytes(&ConversationKey::new(*bundle_key), content)?;
|
|
||||||
|
|
||||||
String::from_utf8(plaintext).map_err(|_| InviteError::BadFragment("bundle is not utf8"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
|
|
||||||
match Nip19::from_bech32(naddr.trim_start_matches("nostr:")) {
|
|
||||||
Ok(Nip19::Coordinate(coordinate))
|
|
||||||
if coordinate.coordinate.kind.as_u16() == KIND_BUNDLE
|
|
||||||
&& coordinate.coordinate.identifier.is_empty() =>
|
|
||||||
{
|
|
||||||
Ok(coordinate.coordinate.public_key)
|
|
||||||
}
|
|
||||||
_ => Err(InviteError::BadLink(
|
|
||||||
"naddr is not an invite-bundle coordinate",
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hex32(value: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
|
|
||||||
decode_hex_32(value).map_err(|_| InviteError::BadHex(field))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dict_id(relay: &str) -> Option<u8> {
|
|
||||||
RELAY_DICT
|
|
||||||
.iter()
|
|
||||||
.position(|known| *known == relay)
|
|
||||||
.map(|index| index as u8 + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dict_url(id: u8) -> Option<&'static str> {
|
|
||||||
RELAY_DICT.get(id.checked_sub(1)? as usize).copied()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty_identifier() -> Tag {
|
|
||||||
Tag::identifier("")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn subkind_tag(value: &str) -> Tag {
|
|
||||||
Tag::custom(TAG_SUBKIND, [value])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn first_tag(event: &Event, name: &str) -> Option<String> {
|
|
||||||
event.tags.iter().find_map(|tag| {
|
|
||||||
let fields = tag.as_slice();
|
|
||||||
|
|
||||||
(fields.len() >= 2 && fields[0] == name).then(|| fields[1].clone())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_error(error: serde_json::Error) -> InviteError {
|
|
||||||
InviteError::Json(error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn crypto_error(error: impl fmt::Display) -> InviteError {
|
|
||||||
InviteError::Crypto(error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use data_encoding::HEXLOWER;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::derive::{community_id_of, invite_bundle_key};
|
|
||||||
|
|
||||||
const SALT: [u8; 32] = [0x33u8; 32];
|
|
||||||
|
|
||||||
fn bundle() -> CommunityInvite {
|
|
||||||
let owner = Keys::generate();
|
|
||||||
|
|
||||||
CommunityInvite {
|
|
||||||
community_id: community_id_of(&owner.public_key().to_bytes(), &SALT),
|
|
||||||
owner: owner.public_key(),
|
|
||||||
owner_salt: HEXLOWER.encode(&SALT),
|
|
||||||
community_root: "44".repeat(32),
|
|
||||||
root_epoch: Epoch(0),
|
|
||||||
control_pk: None,
|
|
||||||
channels: vec![ChannelGrant {
|
|
||||||
id: ChannelId::from_bytes([0x9cu8; 32]),
|
|
||||||
key: Some("55".repeat(32)),
|
|
||||||
epoch: Epoch(1),
|
|
||||||
name: "lounge".to_owned(),
|
|
||||||
extra: Extra::default(),
|
|
||||||
}],
|
|
||||||
relays: vec!["wss://relay.example".to_owned()],
|
|
||||||
name: "Test community".to_owned(),
|
|
||||||
icon: None,
|
|
||||||
expires_at: None,
|
|
||||||
creator_npub: None,
|
|
||||||
label: None,
|
|
||||||
extra: Extra::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn token16() -> [u8; TOKEN_LEN] {
|
|
||||||
std::array::from_fn(|i| i as u8)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fragment_goldens_pin_the_wire_layout() {
|
|
||||||
let token = token16();
|
|
||||||
|
|
||||||
// [04 version][01 stock flag][token 00..0f]
|
|
||||||
let stock = encode_fragment(&token, &stock_relays()).expect("encodes");
|
|
||||||
assert_eq!(stock, "BAEAAQIDBAUGBwgJCgsMDQ4P");
|
|
||||||
assert_eq!(
|
|
||||||
decode_fragment(&stock).expect("decodes"),
|
|
||||||
(token, stock_relays())
|
|
||||||
);
|
|
||||||
|
|
||||||
// [04][00 flags][02 count][02 dict-id][04 dict-id][token 00..0f]
|
|
||||||
let mixed = vec![RELAY_DICT[1].to_owned(), RELAY_DICT[3].to_owned()];
|
|
||||||
let encoded = encode_fragment(&token, &mixed).expect("encodes");
|
|
||||||
assert_eq!(encoded, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
|
|
||||||
assert_eq!(decode_fragment(&encoded).expect("decodes"), (token, mixed));
|
|
||||||
|
|
||||||
// [04][00][01 count][ff verbatim lead][06 len]["ws://h"][token 00..0f]
|
|
||||||
let verbatim = vec!["ws://h".to_owned()];
|
|
||||||
let encoded = encode_fragment(&token, &verbatim).expect("encodes");
|
|
||||||
assert_eq!(encoded, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
|
|
||||||
assert_eq!(
|
|
||||||
decode_fragment(&encoded).expect("decodes"),
|
|
||||||
(token, verbatim)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_fragment_is_strict_about_framing_and_counts() {
|
|
||||||
let token = token16();
|
|
||||||
|
|
||||||
for version in [3u8, 5] {
|
|
||||||
let mut bytes = vec![version, FLAG_STOCK_SET];
|
|
||||||
bytes.extend_from_slice(&token);
|
|
||||||
let encoded = BASE64URL_NOPAD.encode(&bytes);
|
|
||||||
assert!(
|
|
||||||
matches!(decode_fragment(&encoded), Err(InviteError::BadVersion(v)) if v == version),
|
|
||||||
"a legacy and a future version are both refused"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut trailing = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
|
|
||||||
trailing.extend_from_slice(&token);
|
|
||||||
trailing.push(0xff);
|
|
||||||
assert!(matches!(
|
|
||||||
decode_fragment(&BASE64URL_NOPAD.encode(&trailing)),
|
|
||||||
Err(InviteError::BadFragment(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut over = vec![FRAGMENT_VERSION, 0x00, 0x04, 1, 2, 3, 4];
|
|
||||||
over.extend_from_slice(&token);
|
|
||||||
assert!(matches!(
|
|
||||||
decode_fragment(&BASE64URL_NOPAD.encode(&over)),
|
|
||||||
Err(InviteError::BadFragment(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
// An unknown dictionary id is skipped, not fatal, so the dictionary can grow.
|
|
||||||
let mut unknown = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
|
|
||||||
unknown.extend_from_slice(&token);
|
|
||||||
let (decoded, relays) =
|
|
||||||
decode_fragment(&BASE64URL_NOPAD.encode(&unknown)).expect("decodes");
|
|
||||||
assert_eq!(decoded, token);
|
|
||||||
assert!(relays.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_link_round_trips_and_refuses_a_non_invite() {
|
|
||||||
let link_signer = Keys::generate();
|
|
||||||
let token = token16();
|
|
||||||
let relays = vec!["wss://a.example".to_owned()];
|
|
||||||
|
|
||||||
let url = build_invite_url(
|
|
||||||
"https://vectorapp.io/",
|
|
||||||
&link_signer.public_key(),
|
|
||||||
&token,
|
|
||||||
&relays,
|
|
||||||
)
|
|
||||||
.expect("builds");
|
|
||||||
|
|
||||||
let parsed = parse_link(&url).expect("parses");
|
|
||||||
assert_eq!(parsed.link_signer, link_signer.public_key());
|
|
||||||
assert_eq!(parsed.token, token);
|
|
||||||
assert_eq!(parsed.bootstrap_relays, relays);
|
|
||||||
|
|
||||||
let fragment = url.split('#').nth(1).expect("carries a fragment");
|
|
||||||
let bare = format!("{}#{fragment}", parsed.naddr);
|
|
||||||
let reparsed = parse_link(&bare).expect("parses the domain-agnostic form");
|
|
||||||
assert_eq!(reparsed.link_signer, link_signer.public_key());
|
|
||||||
assert_eq!(reparsed.token, token);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
parse_link("https://x/invite/#frag").is_err(),
|
|
||||||
"the naddr is not optional"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_bundle_round_trips_while_a_revocation_reads_as_revoked() {
|
|
||||||
let invite = bundle();
|
|
||||||
let link_signer = Keys::generate();
|
|
||||||
let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
|
|
||||||
|
|
||||||
let event = build_bundle_event(&link_signer, &invite, &key).expect("builds");
|
|
||||||
assert_eq!(event.pubkey, link_signer.public_key());
|
|
||||||
|
|
||||||
match parse_bundle_event(&event, &link_signer.public_key(), &key).expect("parses") {
|
|
||||||
BundleState::Live(opened) => {
|
|
||||||
assert_eq!(opened.community_id, invite.community_id);
|
|
||||||
assert_eq!(opened.channels.len(), 1);
|
|
||||||
}
|
|
||||||
BundleState::Revoked => panic!("expected a live bundle"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let revocation = build_revocation(&link_signer).expect("builds");
|
|
||||||
assert!(matches!(
|
|
||||||
parse_bundle_event(&revocation, &link_signer.public_key(), &key),
|
|
||||||
Ok(BundleState::Revoked)
|
|
||||||
));
|
|
||||||
|
|
||||||
// The token is the only way in, and a squatter is a different coordinate.
|
|
||||||
assert!(
|
|
||||||
parse_bundle_event(
|
|
||||||
&event,
|
|
||||||
&link_signer.public_key(),
|
|
||||||
&invite_bundle_key(&[8u8; TOKEN_LEN])
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
let squatter = Keys::generate();
|
|
||||||
assert!(matches!(
|
|
||||||
parse_bundle_event(&event, &squatter.public_key(), &key),
|
|
||||||
Err(InviteError::BadEvent(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_bundle_off_its_coordinate_or_off_its_owner_is_refused() {
|
|
||||||
let invite = bundle();
|
|
||||||
let link_signer = Keys::generate();
|
|
||||||
let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
|
|
||||||
let json = serde_json::to_string(&invite).expect("serializes");
|
|
||||||
let content = seal_bundle(&key, &json).expect("seals");
|
|
||||||
|
|
||||||
// The fetch filters on the author, so the empty `d` is pinned here: a
|
|
||||||
// signature-valid event of the same author at another `d` is not the bundle.
|
|
||||||
let elsewhere = EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
|
|
||||||
.tags([Tag::identifier("elsewhere"), subkind_tag(vsk::INVITE_LIVE)])
|
|
||||||
.finalize(&link_signer)
|
|
||||||
.expect("signs");
|
|
||||||
assert!(matches!(
|
|
||||||
parse_bundle_event(&elsewhere, &link_signer.public_key(), &key),
|
|
||||||
Err(InviteError::BadEvent(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut forged = bundle();
|
|
||||||
forged.owner = Keys::generate().public_key();
|
|
||||||
assert!(matches!(forged.validate(), Err(InviteError::OwnerMismatch)));
|
|
||||||
assert!(matches!(
|
|
||||||
build_bundle_event(&link_signer, &forged, &key),
|
|
||||||
Err(InviteError::OwnerMismatch)
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut malformed = bundle();
|
|
||||||
malformed.community_root = "not hex".to_owned();
|
|
||||||
assert!(matches!(malformed.validate(), Err(InviteError::BadHex(_))));
|
|
||||||
|
|
||||||
let mut crowded = bundle();
|
|
||||||
crowded.channels = (0..=MAX_BUNDLE_CHANNELS)
|
|
||||||
.map(|_| ChannelGrant {
|
|
||||||
id: ChannelId::from_bytes([0x01; 32]),
|
|
||||||
key: None,
|
|
||||||
epoch: Epoch(0),
|
|
||||||
name: String::new(),
|
|
||||||
extra: Extra::default(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
assert!(matches!(
|
|
||||||
crowded.validate(),
|
|
||||||
Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_direct_invite_round_trips_and_refuses_a_foreign_rumor() {
|
|
||||||
let inviter = Keys::generate();
|
|
||||||
let recipient = Keys::generate();
|
|
||||||
let invite = bundle();
|
|
||||||
|
|
||||||
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,
|
|
||||||
inviter.public_key(),
|
|
||||||
"the wrap author is ephemeral"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
wrap.tags.iter().any(|tag| tag.as_slice() == ["k", "3313"]),
|
|
||||||
"the k tag is what makes an invite indexable"
|
|
||||||
);
|
|
||||||
|
|
||||||
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!(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")
|
|
||||||
.finalize_unsigned(recipient.public_key());
|
|
||||||
let wrap = GiftWrapBuilder::new(recipient.public_key(), rumor)
|
|
||||||
.finalize(&recipient)
|
|
||||||
.expect("wraps");
|
|
||||||
assert!(matches!(
|
|
||||||
smol::block_on(unwrap_direct_invite(&wrap, &recipient)),
|
|
||||||
Err(InviteError::BadEvent(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
|||||||
mod rumor;
|
|
||||||
|
|
||||||
pub mod cord01;
|
|
||||||
pub mod cord02;
|
|
||||||
pub mod cord03;
|
|
||||||
pub mod cord04;
|
|
||||||
pub mod cord05;
|
|
||||||
pub mod cord06;
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
mod cords;
|
|
||||||
mod types;
|
|
||||||
mod utils;
|
|
||||||
|
|
||||||
pub mod state;
|
|
||||||
|
|
||||||
pub use cords::{cord01, cord02, cord03, cord04, cord05, cord06};
|
|
||||||
pub(crate) use types::Extra;
|
|
||||||
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};
|
|
||||||
@@ -1,631 +0,0 @@
|
|||||||
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 relays = material
|
|
||||||
.relays
|
|
||||||
.iter()
|
|
||||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let control_root = match &material.control_root {
|
|
||||||
Some(root) => Some(decode_hex_32(root)?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
control_pks,
|
|
||||||
channels,
|
|
||||||
relays,
|
|
||||||
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)]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
use std::fmt;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use data_encoding::HEXLOWER;
|
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|
||||||
|
|
||||||
use crate::decode_hex_32;
|
|
||||||
|
|
||||||
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
|
|
||||||
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
|
|
||||||
|
|
||||||
macro_rules! hex_id {
|
|
||||||
($(#[$meta:meta])* $name:ident) => {
|
|
||||||
$(#[$meta])*
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
||||||
pub struct $name([u8; 32]);
|
|
||||||
|
|
||||||
impl $name {
|
|
||||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
|
||||||
Self(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_hex(&self) -> String {
|
|
||||||
HEXLOWER.encode(&self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<[u8; 32]> for $name {
|
|
||||||
fn from(bytes: [u8; 32]) -> Self {
|
|
||||||
Self(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for $name {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.write_str(&self.to_hex())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for $name {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}({})", stringify!($name), self.to_hex())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for $name {
|
|
||||||
type Err = anyhow::Error;
|
|
||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self> {
|
|
||||||
Ok(Self(decode_hex_32(value)?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Serialize for $name {
|
|
||||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
||||||
serializer.serialize_str(&self.to_hex())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for $name {
|
|
||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
||||||
let value = String::deserialize(deserializer)?;
|
|
||||||
value.parse().map_err(serde::de::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
hex_id! {
|
|
||||||
/// A self-certifying commitment to the owner's key, never on the wire.
|
|
||||||
CommunityId
|
|
||||||
}
|
|
||||||
|
|
||||||
hex_id! {
|
|
||||||
ChannelId
|
|
||||||
}
|
|
||||||
|
|
||||||
hex_id! {
|
|
||||||
/// Both a Role's entity coordinate and the field it repeats in its own content.
|
|
||||||
RoleId
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
|
|
||||||
#[derive(
|
|
||||||
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
|
|
||||||
)]
|
|
||||||
pub struct Epoch(pub u64);
|
|
||||||
|
|
||||||
impl fmt::Display for Epoch {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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()))
|
|
||||||
}
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
use anyhow::{Result, bail};
|
|
||||||
use hkdf::Hkdf;
|
|
||||||
use nostr::nips::nip44::v2::ConversationKey;
|
|
||||||
use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use crate::{ChannelId, CommunityId, Epoch};
|
|
||||||
|
|
||||||
pub const TOKEN_LEN: usize = 16;
|
|
||||||
|
|
||||||
const LABEL_CHANNEL: &str = "concord/channel";
|
|
||||||
const LABEL_CONTROL: &str = "concord/control";
|
|
||||||
const LABEL_CONTROL_SIGNER: &str = "concord/control-signer";
|
|
||||||
const LABEL_REKEY_PSEUDONYM: &str = "concord/rekey-pseudonym";
|
|
||||||
const LABEL_BASE_REKEY_PSEUDONYM: &str = "concord/base-rekey-pseudonym";
|
|
||||||
const LABEL_RECIPIENT_PSEUDONYM: &str = "concord/recipient-pseudonym";
|
|
||||||
const LABEL_GUESTBOOK: &str = "concord/guestbook";
|
|
||||||
const LABEL_DISSOLVED: &str = "concord/dissolved";
|
|
||||||
const LABEL_GRANT: &str = "concord/grant";
|
|
||||||
const LABEL_BANLIST: &str = "concord/banlist";
|
|
||||||
const LABEL_PINS: &str = "concord/pins";
|
|
||||||
const LABEL_INVITE_LINKS: &str = "concord/invite-links";
|
|
||||||
const LABEL_INVITE_KEY: &str = "concord/invite-key";
|
|
||||||
|
|
||||||
const LABEL_COMMUNITY: &str = "concord/community";
|
|
||||||
const LABEL_EPOCH_COMMITMENT: &str = "concord/epoch-key-commitment";
|
|
||||||
|
|
||||||
const ZERO32: [u8; 32] = [0u8; 32];
|
|
||||||
|
|
||||||
fn build_info(label: &str, id32: &[u8; 32], epoch: Option<u64>) -> Vec<u8> {
|
|
||||||
let mut info = Vec::with_capacity(label.len() + 1 + 32 + 8);
|
|
||||||
info.extend_from_slice(label.as_bytes());
|
|
||||||
info.push(0x00);
|
|
||||||
info.extend_from_slice(id32);
|
|
||||||
|
|
||||||
if let Some(epoch) = epoch {
|
|
||||||
info.extend_from_slice(&epoch.to_be_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
info
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32] {
|
|
||||||
let mut okm = [0u8; 32];
|
|
||||||
Hkdf::<Sha256>::new(None, ikm)
|
|
||||||
.expand(info, &mut okm)
|
|
||||||
.expect("expanding HKDF to 32 bytes is below the 255*32 ceiling");
|
|
||||||
okm
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hkdf_to_secret_key(ikm: &[u8], base_info: &[u8]) -> Result<SecretKey> {
|
|
||||||
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, base_info)) {
|
|
||||||
return Ok(secret_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
for counter in 0u8..=u8::MAX {
|
|
||||||
let mut info = Vec::with_capacity(base_info.len() + 1);
|
|
||||||
info.extend_from_slice(base_info);
|
|
||||||
info.push(counter);
|
|
||||||
|
|
||||||
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, &info)) {
|
|
||||||
return Ok(secret_key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bail!("seed stayed out of the secp256k1 scalar range across all 256 counters")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct GroupKey {
|
|
||||||
keys: Keys,
|
|
||||||
conversation: ConversationKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GroupKey {
|
|
||||||
fn derive(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> Result<Self> {
|
|
||||||
let secret_key = hkdf_to_secret_key(secret, &build_info(label, id32, epoch))?;
|
|
||||||
let keys = Keys::new(secret_key);
|
|
||||||
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
|
|
||||||
|
|
||||||
Ok(Self { keys, conversation })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pk(&self) -> PublicKey {
|
|
||||||
self.keys.public_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pk_hex(&self) -> String {
|
|
||||||
self.keys.public_key().to_hex()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn keys(&self) -> &Keys {
|
|
||||||
&self.keys
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn conversation(&self) -> &ConversationKey {
|
|
||||||
&self.conversation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for GroupKey {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("GroupKey")
|
|
||||||
.field("pk", &self.pk_hex())
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `secret` is the `community_root` for a public channel.
|
|
||||||
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey> {
|
|
||||||
GroupKey::derive(LABEL_CHANNEL, secret, channel.as_bytes(), Some(epoch.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The plane's read key: its conversation key encrypts the wraps for every member.
|
|
||||||
pub fn control_group_key(
|
|
||||||
community_root: &[u8; 32],
|
|
||||||
community_id: &CommunityId,
|
|
||||||
epoch: Epoch,
|
|
||||||
) -> Result<GroupKey> {
|
|
||||||
GroupKey::derive(
|
|
||||||
LABEL_CONTROL,
|
|
||||||
community_root,
|
|
||||||
community_id.as_bytes(),
|
|
||||||
Some(epoch.0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The plane's address and wrap signer, held only by staff; wraps still read under [`control_group_key`].
|
|
||||||
pub fn control_signer_group_key(
|
|
||||||
control_root: &[u8; 32],
|
|
||||||
community_id: &CommunityId,
|
|
||||||
epoch: Epoch,
|
|
||||||
) -> Result<GroupKey> {
|
|
||||||
GroupKey::derive(
|
|
||||||
LABEL_CONTROL_SIGNER,
|
|
||||||
control_root,
|
|
||||||
community_id.as_bytes(),
|
|
||||||
Some(epoch.0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Member-writable, unlike the Control Plane: a join or a leave is each member's own word.
|
|
||||||
pub fn guestbook_group_key(
|
|
||||||
community_root: &[u8; 32],
|
|
||||||
community_id: &CommunityId,
|
|
||||||
epoch: Epoch,
|
|
||||||
) -> Result<GroupKey> {
|
|
||||||
GroupKey::derive(
|
|
||||||
LABEL_GUESTBOOK,
|
|
||||||
community_root,
|
|
||||||
community_id.as_bytes(),
|
|
||||||
Some(epoch.0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keyed by the prior `community_root`, so any retained member recovers any epoch's rekey.
|
|
||||||
pub fn channel_rekey_group_key(
|
|
||||||
prior_root: &[u8; 32],
|
|
||||||
channel: &ChannelId,
|
|
||||||
new_epoch: Epoch,
|
|
||||||
) -> Result<GroupKey> {
|
|
||||||
GroupKey::derive(
|
|
||||||
LABEL_REKEY_PSEUDONYM,
|
|
||||||
prior_root,
|
|
||||||
channel.as_bytes(),
|
|
||||||
Some(new_epoch.0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn base_rekey_group_key(
|
|
||||||
prior_root: &[u8; 32],
|
|
||||||
community_id: &CommunityId,
|
|
||||||
new_epoch: Epoch,
|
|
||||||
) -> Result<GroupKey> {
|
|
||||||
GroupKey::derive(
|
|
||||||
LABEL_BASE_REKEY_PSEUDONYM,
|
|
||||||
prior_root,
|
|
||||||
community_id.as_bytes(),
|
|
||||||
Some(new_epoch.0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dissolved_group_key(community_id: &CommunityId) -> Result<GroupKey> {
|
|
||||||
GroupKey::derive(LABEL_DISSOLVED, community_id.as_bytes(), &ZERO32, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(LABEL_COMMUNITY.as_bytes());
|
|
||||||
hasher.update(owner_xonly);
|
|
||||||
hasher.update(owner_salt);
|
|
||||||
CommunityId::from_bytes(hasher.finalize().into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify_community_id(
|
|
||||||
community_id: &CommunityId,
|
|
||||||
owner_xonly: &[u8; 32],
|
|
||||||
owner_salt: &[u8; 32],
|
|
||||||
) -> bool {
|
|
||||||
community_id_of(owner_xonly, owner_salt) == *community_id
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The continuity a rekey blob must satisfy against the key currently held.
|
|
||||||
pub fn epoch_key_commitment(previous_epoch: Epoch, previous_key: &[u8; 32]) -> [u8; 32] {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(LABEL_EPOCH_COMMITMENT.as_bytes());
|
|
||||||
hasher.update(previous_epoch.0.to_be_bytes());
|
|
||||||
hasher.update(previous_key);
|
|
||||||
hasher.finalize().into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bound to the `community_id`, so a member's Grant coordinate survives every refounding.
|
|
||||||
pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] {
|
|
||||||
hkdf32(
|
|
||||||
community_id.as_bytes(),
|
|
||||||
&build_info(LABEL_GRANT, member_xonly, None),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] {
|
|
||||||
hkdf32(
|
|
||||||
community_id.as_bytes(),
|
|
||||||
&build_info(LABEL_BANLIST, &ZERO32, None),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pins_locator(community_id: &CommunityId, channel: &ChannelId) -> [u8; 32] {
|
|
||||||
hkdf32(
|
|
||||||
community_id.as_bytes(),
|
|
||||||
&build_info(LABEL_PINS, channel.as_bytes(), None),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bound to the creator, so each creator owns exactly their own registry.
|
|
||||||
pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] {
|
|
||||||
hkdf32(
|
|
||||||
community_id.as_bytes(),
|
|
||||||
&build_info(LABEL_INVITE_LINKS, creator_xonly, None),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Built from public inputs only, so a locator match proves nothing about authenticity
|
|
||||||
pub fn recipient_locator(
|
|
||||||
rotator_xonly: &[u8; 32],
|
|
||||||
recipient_xonly: &[u8; 32],
|
|
||||||
scope_id: &[u8; 32],
|
|
||||||
new_epoch: Epoch,
|
|
||||||
) -> [u8; 32] {
|
|
||||||
let mut ikm = [0u8; 64];
|
|
||||||
ikm[..32].copy_from_slice(rotator_xonly);
|
|
||||||
ikm[32..].copy_from_slice(recipient_xonly);
|
|
||||||
hkdf32(
|
|
||||||
&ikm,
|
|
||||||
&build_info(LABEL_RECIPIENT_PSEUDONYM, scope_id, Some(new_epoch.0)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The raw output is the NIP-44 conversation key (CORD-05 §2).
|
|
||||||
pub fn invite_bundle_key(token: &[u8; TOKEN_LEN]) -> [u8; 32] {
|
|
||||||
hkdf32(token, &build_info(LABEL_INVITE_KEY, &ZERO32, None))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const CHANNEL_E0_SEED: &str =
|
|
||||||
"1a99a5958bf9fcc5336e6e19db42aabf36ffbfa12f38a1d5fbde2ae383ed751b";
|
|
||||||
const CHANNEL_E0_PK: &str = "7a5c5dff759a63f1fc2779864487432bae3d1ea72c4ffabd39f4c1fdaf62097a";
|
|
||||||
const CHANNEL_EMULTI_PK: &str =
|
|
||||||
"f20c7d192cc87615d7341e86f38f85303f4708b40232d4fea521ab8217767391";
|
|
||||||
const CONTROL_E0_PK: &str = "c43df20bf4d6eeaea5149619662ffe9b211f31e11bb4a59f56b6e906f702d46f";
|
|
||||||
const CONTROL_SIGNER_E0_SEED: &str =
|
|
||||||
"c4a3e8354d95137132087356412b67b53e025d127d45de45cff9ecf45b0c24f6";
|
|
||||||
const CONTROL_SIGNER_E0_PK: &str =
|
|
||||||
"718aef388257f3fd9f1bfae5cf2cbd0594a2ffc31adb5c1fe22c502c046acaee";
|
|
||||||
const CONTROL_SIGNER_EMULTI_PK: &str =
|
|
||||||
"e27235cc13be2f9ad65648e01ff2b63402846469c8638b5386c625688194ec7d";
|
|
||||||
const GUESTBOOK_E0_PK: &str =
|
|
||||||
"ad09de582026fa7a052db18bb5827fa24c15e929d59aadcc91efb8508f5368ad";
|
|
||||||
const CHANNEL_REKEY_E1_PK: &str =
|
|
||||||
"7c55cdb957e9db2b4800d687b2a07d3f7066b1a35824a1e86ba871f55e87e8b5";
|
|
||||||
const BASE_REKEY_E1_PK: &str =
|
|
||||||
"fb2fa44fba66ba15595f784255a1cb569531db8784432ac0e4fe838498dd9dea";
|
|
||||||
const DISSOLVED_PK: &str = "4d3d55d88fdf9d9c2089651e5cbb0dfa93b6b9b10cdcb2319b0dce1a1398096a";
|
|
||||||
const GRANT_LOCATOR: &str = "fd2f88cc7f1eb8d7d862c91dc22afe700c358d1845158b3f353b769ce4898e35";
|
|
||||||
const BANLIST_LOCATOR: &str =
|
|
||||||
"88089214afae6d3c412fd817ada44d6df4d485a53565646471e74476397693c9";
|
|
||||||
const INVITE_LINKS_LOCATOR: &str =
|
|
||||||
"f4ae29994165767bac23e8dce630f81b926d2c8aa150e5cbf0bdf75865e8379a";
|
|
||||||
const RECIPIENT_LOCATOR: &str =
|
|
||||||
"342deb400e191f0f52c81f27600934552550beb85aa9bf169f02d0e7f826cf74";
|
|
||||||
const INVITE_KEY: &str = "94bf8b0d89e579ddaeccf8d9db3f5de5c86a1259c597f2560ff0120173bc5e1f";
|
|
||||||
const COMMUNITY_ID: &str = "2b790bd59df98bdc52092b74ebd6933a89ef8eaeecc9030861cbdeae7c814c46";
|
|
||||||
const EPOCH_COMMITMENT: &str =
|
|
||||||
"3e6d6a3c9973c16d1ca7c5602d36979927c55c21a7e2c840f883af3f047e80a4";
|
|
||||||
const PINS_LOCATOR: &str = "3b4529395a35c981ed409b588af3c4cd3081992958a485347356a173c3146c52";
|
|
||||||
const EPOCH_MULTI: u64 = 0x0102030405060708;
|
|
||||||
|
|
||||||
/// `0x00..0x1f` / `0xff..0xe0` / `0x11` x32 — the inputs every vector uses.
|
|
||||||
fn secret() -> [u8; 32] {
|
|
||||||
let mut key = [0u8; 32];
|
|
||||||
for (index, byte) in key.iter_mut().enumerate() {
|
|
||||||
*byte = index as u8;
|
|
||||||
}
|
|
||||||
key
|
|
||||||
}
|
|
||||||
|
|
||||||
fn id32() -> [u8; 32] {
|
|
||||||
let mut id = [0u8; 32];
|
|
||||||
for (index, byte) in id.iter_mut().enumerate() {
|
|
||||||
*byte = 255 - index as u8;
|
|
||||||
}
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hex(bytes: &[u8]) -> String {
|
|
||||||
data_encoding::HEXLOWER.encode(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn golden_vectors() {
|
|
||||||
let secret = secret();
|
|
||||||
let id = id32();
|
|
||||||
let alt = [0x11u8; 32];
|
|
||||||
let community_id = CommunityId::from_bytes(id);
|
|
||||||
let channel = ChannelId::from_bytes(id);
|
|
||||||
|
|
||||||
let channel_e0 = channel_group_key(&secret, &channel, Epoch(0)).expect("derives");
|
|
||||||
assert_eq!(
|
|
||||||
hex(channel_e0.keys().secret_key().as_secret_bytes()),
|
|
||||||
CHANNEL_E0_SEED
|
|
||||||
);
|
|
||||||
assert_eq!(channel_e0.pk_hex(), CHANNEL_E0_PK);
|
|
||||||
assert_eq!(
|
|
||||||
channel_group_key(&secret, &channel, Epoch(EPOCH_MULTI))
|
|
||||||
.expect("derives")
|
|
||||||
.pk_hex(),
|
|
||||||
CHANNEL_EMULTI_PK
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
control_group_key(&secret, &community_id, Epoch(0))
|
|
||||||
.expect("derives")
|
|
||||||
.pk_hex(),
|
|
||||||
CONTROL_E0_PK
|
|
||||||
);
|
|
||||||
|
|
||||||
let signer = control_signer_group_key(&secret, &community_id, Epoch(0)).expect("derives");
|
|
||||||
assert_eq!(
|
|
||||||
hex(signer.keys().secret_key().as_secret_bytes()),
|
|
||||||
CONTROL_SIGNER_E0_SEED
|
|
||||||
);
|
|
||||||
assert_eq!(signer.pk_hex(), CONTROL_SIGNER_E0_PK);
|
|
||||||
assert_eq!(
|
|
||||||
control_signer_group_key(&secret, &community_id, Epoch(EPOCH_MULTI))
|
|
||||||
.expect("derives")
|
|
||||||
.pk_hex(),
|
|
||||||
CONTROL_SIGNER_EMULTI_PK
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
guestbook_group_key(&secret, &community_id, Epoch(0))
|
|
||||||
.expect("derives")
|
|
||||||
.pk_hex(),
|
|
||||||
GUESTBOOK_E0_PK
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
channel_rekey_group_key(&secret, &channel, Epoch(1))
|
|
||||||
.expect("derives")
|
|
||||||
.pk_hex(),
|
|
||||||
CHANNEL_REKEY_E1_PK
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
base_rekey_group_key(&secret, &community_id, Epoch(1))
|
|
||||||
.expect("derives")
|
|
||||||
.pk_hex(),
|
|
||||||
BASE_REKEY_E1_PK
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
dissolved_group_key(&community_id)
|
|
||||||
.expect("derives")
|
|
||||||
.pk_hex(),
|
|
||||||
DISSOLVED_PK
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(hex(&grant_locator(&community_id, &alt)), GRANT_LOCATOR);
|
|
||||||
assert_eq!(hex(&banlist_locator(&community_id)), BANLIST_LOCATOR);
|
|
||||||
assert_eq!(
|
|
||||||
hex(&invite_links_locator(&community_id, &alt)),
|
|
||||||
INVITE_LINKS_LOCATOR
|
|
||||||
);
|
|
||||||
assert_eq!(hex(&pins_locator(&community_id, &channel)), PINS_LOCATOR);
|
|
||||||
assert_eq!(
|
|
||||||
hex(&recipient_locator(&secret, &alt, &id, Epoch(3))),
|
|
||||||
RECIPIENT_LOCATOR
|
|
||||||
);
|
|
||||||
assert_eq!(hex(&invite_bundle_key(&[0x07u8; TOKEN_LEN])), INVITE_KEY);
|
|
||||||
|
|
||||||
assert_eq!(hex(community_id_of(&secret, &alt).as_bytes()), COMMUNITY_ID);
|
|
||||||
assert_eq!(
|
|
||||||
hex(&epoch_key_commitment(Epoch(2), &secret)),
|
|
||||||
EPOCH_COMMITMENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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 fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
|
||||||
decode_hex_lower::<32>(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn decode_hex_lower<const N: usize>(value: &str) -> Result<[u8; N]> {
|
|
||||||
let bytes = HEXLOWER
|
|
||||||
.decode(value.as_bytes())
|
|
||||||
.map_err(|error| anyhow!("invalid hex: {error}"))?;
|
|
||||||
|
|
||||||
let decoded: [u8; N] = bytes
|
|
||||||
.as_slice()
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| anyhow!("expected {N} bytes, got {}", bytes.len()))?;
|
|
||||||
|
|
||||||
if HEXLOWER.encode(&decoded) != value {
|
|
||||||
bail!("hex must be lowercase and canonical");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn fill_random(bytes: &mut [u8]) -> Result<()> {
|
|
||||||
SysRng
|
|
||||||
.try_fill_bytes(bytes)
|
|
||||||
.map_err(|error| anyhow!("os rng: {error}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn random_32() -> Result<[u8; 32]> {
|
|
||||||
let mut bytes = [0u8; 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,8 @@ anyhow.workspace = true
|
|||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
|
common = { path = "../common" }
|
||||||
|
person = { path = "../person" }
|
||||||
chat = { path = "../chat" }
|
chat = { path = "../chat" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
|||||||
+183
-11
@@ -3,7 +3,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{Result, anyhow, bail};
|
||||||
use chat::Message;
|
use chat::Message;
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
use common::emojis_from_tags;
|
||||||
|
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
@@ -12,7 +13,7 @@ use crate::LOCAL_KEYS;
|
|||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
||||||
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
|
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
|
||||||
TimelineWindow,
|
TimelineWindow, tag_value,
|
||||||
};
|
};
|
||||||
|
|
||||||
const REPLAY_LIMIT: usize = 100;
|
const REPLAY_LIMIT: usize = 100;
|
||||||
@@ -27,6 +28,21 @@ enum Absorbed {
|
|||||||
Updated,
|
Updated,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Reaction {
|
||||||
|
pub id: EventId,
|
||||||
|
pub emoji: String,
|
||||||
|
pub image: Option<SharedUri>,
|
||||||
|
pub author: PublicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Invite {
|
||||||
|
pub id: EventId,
|
||||||
|
pub code: String,
|
||||||
|
pub created_at: Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Row {
|
pub enum Row {
|
||||||
Message(Box<Message>),
|
Message(Box<Message>),
|
||||||
@@ -78,6 +94,9 @@ pub struct Group {
|
|||||||
state_ids: HashMap<Kind, EventId>,
|
state_ids: HashMap<Kind, EventId>,
|
||||||
rows: Vec<Row>,
|
rows: Vec<Row>,
|
||||||
index: HashSet<EventId>,
|
index: HashSet<EventId>,
|
||||||
|
reactions: HashMap<EventId, Vec<Reaction>>,
|
||||||
|
/// Active invite codes; revocations arrive as 9005 deletions.
|
||||||
|
invites: Vec<Invite>,
|
||||||
leave_requests: HashMap<PublicKey, Timestamp>,
|
leave_requests: HashMap<PublicKey, Timestamp>,
|
||||||
window: TimelineWindow,
|
window: TimelineWindow,
|
||||||
oldest: Option<Timestamp>,
|
oldest: Option<Timestamp>,
|
||||||
@@ -105,6 +124,8 @@ impl Group {
|
|||||||
state_ids: HashMap::new(),
|
state_ids: HashMap::new(),
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
index: HashSet::new(),
|
index: HashSet::new(),
|
||||||
|
reactions: HashMap::new(),
|
||||||
|
invites: Vec::new(),
|
||||||
leave_requests: HashMap::new(),
|
leave_requests: HashMap::new(),
|
||||||
window: TimelineWindow::new(),
|
window: TimelineWindow::new(),
|
||||||
oldest: None,
|
oldest: None,
|
||||||
@@ -180,6 +201,14 @@ impl Group {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn reactions(&self, id: &EventId) -> &[Reaction] {
|
||||||
|
self.reactions.get(id).map(Vec::as_slice).unwrap_or(&[])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invites(&self) -> &[Invite] {
|
||||||
|
&self.invites
|
||||||
|
}
|
||||||
|
|
||||||
pub fn loading_more(&self) -> bool {
|
pub fn loading_more(&self) -> bool {
|
||||||
self.loading_more
|
self.loading_more
|
||||||
}
|
}
|
||||||
@@ -204,18 +233,26 @@ impl Group {
|
|||||||
&mut self,
|
&mut self,
|
||||||
content: &str,
|
content: &str,
|
||||||
replies: Vec<EventId>,
|
replies: Vec<EventId>,
|
||||||
|
emojis: Vec<Tag>,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<()>> {
|
) -> Task<Result<()>> {
|
||||||
self.dispatch(move |id, prev| id.message(content, &replies, prev), cx)
|
self.dispatch(
|
||||||
|
move |id, prev| id.message(content, &replies, &emojis, prev),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_reaction(
|
pub fn send_reaction(
|
||||||
&mut self,
|
&mut self,
|
||||||
target: EventId,
|
target: EventId,
|
||||||
emoji: String,
|
emoji: String,
|
||||||
|
emojis: Vec<Tag>,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<()>> {
|
) -> Task<Result<()>> {
|
||||||
self.dispatch(move |id, prev| id.reaction(target, &emoji, prev), cx)
|
self.dispatch(
|
||||||
|
move |id, prev| id.reaction(target, &emoji, &emojis, prev),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_user(
|
pub fn remove_user(
|
||||||
@@ -247,6 +284,10 @@ impl Group {
|
|||||||
self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx)
|
self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn delete_group(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||||
|
self.dispatch(GroupId::delete_group, cx)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_invite(&mut self, code: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
|
pub fn create_invite(&mut self, code: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||||
self.dispatch(move |id, prev| id.create_invite(code, prev), cx)
|
self.dispatch(move |id, prev| id.create_invite(code, prev), cx)
|
||||||
}
|
}
|
||||||
@@ -309,6 +350,15 @@ impl Group {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn mark_member(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.membership == Membership::Member {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.membership = Membership::Member;
|
||||||
|
cx.emit(GroupEvent::Updated);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn mark_refused(&mut self, reason: String, cx: &mut Context<Self>) {
|
pub(crate) fn mark_refused(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||||
if self.membership == Membership::Member {
|
if self.membership == Membership::Member {
|
||||||
return;
|
return;
|
||||||
@@ -349,6 +399,7 @@ impl Group {
|
|||||||
let filters = [
|
let filters = [
|
||||||
key.id().timeline_filter(REPLAY_LIMIT),
|
key.id().timeline_filter(REPLAY_LIMIT),
|
||||||
key.id().membership_filter(me),
|
key.id().membership_filter(me),
|
||||||
|
key.id().moderation_filter(REPLAY_LIMIT),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(key.id().state_filters())
|
.chain(key.id().state_filters())
|
||||||
@@ -497,15 +548,31 @@ impl Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context<Self>) {
|
pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context<Self>) {
|
||||||
|
if self.retract(id) {
|
||||||
|
cx.emit(GroupEvent::Updated);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an event and everything attached to it.
|
||||||
|
fn retract(&mut self, id: EventId) -> bool {
|
||||||
self.window.remove(id);
|
self.window.remove(id);
|
||||||
|
|
||||||
if !self.index.remove(&id) {
|
if !self.index.remove(&id) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.rows.retain(|row| row.id() != id);
|
self.rows.retain(|row| row.id() != id);
|
||||||
cx.emit(GroupEvent::Updated);
|
self.invites.retain(|invite| invite.id != id);
|
||||||
cx.notify();
|
self.reactions.remove(&id);
|
||||||
|
|
||||||
|
for reactions in self.reactions.values_mut() {
|
||||||
|
reactions.retain(|reaction| reaction.id != id);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.reactions.retain(|_, reactions| !reactions.is_empty());
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn eose(&mut self, cx: &mut Context<Self>) {
|
pub(crate) fn eose(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -523,13 +590,26 @@ impl Group {
|
|||||||
|
|
||||||
fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed {
|
fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed {
|
||||||
if STATE_KINDS.contains(&event.kind) {
|
if STATE_KINDS.contains(&event.kind) {
|
||||||
return if self.absorb_state(event) {
|
self.window.note(event);
|
||||||
|
|
||||||
|
return if self.absorb_state(event, me) {
|
||||||
Absorbed::Updated
|
Absorbed::Updated
|
||||||
} else {
|
} else {
|
||||||
Absorbed::Ignored
|
Absorbed::Ignored
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NIP-29: relays must reject events whose
|
||||||
|
// `previous` references are absent from their database.
|
||||||
|
let previous = event.tags.iter().find(|tag| tag.kind() == "previous");
|
||||||
|
if self.window.references_seen(previous) == Some(false) {
|
||||||
|
log::warn!(
|
||||||
|
"nip29: event {} carries previous references not seen on {}",
|
||||||
|
event.id,
|
||||||
|
self.key.relay()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
self.track(event);
|
self.track(event);
|
||||||
|
|
||||||
let updated = self.absorb_membership(event, me);
|
let updated = self.absorb_membership(event, me);
|
||||||
@@ -541,6 +621,36 @@ impl Group {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if event.kind == Kind::Reaction {
|
||||||
|
return if self.push_reaction(event) {
|
||||||
|
Absorbed::Updated
|
||||||
|
} else {
|
||||||
|
Absorbed::Ignored
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.kind == Kind::GroupCreateInvite {
|
||||||
|
return if self.push_invite(event) {
|
||||||
|
Absorbed::Updated
|
||||||
|
} else {
|
||||||
|
Absorbed::Ignored
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.kind == Kind::GroupDeleteEvent {
|
||||||
|
let retracted = event
|
||||||
|
.tags
|
||||||
|
.event_ids()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|target| self.retract(target));
|
||||||
|
|
||||||
|
return if retracted {
|
||||||
|
Absorbed::Updated
|
||||||
|
} else {
|
||||||
|
Absorbed::Ignored
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if RENDER_KINDS.contains(&event.kind) {
|
if RENDER_KINDS.contains(&event.kind) {
|
||||||
return Absorbed::Shown {
|
return Absorbed::Shown {
|
||||||
fresh: self.push_row(event),
|
fresh: self.push_row(event),
|
||||||
@@ -583,18 +693,24 @@ impl Group {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn absorb_state(&mut self, event: &Event) -> bool {
|
fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool {
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
||||||
Ok(metadata) => self.metadata = Some(metadata),
|
Ok(metadata) => self.metadata = Some(metadata),
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
},
|
},
|
||||||
Kind::GroupAdmins => match GroupAdmins::parse(event) {
|
Kind::GroupAdmins => match GroupAdmins::parse(event) {
|
||||||
Ok(admins) => self.admins = admins,
|
Ok(admins) => {
|
||||||
|
self.admins = admins;
|
||||||
|
self.adopt_roster_membership(me);
|
||||||
|
}
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
},
|
},
|
||||||
Kind::GroupMembers => match GroupMembers::parse(event) {
|
Kind::GroupMembers => match GroupMembers::parse(event) {
|
||||||
Ok(members) => self.members = members,
|
Ok(members) => {
|
||||||
|
self.members = members;
|
||||||
|
self.adopt_roster_membership(me);
|
||||||
|
}
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
},
|
},
|
||||||
Kind::GroupRoles => match GroupRoles::parse(event) {
|
Kind::GroupRoles => match GroupRoles::parse(event) {
|
||||||
@@ -611,6 +727,21 @@ impl Group {
|
|||||||
self.state_ids.insert(event.kind, event.id) != Some(event.id)
|
self.state_ids.insert(event.kind, event.id) != Some(event.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn adopt_roster_membership(&mut self, me: PublicKey) -> bool {
|
||||||
|
if self.membership != Membership::Unknown {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let listed = self.admins.contains(&me) || self.members.iter().any(|member| *member == me);
|
||||||
|
|
||||||
|
if !listed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.membership = Membership::Member;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn push_row(&mut self, event: &Event) -> bool {
|
fn push_row(&mut self, event: &Event) -> bool {
|
||||||
if !self.index.insert(event.id) {
|
if !self.index.insert(event.id) {
|
||||||
return false;
|
return false;
|
||||||
@@ -626,6 +757,47 @@ impl Group {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn push_reaction(&mut self, event: &Event) -> bool {
|
||||||
|
let Some(target) = event.tags.event_ids().next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.index.insert(event.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let emojis = emojis_from_tags(&event.tags);
|
||||||
|
let image = Message::reaction_image(&event.content, &emojis)
|
||||||
|
.map(|url| SharedUri::from(url.to_string()));
|
||||||
|
|
||||||
|
self.reactions.entry(target).or_default().push(Reaction {
|
||||||
|
id: event.id,
|
||||||
|
emoji: event.content.clone(),
|
||||||
|
image,
|
||||||
|
author: event.pubkey,
|
||||||
|
});
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_invite(&mut self, event: &Event) -> bool {
|
||||||
|
let Some(code) = tag_value(&event.tags, "code") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.index.insert(event.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.invites.push(Invite {
|
||||||
|
id: event.id,
|
||||||
|
code: code.to_owned(),
|
||||||
|
created_at: event.created_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn push_activity(&mut self, event: &Event) -> bool {
|
fn push_activity(&mut self, event: &Event) -> bool {
|
||||||
let activity = match Activity::parse(event) {
|
let activity = match Activity::parse(event) {
|
||||||
Ok(activity) => activity,
|
Ok(activity) => activity,
|
||||||
|
|||||||
+205
-25
@@ -2,11 +2,11 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, Result};
|
use anyhow::{Error, Result, anyhow};
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::{NostrRegistry, UniversalSigner};
|
||||||
|
|
||||||
mod discovery;
|
mod discovery;
|
||||||
mod group;
|
mod group;
|
||||||
@@ -23,6 +23,10 @@ pub(crate) static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
|||||||
const BROWSE_LIMIT: usize = 500;
|
const BROWSE_LIMIT: usize = 500;
|
||||||
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
|
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||||
|
const CONFIRM_POLLS: usize = 20;
|
||||||
|
const CONFIRM_INTERVAL: Duration = Duration::from_millis(250);
|
||||||
|
const METADATA_WARNING: &str =
|
||||||
|
"Group created, but the relay did not accept its name. Edit the group to set it.";
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
|
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
|
||||||
@@ -46,6 +50,11 @@ pub enum GroupsEvent {
|
|||||||
},
|
},
|
||||||
/// The discovery synced new groups into the local database.
|
/// The discovery synced new groups into the local database.
|
||||||
Synced,
|
Synced,
|
||||||
|
/// A relay accepted a new group; the warning carries a non-fatal issue.
|
||||||
|
Created {
|
||||||
|
key: GroupKey,
|
||||||
|
warning: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -161,7 +170,7 @@ impl GroupsRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.upsert_entry(&key, None);
|
self.upsert_entry(&key, None, false);
|
||||||
self.publish_list(cx);
|
self.publish_list(cx);
|
||||||
|
|
||||||
cx.emit(GroupsEvent::Updated);
|
cx.emit(GroupsEvent::Updated);
|
||||||
@@ -177,11 +186,16 @@ impl GroupsRegistry {
|
|||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
if let Err(error) = sent.await {
|
if let Err(error) = sent.await {
|
||||||
let reason = error.to_string();
|
let reason = error.to_string();
|
||||||
|
let duplicate = reason.starts_with("duplicate");
|
||||||
|
|
||||||
if let Err(error) = this.update(cx, |this, cx| {
|
if let Err(error) = this.update(cx, |this, cx| {
|
||||||
if let Some(group) = this.group(&key, cx) {
|
if let Some(group) = this.group(&key, cx) {
|
||||||
group.update(cx, |group, cx| {
|
group.update(cx, |group, cx| {
|
||||||
group.mark_refused(reason, cx);
|
if duplicate {
|
||||||
|
group.mark_member(cx);
|
||||||
|
} else {
|
||||||
|
group.mark_refused(reason, cx);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
@@ -191,31 +205,94 @@ impl GroupsRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create(&mut self, key: GroupKey, metadata: GroupMetadata, cx: &mut Context<Self>) {
|
pub fn create(&mut self, relay: RelayUrl, options: GroupCreateOptions, cx: &mut Context<Self>) {
|
||||||
|
let name = options.name.trim().to_owned();
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
cx.emit(GroupsEvent::Error("group name is empty".to_owned()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = options.custom_id.as_deref().unwrap_or_default();
|
||||||
|
let id = if raw.is_empty() {
|
||||||
|
readable_id()
|
||||||
|
} else {
|
||||||
|
raw.trim().to_lowercase()
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = match GroupId::new(id) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(error) => {
|
||||||
|
cx.emit(GroupsEvent::Error(error.to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = GroupKey::new(relay, id);
|
||||||
self.ensure(key.clone(), cx);
|
self.ensure(key.clone(), cx);
|
||||||
|
|
||||||
let Some(group) = self.group(&key, cx) else {
|
let Some(group) = self.group(&key, cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.upsert_entry(&key, metadata.name().map(str::to_owned));
|
let metadata = options.metadata(name.clone());
|
||||||
self.publish_list(cx);
|
let list_private = options.list_private;
|
||||||
|
let builder = key.id().create_group(None);
|
||||||
cx.emit(GroupsEvent::Updated);
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
let builder = key.id().create_group(&metadata, None);
|
|
||||||
let published = group.read(cx).publish(builder, cx);
|
let published = group.read(cx).publish(builder, cx);
|
||||||
let logged = key.clone();
|
let created = key;
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
if let Err(e) = published.await {
|
if let Err(error) = published.await {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.forget(logged, cx);
|
this.forget(created, cx);
|
||||||
cx.emit(GroupsEvent::Error(e.to_string()));
|
cx.emit(GroupsEvent::Error(error.to_string()));
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Give the relay time to publish the group's metadata event,
|
||||||
|
// which settles the group id (the relay may override the suggested one).
|
||||||
|
for _ in 0..CONFIRM_POLLS {
|
||||||
|
if group.read_with(cx, |group, _cx| group.metadata().is_some()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cx.background_executor().timer(CONFIRM_INTERVAL).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The relay can reject the metadata while the admin grant from the
|
||||||
|
// create is still settling, one delayed retry covers that race.
|
||||||
|
let mut warning = None;
|
||||||
|
|
||||||
|
for attempt in 0..2 {
|
||||||
|
if attempt > 0 {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let published = group.update(cx, |group, cx| group.edit_metadata(&metadata, cx));
|
||||||
|
let accepted = published.await.is_ok();
|
||||||
|
|
||||||
|
if accepted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt == 1 {
|
||||||
|
warning = Some(METADATA_WARNING.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.upsert_entry(&created, Some(name), list_private);
|
||||||
|
this.publish_list(cx);
|
||||||
|
|
||||||
|
cx.emit(GroupsEvent::Updated);
|
||||||
|
cx.emit(GroupsEvent::Created {
|
||||||
|
key: created,
|
||||||
|
warning,
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +417,30 @@ impl GroupsRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the relay to delete the group (kind 9008).
|
||||||
|
pub fn delete_group(&mut self, key: &GroupKey, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||||
|
let Some(group) = self.group(key, cx) else {
|
||||||
|
return Task::ready(Err(anyhow!("nip29: group not found")));
|
||||||
|
};
|
||||||
|
|
||||||
|
let sent = group.update(cx, |group, cx| group.delete_group(cx));
|
||||||
|
let key = key.clone();
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let result = sent.await;
|
||||||
|
|
||||||
|
if result.is_ok()
|
||||||
|
&& let Err(error) = this.update(cx, |this, cx| {
|
||||||
|
this.forget(key, cx);
|
||||||
|
})
|
||||||
|
{
|
||||||
|
log::warn!("forgetting the deleted group failed: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
self.notification_listener = None;
|
self.notification_listener = None;
|
||||||
self.signal_consumer = None;
|
self.signal_consumer = None;
|
||||||
@@ -392,7 +493,10 @@ impl GroupsRegistry {
|
|||||||
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
||||||
|
|
||||||
match client.database().query(filter).await?.into_iter().next() {
|
match client.database().query(filter).await?.into_iter().next() {
|
||||||
Some(event) => GroupList::parse(&event),
|
Some(event) => {
|
||||||
|
let list = GroupList::parse(&event)?;
|
||||||
|
Ok(Self::hydrate_list(list, &signer, &me).await)
|
||||||
|
}
|
||||||
None => Ok(GroupList::default()),
|
None => Ok(GroupList::default()),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -435,6 +539,10 @@ impl GroupsRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
|
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
|
||||||
|
// Carry the event's private section so a later publish can preserve it.
|
||||||
|
self.list
|
||||||
|
.set_content(list.content().map(str::to_owned), list.content_readable());
|
||||||
|
|
||||||
let entries = list.entries().to_vec();
|
let entries = list.entries().to_vec();
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
@@ -446,6 +554,58 @@ impl GroupsRegistry {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrypt a list event's private section into its entries.
|
||||||
|
async fn hydrate_list(
|
||||||
|
mut list: GroupList,
|
||||||
|
signer: &UniversalSigner,
|
||||||
|
me: &PublicKey,
|
||||||
|
) -> GroupList {
|
||||||
|
let Some(content) = list.content().map(str::to_owned) else {
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
|
||||||
|
match signer.nip44_decrypt(me, &content).await {
|
||||||
|
Ok(plaintext) => match parse_private_section(&plaintext) {
|
||||||
|
Some(entries) => {
|
||||||
|
list.merge_private(entries);
|
||||||
|
list.mark_readable();
|
||||||
|
}
|
||||||
|
None => log::warn!("ignoring a malformed private group list section"),
|
||||||
|
},
|
||||||
|
Err(error) => log::warn!("decrypting the private group list failed: {error}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absorb a list event, decrypting its private section off the hot path.
|
||||||
|
fn absorb_list(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||||
|
let list = match GroupList::parse(&event) {
|
||||||
|
Ok(list) => list,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("ignoring a malformed group list: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if list.content().is_none() {
|
||||||
|
self.merge(list, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
let Some(me) = nostr.read(cx).current_user() else {
|
||||||
|
self.merge(list, cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let list = Self::hydrate_list(list, &signer, &me).await;
|
||||||
|
this.update(cx, |this, cx| this.merge(list, cx)).ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
fn watch_forks(&mut self, cx: &mut Context<Self>) {
|
fn watch_forks(&mut self, cx: &mut Context<Self>) {
|
||||||
let executor = cx.background_executor().clone();
|
let executor = cx.background_executor().clone();
|
||||||
let tx = self.signal_tx.clone();
|
let tx = self.signal_tx.clone();
|
||||||
@@ -662,13 +822,14 @@ impl GroupsRegistry {
|
|||||||
.and_then(|entry| entry.name.clone())
|
.and_then(|entry| entry.name.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upsert_entry(&mut self, key: &GroupKey, name: Option<String>) {
|
fn upsert_entry(&mut self, key: &GroupKey, name: Option<String>, private: bool) {
|
||||||
let name = name.or_else(|| self.entry_name(key));
|
let name = name.or_else(|| self.entry_name(key));
|
||||||
|
|
||||||
self.list.upsert(GroupListEntry {
|
self.list.upsert(GroupListEntry {
|
||||||
id: key.id().clone(),
|
id: key.id().clone(),
|
||||||
relay: key.relay().clone(),
|
relay: key.relay().clone(),
|
||||||
name,
|
name,
|
||||||
|
private,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,7 +851,9 @@ impl GroupsRegistry {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.upsert_entry(&key, Some(name));
|
let private = self.list.entry(&key).is_some_and(|entry| entry.private);
|
||||||
|
|
||||||
|
self.upsert_entry(&key, Some(name), private);
|
||||||
self.publish_list(cx);
|
self.publish_list(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,10 +861,30 @@ impl GroupsRegistry {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
let me = nostr.read(cx).current_user();
|
||||||
let builder = self.list.to_builder();
|
let list = self.list.clone();
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
||||||
|
// Re-encrypt the private section when it carries local entries; a
|
||||||
|
// section this session could not read is carried verbatim.
|
||||||
|
let content = match (list.has_private(), list.content_readable()) {
|
||||||
|
(true, _) => match list.encode_private().zip(me) {
|
||||||
|
Some((plaintext, me)) => match signer.nip44_encrypt(&me, &plaintext).await {
|
||||||
|
Ok(content) => Some(content),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("encrypting the private group list failed: {error}");
|
||||||
|
list.content().map(str::to_owned)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
(false, false) => list.content().map(str::to_owned),
|
||||||
|
(false, true) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let builder = EventBuilder::new(Kind::SimpleGroups, content.unwrap_or_default())
|
||||||
|
.tags(list.public_tags());
|
||||||
|
|
||||||
let event = match builder.finalize_async(&signer).await {
|
let event = match builder.finalize_async(&signer).await {
|
||||||
Ok(event) => event,
|
Ok(event) => event,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -858,10 +1041,7 @@ impl GroupsRegistry {
|
|||||||
|
|
||||||
fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context<Self>) {
|
fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context<Self>) {
|
||||||
if id == list_subscription_id() {
|
if id == list_subscription_id() {
|
||||||
match GroupList::parse(&event) {
|
self.absorb_list(event, cx);
|
||||||
Ok(list) => self.merge(list, cx),
|
|
||||||
Err(error) => log::warn!("ignoring a malformed group list: {error}"),
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+361
-38
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
@@ -43,7 +44,7 @@ fn has_tag(tags: &Tags, name: &str) -> bool {
|
|||||||
tags.iter().any(|tag| tag.kind() == name)
|
tags.iter().any(|tag| tag.kind() == name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
pub(crate) fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||||
tags.iter()
|
tags.iter()
|
||||||
.find(|tag| tag.kind() == name)
|
.find(|tag| tag.kind() == name)
|
||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
@@ -98,22 +99,38 @@ impl GroupId {
|
|||||||
.kinds(MEMBERSHIP_KINDS)
|
.kinds(MEMBERSHIP_KINDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn moderation_filter(&self, limit: usize) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
|
||||||
|
.kinds([Kind::GroupCreateInvite, Kind::GroupDeleteEvent])
|
||||||
|
.limit(limit)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn message(
|
pub fn message(
|
||||||
&self,
|
&self,
|
||||||
content: &str,
|
content: &str,
|
||||||
replies: &[EventId],
|
replies: &[EventId],
|
||||||
|
emojis: &[Tag],
|
||||||
previous: Option<Tag>,
|
previous: Option<Tag>,
|
||||||
) -> EventBuilder {
|
) -> EventBuilder {
|
||||||
EventBuilder::new(Kind::ChatMessage, content)
|
EventBuilder::new(Kind::ChatMessage, content)
|
||||||
.tag(self.h_tag())
|
.tag(self.h_tag())
|
||||||
.tags(replies.iter().copied().map(Tag::event))
|
.tags(replies.iter().copied().map(Tag::event))
|
||||||
|
.tags(emojis.iter().cloned())
|
||||||
.tag_maybe(previous)
|
.tag_maybe(previous)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reaction(&self, target: EventId, emoji: &str, previous: Option<Tag>) -> EventBuilder {
|
pub fn reaction(
|
||||||
|
&self,
|
||||||
|
target: EventId,
|
||||||
|
emoji: &str,
|
||||||
|
emojis: &[Tag],
|
||||||
|
previous: Option<Tag>,
|
||||||
|
) -> EventBuilder {
|
||||||
EventBuilder::new(Kind::Reaction, emoji)
|
EventBuilder::new(Kind::Reaction, emoji)
|
||||||
.tag(self.h_tag())
|
.tag(self.h_tag())
|
||||||
.tag(Tag::event(target))
|
.tag(Tag::event(target))
|
||||||
|
.tags(emojis.iter().cloned())
|
||||||
.tag_maybe(previous)
|
.tag_maybe(previous)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,9 +172,9 @@ impl GroupId {
|
|||||||
.tag_maybe(previous)
|
.tag_maybe(previous)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_group(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
|
pub fn create_group(&self, previous: Option<Tag>) -> EventBuilder {
|
||||||
EventBuilder::new(Kind::GroupCreateGroup, "")
|
EventBuilder::new(Kind::GroupCreateGroup, "")
|
||||||
.tags(self.metadata_tags(metadata))
|
.tag(self.h_tag())
|
||||||
.tag_maybe(previous)
|
.tag_maybe(previous)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +229,12 @@ impl GroupId {
|
|||||||
.tag_maybe(previous)
|
.tag_maybe(previous)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn delete_group(&self, previous: Option<Tag>) -> EventBuilder {
|
||||||
|
EventBuilder::new(Kind::GroupDeleteGroup, "")
|
||||||
|
.tag(self.h_tag())
|
||||||
|
.tag_maybe(previous)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_invite(&self, code: &str, previous: Option<Tag>) -> EventBuilder {
|
pub fn create_invite(&self, code: &str, previous: Option<Tag>) -> EventBuilder {
|
||||||
EventBuilder::new(Kind::GroupCreateInvite, "")
|
EventBuilder::new(Kind::GroupCreateInvite, "")
|
||||||
.tag(self.h_tag())
|
.tag(self.h_tag())
|
||||||
@@ -435,6 +458,38 @@ impl GroupMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Input for creating a group: access flags are presence-only per NIP-29.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct GroupCreateOptions {
|
||||||
|
pub name: String,
|
||||||
|
pub about: Option<String>,
|
||||||
|
pub picture: Option<String>,
|
||||||
|
pub private: bool,
|
||||||
|
pub closed: bool,
|
||||||
|
pub restricted: bool,
|
||||||
|
pub hidden: bool,
|
||||||
|
/// List the group in the NIP-44 encrypted section of the user's group list.
|
||||||
|
pub list_private: bool,
|
||||||
|
pub custom_id: Option<String>,
|
||||||
|
pub parent: Option<GroupId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupCreateOptions {
|
||||||
|
pub(crate) fn metadata(&self, name: String) -> GroupMetadata {
|
||||||
|
GroupMetadata {
|
||||||
|
name: Some(name),
|
||||||
|
picture: self.picture.clone(),
|
||||||
|
about: self.about.clone(),
|
||||||
|
private: self.private,
|
||||||
|
restricted: self.restricted,
|
||||||
|
hidden: self.hidden,
|
||||||
|
closed: self.closed,
|
||||||
|
parent: self.parent.clone(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GroupCandidate {
|
pub struct GroupCandidate {
|
||||||
pub key: GroupKey,
|
pub key: GroupKey,
|
||||||
@@ -611,10 +666,20 @@ pub(crate) struct GroupListEntry {
|
|||||||
pub id: GroupId,
|
pub id: GroupId,
|
||||||
pub relay: RelayUrl,
|
pub relay: RelayUrl,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
/// Kept in the encrypted section of the list event, not in public tags.
|
||||||
|
pub private: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub(crate) struct GroupList(Vec<GroupListEntry>);
|
pub(crate) struct GroupList {
|
||||||
|
entries: Vec<GroupListEntry>,
|
||||||
|
/// The raw private section of the last seen list event.
|
||||||
|
content: Option<String>,
|
||||||
|
/// Whether the content was decrypted in this session.
|
||||||
|
readable: bool,
|
||||||
|
/// Tags written by other clients.
|
||||||
|
foreign: Vec<Tag>,
|
||||||
|
}
|
||||||
|
|
||||||
impl GroupList {
|
impl GroupList {
|
||||||
pub fn parse(event: &Event) -> Result<Self> {
|
pub fn parse(event: &Event) -> Result<Self> {
|
||||||
@@ -623,55 +688,123 @@ impl GroupList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
|
let mut foreign = Vec::new();
|
||||||
|
|
||||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "group") {
|
for tag in event.tags.iter() {
|
||||||
let fields = tag.as_slice();
|
match tag.kind() {
|
||||||
|
"group" => {
|
||||||
|
let fields = tag.as_slice();
|
||||||
|
|
||||||
let (Some(id), Some(relay)) = (fields.get(1), fields.get(2)) else {
|
let (Some(id), Some(relay)) = (fields.get(1), fields.get(2)) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok(id) = GroupId::new(id.as_str()) else {
|
let Ok(id) = GroupId::new(id.as_str()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Ok(relay) = RelayUrl::parse(relay.as_str()) else {
|
let Ok(relay) = RelayUrl::parse(relay.as_str()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
entries.push(GroupListEntry {
|
entries.push(GroupListEntry {
|
||||||
id,
|
id,
|
||||||
relay,
|
relay,
|
||||||
name: fields.get(3).cloned(),
|
name: fields.get(3).cloned(),
|
||||||
});
|
private: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Rebuilt from the entries on publish.
|
||||||
|
"r" => {}
|
||||||
|
_ => foreign.push(tag.clone()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self(entries))
|
let content = if event.content.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(event.content.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
entries,
|
||||||
|
readable: content.is_none(),
|
||||||
|
content,
|
||||||
|
foreign,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn entries(&self) -> &[GroupListEntry] {
|
pub fn entries(&self) -> &[GroupListEntry] {
|
||||||
&self.0
|
&self.entries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entry(&self, key: &GroupKey) -> Option<&GroupListEntry> {
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.id == *key.id() && entry.relay == *key.relay())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert(&mut self, entry: GroupListEntry) {
|
pub fn upsert(&mut self, entry: GroupListEntry) {
|
||||||
match self
|
match self
|
||||||
.0
|
.entries
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|existing| existing.id == entry.id && existing.relay == entry.relay)
|
.find(|existing| existing.id == entry.id && existing.relay == entry.relay)
|
||||||
{
|
{
|
||||||
Some(existing) => *existing = entry,
|
Some(existing) => *existing = entry,
|
||||||
None => self.0.push(entry),
|
None => self.entries.push(entry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove(&mut self, key: &GroupKey) {
|
pub fn remove(&mut self, key: &GroupKey) {
|
||||||
self.0
|
self.entries
|
||||||
.retain(|entry| entry.id != *key.id() || entry.relay != *key.relay());
|
.retain(|entry| entry.id != *key.id() || entry.relay != *key.relay());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_builder(&self) -> EventBuilder {
|
/// The raw private section of the list event, if any.
|
||||||
let mut tags: Vec<Tag> = Vec::with_capacity(self.0.len() * 2);
|
pub(crate) fn content(&self) -> Option<&str> {
|
||||||
|
self.content.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn content_readable(&self) -> bool {
|
||||||
|
self.readable
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_content(&mut self, content: Option<String>, readable: bool) {
|
||||||
|
self.content = content;
|
||||||
|
self.readable = readable;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_private(&self) -> bool {
|
||||||
|
self.entries.iter().any(|entry| entry.private)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the private section as readable after a successful decrypt.
|
||||||
|
pub(crate) fn mark_readable(&mut self) {
|
||||||
|
self.readable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add decrypted private entries; the public listing wins on collision.
|
||||||
|
pub(crate) fn merge_private(&mut self, entries: Vec<GroupListEntry>) {
|
||||||
|
for entry in entries {
|
||||||
|
let public = self.entries.iter().any(|existing| {
|
||||||
|
!existing.private && existing.id == entry.id && existing.relay == entry.relay
|
||||||
|
});
|
||||||
|
|
||||||
|
if !public {
|
||||||
|
self.upsert(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tags of the public section of the list event.
|
||||||
|
pub(crate) fn public_tags(&self) -> Vec<Tag> {
|
||||||
|
let mut tags = self.foreign.clone();
|
||||||
|
tags.reserve(self.entries.len() * 2);
|
||||||
|
|
||||||
|
for entry in &self.entries {
|
||||||
|
if entry.private {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for entry in &self.0 {
|
|
||||||
let mut values = vec![
|
let mut values = vec![
|
||||||
entry.id.as_str().to_owned(),
|
entry.id.as_str().to_owned(),
|
||||||
entry.relay.as_str().to_owned(),
|
entry.relay.as_str().to_owned(),
|
||||||
@@ -685,8 +818,68 @@ impl GroupList {
|
|||||||
tags.push(Tag::custom("r", [entry.relay.as_str()]));
|
tags.push(Tag::custom("r", [entry.relay.as_str()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
EventBuilder::new(Kind::SimpleGroups, "").tags(tags)
|
tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The private entries encoded as a tag array, ready for encryption.
|
||||||
|
pub(crate) fn encode_private(&self) -> Option<String> {
|
||||||
|
let tags: Vec<Vec<String>> = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.private)
|
||||||
|
.map(|entry| {
|
||||||
|
let mut values = vec![
|
||||||
|
entry.id.as_str().to_owned(),
|
||||||
|
entry.relay.as_str().to_owned(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(name) = &entry.name {
|
||||||
|
values.push(name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
values
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if tags.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(&tags).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a decrypted private section; `None` when it isn't a tag array.
|
||||||
|
pub(crate) fn parse_private_section(plaintext: &str) -> Option<Vec<GroupListEntry>> {
|
||||||
|
let tags = serde_json::from_str::<Vec<Vec<String>>>(plaintext).ok()?;
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
for tag in tags {
|
||||||
|
if tag.first().map(String::as_str) != Some("group") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (Some(id), Some(relay)) = (tag.get(1), tag.get(2)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(id) = GroupId::new(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(relay) = RelayUrl::parse(relay) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.push(GroupListEntry {
|
||||||
|
id,
|
||||||
|
relay,
|
||||||
|
name: tag.get(3).cloned(),
|
||||||
|
private: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -795,28 +988,41 @@ impl Activity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub(crate) struct TimelineWindow(Vec<Event>);
|
pub(crate) struct TimelineWindow {
|
||||||
|
events: Vec<Event>,
|
||||||
|
/// First 4 bytes of every event seen from this relay,
|
||||||
|
/// for `previous` reference checks.
|
||||||
|
seen: HashSet<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
impl TimelineWindow {
|
impl TimelineWindow {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self(Vec::new())
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push(&mut self, event: Event) {
|
pub fn push(&mut self, event: Event) {
|
||||||
self.0.retain(|existing| existing.id != event.id);
|
self.seen.insert(prefix(event.id));
|
||||||
let position = self.0.partition_point(|e| e.created_at > event.created_at);
|
self.events.retain(|existing| existing.id != event.id);
|
||||||
|
let position = self
|
||||||
|
.events
|
||||||
|
.partition_point(|e| e.created_at > event.created_at);
|
||||||
|
|
||||||
self.0.insert(position, event);
|
self.events.insert(position, event);
|
||||||
self.0.truncate(TIMELINE_WINDOW);
|
self.events.truncate(TIMELINE_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Note an event as seen without keeping it in the window.
|
||||||
|
pub fn note(&mut self, event: &Event) {
|
||||||
|
self.seen.insert(prefix(event.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove(&mut self, id: EventId) {
|
pub fn remove(&mut self, id: EventId) {
|
||||||
self.0.retain(|event| event.id != id);
|
self.events.retain(|event| event.id != id);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag> {
|
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag> {
|
||||||
let references: Vec<String> = self
|
let references: Vec<String> = self
|
||||||
.0
|
.events
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| event.pubkey != me)
|
.filter(|event| event.pubkey != me)
|
||||||
.take(PREVIOUS_REFS)
|
.take(PREVIOUS_REFS)
|
||||||
@@ -829,4 +1035,121 @@ impl TimelineWindow {
|
|||||||
Some(Tag::custom("previous", references))
|
Some(Tag::custom("previous", references))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether every `previous` reference points at an event seen from this
|
||||||
|
/// relay. `None` when the event carries no `previous` tag. Mirrors the
|
||||||
|
/// relay-side rejection the spec expects, so a relay handing out
|
||||||
|
/// out-of-context events can be called out.
|
||||||
|
pub fn references_seen(&self, tag: Option<&Tag>) -> Option<bool> {
|
||||||
|
let values = tag?.as_slice().iter().skip(1);
|
||||||
|
|
||||||
|
for value in values {
|
||||||
|
let Ok(prefix) = u32::from_str_radix(value, 16) else {
|
||||||
|
return Some(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.seen.contains(&prefix) {
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first 8 hex characters of an event id, as a `previous` reference.
|
||||||
|
fn prefix(id: EventId) -> u32 {
|
||||||
|
let bytes = id.as_bytes();
|
||||||
|
u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::LOCAL_KEYS;
|
||||||
|
|
||||||
|
fn event(kind: Kind, tags: Vec<Tag>) -> Event {
|
||||||
|
futures::executor::block_on(
|
||||||
|
EventBuilder::new(kind, "")
|
||||||
|
.tags(tags)
|
||||||
|
.finalize_async(&*LOCAL_KEYS),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relay(url: &str) -> RelayUrl {
|
||||||
|
RelayUrl::parse(url).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_tags_preserve_foreign_tags() {
|
||||||
|
let event = event(
|
||||||
|
Kind::SimpleGroups,
|
||||||
|
vec![
|
||||||
|
Tag::custom("group", ["pizza", "wss://relay.example", "Pizza Lovers"]),
|
||||||
|
Tag::custom("d", ["favorite-groups"]),
|
||||||
|
Tag::custom("client", ["nostord"]),
|
||||||
|
Tag::custom("r", ["wss://other.example"]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut list = GroupList::parse(&event).unwrap();
|
||||||
|
assert_eq!(list.entries().len(), 1);
|
||||||
|
|
||||||
|
list.upsert(GroupListEntry {
|
||||||
|
id: GroupId::new("sushi").unwrap(),
|
||||||
|
relay: relay("wss://relay2.example"),
|
||||||
|
name: None,
|
||||||
|
private: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let tags = list.public_tags();
|
||||||
|
|
||||||
|
// Foreign tags survive a republish...
|
||||||
|
assert!(tags.iter().any(|tag| tag.kind() == "d"
|
||||||
|
&& tag.as_slice().get(1).map(String::as_str) == Some("favorite-groups")));
|
||||||
|
assert!(tags.iter().any(|tag| tag.kind() == "client"));
|
||||||
|
|
||||||
|
// ...while `group` and `r` tags are rebuilt from the entries.
|
||||||
|
assert_eq!(tags.iter().filter(|tag| tag.kind() == "group").count(), 2);
|
||||||
|
assert_eq!(tags.iter().filter(|tag| tag.kind() == "r").count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn previous_references_validate_against_seen_events() {
|
||||||
|
let mut window = TimelineWindow::new();
|
||||||
|
let seen = event(Kind::ChatMessage, vec![Tag::custom("t", ["one"])]);
|
||||||
|
let other = event(Kind::ChatMessage, vec![Tag::custom("t", ["two"])]);
|
||||||
|
|
||||||
|
window.push(seen.clone());
|
||||||
|
|
||||||
|
let reference = |id: &EventId| {
|
||||||
|
let bytes = id.as_bytes();
|
||||||
|
Tag::custom(
|
||||||
|
"previous",
|
||||||
|
[format!(
|
||||||
|
"{0:02x}{1:02x}{2:02x}{3:02x}",
|
||||||
|
bytes[0], bytes[1], bytes[2], bytes[3]
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
window.references_seen(Some(&reference(&seen.id))),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
window.references_seen(Some(&reference(&other.id))),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
assert_eq!(window.references_seen(None), None);
|
||||||
|
|
||||||
|
// Any number of references is allowed, including zero.
|
||||||
|
let empty = Tag::custom("previous", Vec::<String>::new());
|
||||||
|
assert_eq!(window.references_seen(Some(&empty)), Some(true));
|
||||||
|
|
||||||
|
// A malformed reference can't be verified.
|
||||||
|
let malformed = Tag::custom("previous", ["nothex"]);
|
||||||
|
assert_eq!(window.references_seen(Some(&malformed)), Some(false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,3 +58,44 @@ pub(crate) fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)
|
|||||||
|
|
||||||
Some((id, event.created_at, members))
|
Some((id, event.created_at, members))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ONSETS: [&str; 34] = [
|
||||||
|
"b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "r", "s", "t", "v", "br", "cr",
|
||||||
|
"dr", "fr", "gr", "pr", "tr", "bl", "cl", "fl", "gl", "pl", "sl", "sh", "ch", "th", "st", "sk",
|
||||||
|
];
|
||||||
|
const NUCLEI: [&str; 9] = ["a", "e", "i", "o", "u", "ae", "ei", "io", "ou"];
|
||||||
|
const CODAS: [&str; 11] = ["", "", "", "n", "r", "s", "t", "d", "l", "m", "k"];
|
||||||
|
|
||||||
|
/// A short pronounceable id for new groups, seeded from a fresh key.
|
||||||
|
pub(crate) fn readable_id() -> String {
|
||||||
|
let mut picks = SecretKey::generate().secret_bytes().into_iter();
|
||||||
|
|
||||||
|
let mut id = String::with_capacity(18);
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
let onset = ONSETS[usize::from(picks.next().unwrap_or(0)) % ONSETS.len()];
|
||||||
|
let nucleus = NUCLEI[usize::from(picks.next().unwrap_or(0)) % NUCLEI.len()];
|
||||||
|
let coda = CODAS[usize::from(picks.next().unwrap_or(0)) % CODAS.len()];
|
||||||
|
|
||||||
|
id.push_str(onset);
|
||||||
|
id.push_str(nucleus);
|
||||||
|
id.push_str(coda);
|
||||||
|
}
|
||||||
|
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_readable_id() {
|
||||||
|
for _ in 0..32 {
|
||||||
|
let id = readable_id();
|
||||||
|
|
||||||
|
assert!((6..=15).contains(&id.len()));
|
||||||
|
assert!(id.chars().all(|character| character.is_ascii_lowercase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+190
-92
@@ -1,112 +1,210 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use common::TimestampExt;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, ClipboardItem, ParentElement, SharedString, Styled, WeakEntity, Window, div,
|
App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled,
|
||||||
px,
|
WeakEntity, Window, div, px,
|
||||||
};
|
};
|
||||||
use nip29::Group;
|
use nip29::{Group, Invite};
|
||||||
use nostr_sdk::prelude::SecretKey;
|
use nostr_sdk::prelude::SecretKey;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||||
use ui::dialog::DialogButtonProps;
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::InputState;
|
use ui::input::InputState;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::{WindowExtension, v_flex};
|
use ui::{Sizable, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
use crate::util::field;
|
use crate::util::{field, report};
|
||||||
|
|
||||||
const CODE_LEN: usize = 12;
|
const CODE_LEN: usize = 12;
|
||||||
|
|
||||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||||
let code = SecretKey::generate().to_secret_hex();
|
|
||||||
let code: String = code.chars().take(CODE_LEN).collect();
|
|
||||||
|
|
||||||
let input = cx.new(|cx| {
|
let input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
.placeholder("Invite code")
|
.placeholder("Invite code")
|
||||||
.default_value(code)
|
.default_value(random_code())
|
||||||
});
|
});
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, cx| {
|
window.open_dialog(cx, move |this, _window, cx| {
|
||||||
let group = group.clone();
|
this.max_h(px(600.))
|
||||||
let input = input.clone();
|
.show_close(true)
|
||||||
|
.title("Invites")
|
||||||
this.width(px(420.))
|
.child(body(group.clone(), input.clone(), cx))
|
||||||
.confirm()
|
|
||||||
.title("Invite link")
|
|
||||||
.button_props(
|
|
||||||
DialogButtonProps::default()
|
|
||||||
.cancel_text("Cancel")
|
|
||||||
.ok_text("Copy link"),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex().gap_3().child(
|
|
||||||
div()
|
|
||||||
.text_style(TextStyle::Callout)
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child("Share the code with whoever should be able to join. The relay must accept it."),
|
|
||||||
)
|
|
||||||
.child(field("Code", &input, cx)),
|
|
||||||
)
|
|
||||||
.on_ok(move |_event, window, cx| {
|
|
||||||
let code = input.read(cx).value().trim().to_owned();
|
|
||||||
|
|
||||||
if code.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let link = group
|
|
||||||
.read_with(cx, |group, _cx| {
|
|
||||||
group.metadata().and_then(|metadata| {
|
|
||||||
metadata
|
|
||||||
.naddr(group.key().relay(), Some(&code))
|
|
||||||
.ok()
|
|
||||||
.map(SharedString::from)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
let Some(link) = link else {
|
|
||||||
window.push_notification(
|
|
||||||
Notification::error(
|
|
||||||
"The relay doesn't publish group details, so no invite link can be made",
|
|
||||||
),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
match group.update(cx, |group, cx| group.create_invite(&code, cx)) {
|
|
||||||
Ok(task) => {
|
|
||||||
window
|
|
||||||
.spawn(cx, async move |cx| -> Result<()> {
|
|
||||||
if let Err(error) = task.await {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
window.push_notification(
|
|
||||||
Notification::error(error.to_string()),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
})?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
|
||||||
link.to_string(),
|
|
||||||
));
|
|
||||||
window.push_notification(
|
|
||||||
Notification::success("Invite link copied"),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn random_code() -> String {
|
||||||
|
SecretKey::generate()
|
||||||
|
.to_secret_hex()
|
||||||
|
.chars()
|
||||||
|
.take(CODE_LEN)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
|
||||||
|
let invites = group
|
||||||
|
.upgrade()
|
||||||
|
.map(|entity| entity.read(cx).invites().to_vec())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.text_style(TextStyle::Callout)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Anyone with a code can ask to join. Revoking a code stops new joins, it doesn't remove anyone."),
|
||||||
|
)
|
||||||
|
.when(invites.is_empty(), |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_faint)
|
||||||
|
.child("No active invites"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.children(invites.iter().map(|invite| invite_row(group.clone(), invite, cx)))
|
||||||
|
.child(create_row(group, input, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invite_row(group: WeakEntity<Group>, invite: &Invite, cx: &App) -> impl IntoElement {
|
||||||
|
let created = div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.text_color(cx.theme().text_faint)
|
||||||
|
.child(invite.created_at.to_ago());
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.justify_between()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_2()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
|
.truncate()
|
||||||
|
.child(SharedString::from(invite.code.clone())),
|
||||||
|
)
|
||||||
|
.child(created),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.gap_1()
|
||||||
|
.child(copy_button(&group, &invite.code))
|
||||||
|
.child(revoke_button(&group, invite.id, &invite.code)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_button(group: &WeakEntity<Group>, code: &str) -> Button {
|
||||||
|
let group = group.clone();
|
||||||
|
let code = code.to_owned();
|
||||||
|
|
||||||
|
Button::new(SharedString::from(format!("invite-copy-{code}")))
|
||||||
|
.label("Copy")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
let Some(link) = group
|
||||||
|
.read_with(cx, |group, _cx| invite_link(group, &code))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
else {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(
|
||||||
|
"The relay doesn't publish group details, so no invite link can be made",
|
||||||
|
),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
cx.write_to_clipboard(ClipboardItem::new_string(link));
|
||||||
|
window.push_notification(Notification::success("Invite link copied"), cx);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn revoke_button(group: &WeakEntity<Group>, id: nostr_sdk::prelude::EventId, code: &str) -> Button {
|
||||||
|
let group = group.clone();
|
||||||
|
|
||||||
|
Button::new(SharedString::from(format!("invite-revoke-{code}")))
|
||||||
|
.label("Revoke")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
match group.update(cx, |group, cx| group.delete_event(id, cx)) {
|
||||||
|
Ok(task) => report(window, cx, task),
|
||||||
|
Err(error) => log::warn!("nip29: revoking an invite failed: {error}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_row(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.items_end()
|
||||||
|
.gap_2()
|
||||||
|
.child(field("New code", &input, cx).flex_1())
|
||||||
|
.child(
|
||||||
|
Button::new("invite-create")
|
||||||
|
.label("Create & copy link")
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
create(group.clone(), input.clone(), window, cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invite_link(group: &Group, code: &str) -> Option<String> {
|
||||||
|
group
|
||||||
|
.metadata()
|
||||||
|
.and_then(|metadata| metadata.naddr(group.key().relay(), Some(code)).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(group: WeakEntity<Group>, input: Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||||
|
let code = input.read(cx).value().trim().to_owned();
|
||||||
|
|
||||||
|
if code.is_empty() {
|
||||||
|
window.push_notification(Notification::error("The invite code is empty"), cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(link) = group
|
||||||
|
.read_with(cx, |group, _cx| invite_link(group, &code))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
else {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(
|
||||||
|
"The relay doesn't publish group details, so no invite link can be made",
|
||||||
|
),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match group.update(cx, |group, cx| group.create_invite(&code, cx)) {
|
||||||
|
Ok(task) => {
|
||||||
|
window
|
||||||
|
.spawn(cx, async move |cx| -> Result<()> {
|
||||||
|
if let Err(error) = task.await {
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
|
})?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
input.update(cx, |input, cx| {
|
||||||
|
input.set_value("", window, cx);
|
||||||
|
});
|
||||||
|
cx.write_to_clipboard(ClipboardItem::new_string(link));
|
||||||
|
window.push_notification(Notification::success("Invite link copied"), cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+260
-14
@@ -5,13 +5,13 @@ use chat_ui::text::rendered_text;
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
|
FollowMode, ImageSource, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
|
||||||
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
|
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
|
||||||
list, px,
|
list, px,
|
||||||
};
|
};
|
||||||
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
|
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::Directory;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -26,7 +26,7 @@ use ui::markdown::RenderedText;
|
|||||||
use ui::message::WelcomeMessage;
|
use ui::message::WelcomeMessage;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
use util::{attachment_name, display_name, opens_run, pin_label, report};
|
use util::{attachment_name, display_name, opens_run, pin_label, report};
|
||||||
|
|
||||||
mod details;
|
mod details;
|
||||||
@@ -51,6 +51,14 @@ pub struct HideGroupMeta {
|
|||||||
pub group: String,
|
pub group: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the shell to open the channel creation dialog under a group.
|
||||||
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[action(namespace = nip29_ui, no_json)]
|
||||||
|
pub struct CreateChannel {
|
||||||
|
pub relay: RelayUrl,
|
||||||
|
pub parent: String,
|
||||||
|
}
|
||||||
|
|
||||||
const ADMIN_ROLE: &str = "admin";
|
const ADMIN_ROLE: &str = "admin";
|
||||||
const LOAD_OLDER_THRESHOLD: usize = 20;
|
const LOAD_OLDER_THRESHOLD: usize = 20;
|
||||||
|
|
||||||
@@ -96,6 +104,8 @@ pub fn actions(group: WeakEntity<Group>, cx: &App) -> AnyElement {
|
|||||||
let info = group.clone();
|
let info = group.clone();
|
||||||
let edit = group.clone();
|
let edit = group.clone();
|
||||||
let invite = group.clone();
|
let invite = group.clone();
|
||||||
|
let channel = group.clone();
|
||||||
|
let delete = group.clone();
|
||||||
let leave = group;
|
let leave = group;
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -131,6 +141,37 @@ pub fn actions(group: WeakEntity<Group>, cx: &App) -> AnyElement {
|
|||||||
invite::open(invite.clone(), window, cx);
|
invite::open(invite.clone(), window, cx);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("group-channel")
|
||||||
|
.icon(IconName::Plus)
|
||||||
|
.tooltip("Create channel")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
let Ok(key) = channel.read_with(cx, |group, _cx| group.key().clone())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dispatch_action(
|
||||||
|
Box::new(CreateChannel {
|
||||||
|
relay: key.relay().clone(),
|
||||||
|
parent: key.id().as_str().to_owned(),
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("group-delete")
|
||||||
|
.icon(IconName::CloseCircle)
|
||||||
|
.tooltip("Delete group")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
confirm_delete(delete.clone(), window, cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
Button::new("leave")
|
Button::new("leave")
|
||||||
@@ -181,6 +222,42 @@ pub fn confirm_leave(group: WeakEntity<Group>, window: &mut Window, cx: &mut App
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn confirm_delete(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||||
|
let Some(name) = group.read_with(cx, |group, _cx| group.display_name()).ok() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(key) = group.read_with(cx, |group, _cx| group.key().clone()).ok() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.open_dialog(cx, move |this, _window, _cx| {
|
||||||
|
let key = key.clone();
|
||||||
|
|
||||||
|
this.confirm()
|
||||||
|
.width(px(360.))
|
||||||
|
.title(format!("Delete {name}?"))
|
||||||
|
.button_props(
|
||||||
|
DialogButtonProps::default()
|
||||||
|
.cancel_text("Cancel")
|
||||||
|
.ok_text("Delete")
|
||||||
|
.ok_variant(ButtonVariant::Danger),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_style(TextStyle::Callout)
|
||||||
|
.child("The relay will remove this group for everyone."),
|
||||||
|
)
|
||||||
|
.on_ok(move |_event, window, cx| {
|
||||||
|
let registry = GroupsRegistry::global(cx);
|
||||||
|
let sent = registry.update(cx, |registry, cx| registry.delete_group(&key, cx));
|
||||||
|
report(window, cx, sent);
|
||||||
|
|
||||||
|
true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a member from the group and surface any relay error as a notification.
|
/// Remove a member from the group and surface any relay error as a notification.
|
||||||
pub fn remove_member(
|
pub fn remove_member(
|
||||||
group: WeakEntity<Group>,
|
group: WeakEntity<Group>,
|
||||||
@@ -231,8 +308,12 @@ pub struct GroupPanel {
|
|||||||
id: SharedString,
|
id: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
group: WeakEntity<Group>,
|
group: WeakEntity<Group>,
|
||||||
|
/// Person directory for members, message authors, and activity subjects
|
||||||
|
directory: Entity<Directory>,
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
head: Option<EventId>,
|
head: Option<EventId>,
|
||||||
|
/// The message the composer is replying to, if any.
|
||||||
|
reply_to: Option<EventId>,
|
||||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||||
input: Entity<TextareaState>,
|
input: Entity<TextareaState>,
|
||||||
/// Uploaded, non-encrypted attachments waiting to be sent.
|
/// Uploaded, non-encrypted attachments waiting to be sent.
|
||||||
@@ -277,12 +358,14 @@ impl GroupPanel {
|
|||||||
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
|
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
|
||||||
if !matches!(event, GroupEvent::Sent(_)) {
|
if !matches!(event, GroupEvent::Sent(_)) {
|
||||||
this.sync(cx);
|
this.sync(cx);
|
||||||
|
this.track_participants(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
subscriptions.push(cx.observe(&group, |this, _group, cx| {
|
subscriptions.push(cx.observe(&group, |this, _group, cx| {
|
||||||
this.sync(cx);
|
this.sync(cx);
|
||||||
|
this.track_participants(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -297,19 +380,43 @@ impl GroupPanel {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
let mut this = Self {
|
||||||
id,
|
id,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
group: group.downgrade(),
|
group: group.downgrade(),
|
||||||
|
directory: Directory::new(cx),
|
||||||
list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)),
|
list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)),
|
||||||
head,
|
head,
|
||||||
|
reply_to: None,
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
input,
|
input,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
uploading: false,
|
uploading: false,
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
};
|
||||||
|
|
||||||
|
this.track_participants(cx);
|
||||||
|
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track the keys rendered by this panel: members, message authors, and
|
||||||
|
/// activity subjects
|
||||||
|
fn track_participants(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(group) = self.group.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let group = group.read(cx);
|
||||||
|
let mut keys: Vec<PublicKey> = group.members().iter().copied().collect();
|
||||||
|
keys.extend(group.rows().iter().map(|row| match row {
|
||||||
|
Row::Message(message) => message.author,
|
||||||
|
Row::Activity(activity) => activity.subject(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
self.directory
|
||||||
|
.update(cx, |directory, cx| directory.track(keys, cx));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn item_count(&self, cx: &App) -> usize {
|
fn item_count(&self, cx: &App) -> usize {
|
||||||
@@ -375,6 +482,33 @@ impl GroupPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scroll_to(&self, id: &EventId, cx: &App) {
|
||||||
|
let Some(group) = self.group.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = group.read(cx).rows();
|
||||||
|
|
||||||
|
if let Some(ix) = rows.iter().position(|row| row.id() == *id) {
|
||||||
|
// The welcome and load-older rows precede the messages.
|
||||||
|
self.list_state.scroll_to_reveal_item(ix + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_reply(&mut self, id: EventId, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.reply_to = Some(id);
|
||||||
|
self.input.update(cx, |input, cx| {
|
||||||
|
input.focus(window, cx);
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_reply(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.reply_to.take().is_some() {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let mut content = self.input.read(cx).value().trim().to_owned();
|
let mut content = self.input.read(cx).value().trim().to_owned();
|
||||||
|
|
||||||
@@ -397,10 +531,12 @@ impl GroupPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(send) = self
|
let replies: Vec<EventId> = self.reply_to.take().into_iter().collect();
|
||||||
.group
|
let emojis = self.emoji_tags(&content, cx);
|
||||||
.update(cx, |group, cx| group.send_message(&content, vec![], cx))
|
|
||||||
else {
|
let Ok(send) = self.group.update(cx, |group, cx| {
|
||||||
|
group.send_message(&content, replies, emojis, cx)
|
||||||
|
}) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -477,6 +613,18 @@ impl GroupPanel {
|
|||||||
.and_then(|group| Notice::of(group.read(cx)))
|
.and_then(|group| Notice::of(group.read(cx)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The signed-in user's custom emoji tags referenced in `content`
|
||||||
|
fn emoji_tags(&self, content: &str, cx: &App) -> Vec<Tag> {
|
||||||
|
let Some(current_user) = NostrRegistry::global(cx).read(cx).current_user() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
self.directory
|
||||||
|
.read(cx)
|
||||||
|
.person(¤t_user, cx)
|
||||||
|
.emoji_tags(content)
|
||||||
|
}
|
||||||
|
|
||||||
fn react(
|
fn react(
|
||||||
&mut self,
|
&mut self,
|
||||||
group: WeakEntity<Group>,
|
group: WeakEntity<Group>,
|
||||||
@@ -485,7 +633,10 @@ impl GroupPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
let Ok(react) = group.update(cx, |group, cx| group.send_reaction(id, emoji, cx)) else {
|
let emojis = self.emoji_tags(&emoji, cx);
|
||||||
|
|
||||||
|
let Ok(react) = group.update(cx, |group, cx| group.send_reaction(id, emoji, emojis, cx))
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -768,16 +919,26 @@ impl GroupPanel {
|
|||||||
|
|
||||||
let message = match row {
|
let message = match row {
|
||||||
Row::Message(message) => message,
|
Row::Message(message) => message,
|
||||||
Row::Activity(activity) => return message::render_activity(ix, activity, cx),
|
Row::Activity(activity) => {
|
||||||
|
return message::render_activity(ix, activity, &self.directory, cx);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let persons = PersonRegistry::global(cx);
|
let directory = self.directory.clone();
|
||||||
|
|
||||||
let content = self
|
let content = self
|
||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(message.id)
|
.entry(message.id)
|
||||||
.or_insert_with(|| rendered_text(&message.content, &message.mentions, &persons, cx))
|
.or_insert_with(|| {
|
||||||
|
rendered_text(
|
||||||
|
&message.content,
|
||||||
|
&message.mentions,
|
||||||
|
&message.emojis,
|
||||||
|
&directory,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
.element(ix.into(), window, cx);
|
.element(ix.into(), window, cx);
|
||||||
|
|
||||||
let admin = nostr
|
let admin = nostr
|
||||||
@@ -793,11 +954,13 @@ impl GroupPanel {
|
|||||||
message,
|
message,
|
||||||
content,
|
content,
|
||||||
show_author,
|
show_author,
|
||||||
|
group.reactions(&message.id),
|
||||||
message::Actions {
|
message::Actions {
|
||||||
group: self.group.clone(),
|
group: self.group.clone(),
|
||||||
panel: cx.weak_entity(),
|
panel: cx.weak_entity(),
|
||||||
admin,
|
admin,
|
||||||
pinned,
|
pinned,
|
||||||
|
persons: self.directory.clone(),
|
||||||
},
|
},
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
@@ -833,6 +996,9 @@ impl GroupPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.p_2()
|
.p_2()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
|
.when_some(self.reply_to, |this, target| {
|
||||||
|
this.child(self.render_reply_target(target, cx))
|
||||||
|
})
|
||||||
.children(self.render_attachments(cx))
|
.children(self.render_attachments(cx))
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -865,6 +1031,73 @@ impl GroupPanel {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_reply_target(&self, target: EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let context = self.group.upgrade().and_then(|entity| {
|
||||||
|
entity.read_with(cx, |group, cx| {
|
||||||
|
let message = group.message(target)?;
|
||||||
|
let author = self.directory.read(cx).person(&message.author, cx);
|
||||||
|
let preview = message.preview();
|
||||||
|
let line = preview.lines().next().unwrap_or_default().trim();
|
||||||
|
|
||||||
|
Some((
|
||||||
|
author.name(),
|
||||||
|
SharedString::from(line.chars().take(80).collect::<String>()),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let (author, snippet) = context.unwrap_or_else(|| {
|
||||||
|
(
|
||||||
|
SharedString::from("Message"),
|
||||||
|
SharedString::from(target.to_hex().chars().take(8).collect::<String>()),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.min_w_0()
|
||||||
|
.items_center()
|
||||||
|
.gap_1()
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.rounded(px(control_radius()))
|
||||||
|
.bg(cx.theme().surface_raised)
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Replying to"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.max_w(px(140.))
|
||||||
|
.truncate()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
.child(author),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
|
.flex_1()
|
||||||
|
.truncate()
|
||||||
|
.text_color(cx.theme().text_faint)
|
||||||
|
.child(snippet),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("cancel-reply")
|
||||||
|
.icon(IconName::Close)
|
||||||
|
.tooltip("Cancel reply")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
|
this.cancel_reply(cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||||
self.attachments
|
self.attachments
|
||||||
.iter()
|
.iter()
|
||||||
@@ -927,13 +1160,22 @@ impl Panel for GroupPanel {
|
|||||||
.child(display_name(group))
|
.child(display_name(group))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| div().text_style(TextStyle::Caption).child("Unknown").into_any_element())
|
.unwrap_or_else(|| {
|
||||||
|
div()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.child("Unknown")
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn background(&self, cx: &App) -> Option<ImageSource> {
|
||||||
|
AppSettings::panel_background_source(cx)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
|
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
|
||||||
let Some(group) = self.group.upgrade() else {
|
let Some(group) = self.group.upgrade() else {
|
||||||
return;
|
return;
|
||||||
@@ -954,6 +1196,10 @@ impl Panel for GroupPanel {
|
|||||||
window.dispatch_action(Box::new(HideGroupMeta { relay, group: id }), cx);
|
window.dispatch_action(Box::new(HideGroupMeta { relay, group: id }), cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_removed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.set_active(false, window, cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for GroupPanel {}
|
impl EventEmitter<PanelEvent> for GroupPanel {}
|
||||||
|
|||||||
+144
-54
@@ -2,17 +2,18 @@ use chat::Message;
|
|||||||
use common::TimestampExt;
|
use common::TimestampExt;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedString,
|
AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled,
|
||||||
SharedUri, Styled, StyledImage, WeakEntity, div, img, px,
|
WeakEntity, div, px,
|
||||||
};
|
};
|
||||||
use nip29::{Activity, Group};
|
use nip29::{Activity, Group, Reaction};
|
||||||
use nostr_sdk::prelude::{EventId, Kind};
|
use nostr_sdk::prelude::{EventId, Kind};
|
||||||
use person::PersonRegistry;
|
use person::Directory;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _, bubble_radius};
|
use state::NostrRegistry;
|
||||||
|
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
|
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
|
||||||
use ui::message::MessageRow;
|
use ui::message::{MessageMedia, MessageRow, ReactionBar, ReplyPreview, group_reactions};
|
||||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
|
use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
|
||||||
|
|
||||||
use crate::GroupPanel;
|
use crate::GroupPanel;
|
||||||
@@ -24,6 +25,7 @@ pub(crate) struct Actions {
|
|||||||
pub panel: WeakEntity<GroupPanel>,
|
pub panel: WeakEntity<GroupPanel>,
|
||||||
pub admin: bool,
|
pub admin: bool,
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
|
pub persons: Entity<Directory>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn render(
|
pub(crate) fn render(
|
||||||
@@ -31,13 +33,27 @@ pub(crate) fn render(
|
|||||||
message: &Message,
|
message: &Message,
|
||||||
content: AnyElement,
|
content: AnyElement,
|
||||||
show_author: bool,
|
show_author: bool,
|
||||||
|
reactions: &[Reaction],
|
||||||
actions: Actions,
|
actions: Actions,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let persons = PersonRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let author = persons.read(cx).get(&message.author, cx);
|
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
|
|
||||||
|
let persons = actions.persons.clone();
|
||||||
|
let author = persons.read(cx).person(&message.author, cx);
|
||||||
|
|
||||||
|
let reaction_groups = group_reactions(
|
||||||
|
reactions.iter().map(|reaction| {
|
||||||
|
(
|
||||||
|
SharedString::from(reaction.emoji.as_str()),
|
||||||
|
reaction.image.clone(),
|
||||||
|
reaction.author,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
nostr.read(cx).current_user(),
|
||||||
|
);
|
||||||
|
|
||||||
let row = MessageRow::new(ix)
|
let row = MessageRow::new(ix)
|
||||||
.show_author(show_author)
|
.show_author(show_author)
|
||||||
.hide_avatar(hide_avatar)
|
.hide_avatar(hide_avatar)
|
||||||
@@ -48,9 +64,21 @@ pub(crate) fn render(
|
|||||||
)
|
)
|
||||||
.author(author.name())
|
.author(author.name())
|
||||||
.timestamp(message.created_at.to_human_time())
|
.timestamp(message.created_at.to_human_time())
|
||||||
|
.when(!message.replies_to.is_empty(), |this| {
|
||||||
|
this.children(replies(
|
||||||
|
&message.replies_to,
|
||||||
|
&persons,
|
||||||
|
&actions.group,
|
||||||
|
&actions.panel,
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
})
|
||||||
.child(content)
|
.child(content)
|
||||||
.when(!message.media.is_empty(), |this| {
|
.when(!message.media.is_empty(), |this| {
|
||||||
this.child(media(&message.media, cx))
|
this.child(MessageMedia::new(&message.media))
|
||||||
|
})
|
||||||
|
.when(!reaction_groups.is_empty(), |this| {
|
||||||
|
this.child(ReactionBar::new(reaction_groups))
|
||||||
})
|
})
|
||||||
.into_any_element();
|
.into_any_element();
|
||||||
|
|
||||||
@@ -64,12 +92,22 @@ pub(crate) fn render(
|
|||||||
format!("message-menu-{}", id.to_hex()),
|
format!("message-menu-{}", id.to_hex()),
|
||||||
row,
|
row,
|
||||||
move |menu, window, cx| {
|
move |menu, window, cx| {
|
||||||
let menu = menu.submenu("React", window, cx, {
|
let menu = menu
|
||||||
let group = group.clone();
|
.item({
|
||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
|
|
||||||
move |menu, _window, _cx| react_menu(menu, group.clone(), panel.clone(), id)
|
PopupMenuItem::new("Reply").on_click(move |_event, _window, cx| {
|
||||||
});
|
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||||
|
panel.start_reply(id, window, cx);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.submenu("React", window, cx, {
|
||||||
|
let group = group.clone();
|
||||||
|
let panel = panel.clone();
|
||||||
|
|
||||||
|
move |menu, _window, cx| react_menu(menu, group.clone(), panel.clone(), id, cx)
|
||||||
|
});
|
||||||
|
|
||||||
if !admin {
|
if !admin {
|
||||||
return menu;
|
return menu;
|
||||||
@@ -82,11 +120,9 @@ pub(crate) fn render(
|
|||||||
|
|
||||||
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click(
|
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click(
|
||||||
move |_event, _window, cx| {
|
move |_event, _window, cx| {
|
||||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||||
panel.toggle_pin(group.clone(), id, window, cx);
|
panel.toggle_pin(group.clone(), id, window, cx);
|
||||||
}) {
|
});
|
||||||
log::warn!("nip29: pinning a message failed: {error}");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -95,11 +131,9 @@ pub(crate) fn render(
|
|||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
|
|
||||||
PopupMenuItem::new("Delete message").on_click(move |_event, _window, cx| {
|
PopupMenuItem::new("Delete message").on_click(move |_event, _window, cx| {
|
||||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||||
panel.delete_message(group.clone(), id, window, cx);
|
panel.delete_message(group.clone(), id, window, cx);
|
||||||
}) {
|
});
|
||||||
log::warn!("nip29: deleting a message failed: {error}");
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -112,8 +146,9 @@ fn react_menu(
|
|||||||
group: WeakEntity<Group>,
|
group: WeakEntity<Group>,
|
||||||
panel: WeakEntity<GroupPanel>,
|
panel: WeakEntity<GroupPanel>,
|
||||||
id: EventId,
|
id: EventId,
|
||||||
|
cx: &App,
|
||||||
) -> PopupMenu {
|
) -> PopupMenu {
|
||||||
REACTIONS.iter().fold(menu, |menu, emoji| {
|
let menu = REACTIONS.iter().fold(menu, |menu, emoji| {
|
||||||
let emoji = *emoji;
|
let emoji = *emoji;
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
@@ -127,12 +162,53 @@ fn react_menu(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let emojis = panel
|
||||||
|
.upgrade()
|
||||||
|
.and_then(|panel| {
|
||||||
|
let current_user = NostrRegistry::global(cx).read(cx).current_user()?;
|
||||||
|
Some(
|
||||||
|
panel
|
||||||
|
.read(cx)
|
||||||
|
.directory
|
||||||
|
.read(cx)
|
||||||
|
.person(¤t_user, cx)
|
||||||
|
.emojis()
|
||||||
|
.to_vec(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if emojis.is_empty() {
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
let menu = menu.separator();
|
||||||
|
emojis.into_iter().fold(menu, |menu, (shortcode, _)| {
|
||||||
|
let emoji = format!(":{shortcode}:");
|
||||||
|
let group = group.clone();
|
||||||
|
let panel = panel.clone();
|
||||||
|
|
||||||
|
menu.item(
|
||||||
|
PopupMenuItem::new(emoji.clone()).on_click(move |_event, _window, cx| {
|
||||||
|
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||||
|
panel.react(group.clone(), id, emoji.clone(), window, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("nip29: sending a reaction failed: {error}");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyElement {
|
pub(crate) fn render_activity(
|
||||||
let persons = PersonRegistry::global(cx);
|
ix: usize,
|
||||||
let subject = persons.read(cx).get(&activity.subject(), cx);
|
activity: &Activity,
|
||||||
|
persons: &Entity<Directory>,
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
|
let subject = persons.read(cx).person(&activity.subject(), cx);
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -205,35 +281,49 @@ fn action(activity: &Activity) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn media(media: &[SharedUri], cx: &App) -> AnyElement {
|
fn replies(
|
||||||
if let [only] = media {
|
targets: &[EventId],
|
||||||
return div()
|
persons: &Entity<Directory>,
|
||||||
.child(
|
group: &WeakEntity<Group>,
|
||||||
img(only.clone())
|
panel: &WeakEntity<GroupPanel>,
|
||||||
.border_1()
|
cx: &App,
|
||||||
.border_color(cx.theme().border_faint)
|
) -> Vec<AnyElement> {
|
||||||
.h(px(250.))
|
let Some(entity) = group.upgrade() else {
|
||||||
.object_fit(ObjectFit::Cover)
|
return Vec::new();
|
||||||
.rounded(px(bubble_radius())),
|
};
|
||||||
|
|
||||||
|
let mut items = Vec::with_capacity(targets.len());
|
||||||
|
|
||||||
|
for (ix, id) in targets.iter().enumerate() {
|
||||||
|
let context = entity.read_with(cx, |group, cx| {
|
||||||
|
let message = group.message(*id)?;
|
||||||
|
let author = persons.read(cx).person(&message.author, cx);
|
||||||
|
|
||||||
|
Some((author.name(), message.preview()))
|
||||||
|
});
|
||||||
|
|
||||||
|
let (author, preview) = context.unwrap_or_else(|| {
|
||||||
|
(
|
||||||
|
SharedString::from("Message"),
|
||||||
|
SharedString::from("original not loaded"),
|
||||||
)
|
)
|
||||||
.into_any_element();
|
});
|
||||||
|
|
||||||
|
let id = *id;
|
||||||
|
let panel = panel.clone();
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
ReplyPreview::new(ix, author, preview)
|
||||||
|
.on_click(move |_event, _window, cx| {
|
||||||
|
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||||
|
panel.scroll_to(&id, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("nip29: scrolling to message failed: {error}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
div()
|
items
|
||||||
.w_full()
|
|
||||||
.flex_1()
|
|
||||||
.flex()
|
|
||||||
.flex_row()
|
|
||||||
.flex_wrap()
|
|
||||||
.gap(px(theme::SPACE))
|
|
||||||
.children(media.iter().enumerate().map(|(ix, item)| {
|
|
||||||
div().id(format!("media-{ix}")).child(
|
|
||||||
img(item.clone())
|
|
||||||
.h_32()
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border_faint)
|
|
||||||
.rounded(px(bubble_radius())),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window, px};
|
use std::sync::Arc;
|
||||||
use nip29::{Group, GroupMetadata};
|
|
||||||
|
use gpui::http_client::{AsyncBody, HttpClient};
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, AppContext, Entity, IntoElement, ParentElement, SharedString, Styled,
|
||||||
|
WeakEntity, Window, div, px,
|
||||||
|
};
|
||||||
|
use nip29::{Group, GroupId, GroupMetadata};
|
||||||
|
use nostr_sdk::prelude::RelayUrl;
|
||||||
|
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dialog::DialogButtonProps;
|
use ui::dialog::DialogButtonProps;
|
||||||
use ui::input::InputState;
|
use ui::input::InputState;
|
||||||
|
use ui::notification::Notification;
|
||||||
use ui::switch::Switch;
|
use ui::switch::Switch;
|
||||||
use ui::{WindowExtension, v_flex};
|
use ui::{Sizable, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
use crate::util::field;
|
use crate::util::field;
|
||||||
|
|
||||||
@@ -53,10 +64,40 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
.default_value(base.picture.clone().unwrap_or_default())
|
.default_value(base.picture.clone().unwrap_or_default())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let parent = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("Parent group id, empty for a root group")
|
||||||
|
.default_value(
|
||||||
|
base.parent
|
||||||
|
.as_ref()
|
||||||
|
.map(|id| id.as_str().to_owned())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let children = cx.new(|_cx| base.children.clone());
|
||||||
let flags = cx.new(|_cx| Flags::of(&base));
|
let flags = cx.new(|_cx| Flags::of(&base));
|
||||||
|
let livekit = cx.new(|_cx| None);
|
||||||
|
|
||||||
|
if let Ok(relay) = group.read_with(cx, |group, _cx| group.key().relay().clone()) {
|
||||||
|
let http = cx.http_client();
|
||||||
|
let probe = cx.background_spawn(async move { probe_livekit(http, relay).await });
|
||||||
|
let livekit = livekit.clone();
|
||||||
|
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
let supported = probe.await;
|
||||||
|
|
||||||
|
livekit.update(cx, |livekit, cx| {
|
||||||
|
*livekit = Some(supported);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, cx| {
|
window.open_dialog(cx, move |this, _window, cx| {
|
||||||
let current = *flags.read(cx);
|
let current = *flags.read(cx);
|
||||||
|
let supported = *livekit.read(cx) != Some(false);
|
||||||
|
|
||||||
this.confirm()
|
this.confirm()
|
||||||
.title("Edit Group")
|
.title("Edit Group")
|
||||||
@@ -71,43 +112,62 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
.child(field("Name", &name, cx))
|
.child(field("Name", &name, cx))
|
||||||
.child(field("About", &about, cx))
|
.child(field("About", &about, cx))
|
||||||
.child(field("Picture", &picture, cx))
|
.child(field("Picture", &picture, cx))
|
||||||
|
.child(field("Parent", &parent, cx))
|
||||||
|
.child(children_section(&children, cx))
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap(px(theme::SPACE))
|
.gap(px(theme::SPACE))
|
||||||
.child(
|
.child(
|
||||||
Switch::new("private")
|
Switch::new("private")
|
||||||
.label("Private")
|
.label("Private")
|
||||||
.description("Only members can read messages.")
|
.description("Only members can read group messages")
|
||||||
.checked(current.private)
|
.checked(current.private)
|
||||||
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Switch::new("restricted")
|
Switch::new("restricted")
|
||||||
.label("Restricted")
|
.label("Restricted")
|
||||||
.description("Only members can send messages.")
|
.description("Only members can post messages")
|
||||||
.checked(current.restricted)
|
.checked(current.restricted)
|
||||||
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
|
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Switch::new("hidden")
|
Switch::new("hidden")
|
||||||
.label("Hidden")
|
.label("Hidden")
|
||||||
.description("Hide the group from non-members.")
|
.description("Hidden from non-members, not discoverable")
|
||||||
.checked(current.hidden)
|
.checked(current.hidden)
|
||||||
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Switch::new("closed")
|
Switch::new("closed")
|
||||||
.label("Closed")
|
.label("Closed")
|
||||||
.description("Ignore join requests.")
|
.description("Join requests are ignored (invite-only)")
|
||||||
.checked(current.closed)
|
.checked(current.closed)
|
||||||
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Switch::new("livekit")
|
Switch::new("livekit")
|
||||||
.label("LiveKit")
|
.label("LiveKit")
|
||||||
.description("Announce audio and video support.")
|
.description(if supported {
|
||||||
|
"Announce audio and video support."
|
||||||
|
} else {
|
||||||
|
"The relay doesn't announce LiveKit support."
|
||||||
|
})
|
||||||
.checked(current.livekit)
|
.checked(current.livekit)
|
||||||
.on_click(toggle(&flags, |flags| &mut flags.livekit)),
|
.on_click({
|
||||||
|
let flags = flags.clone();
|
||||||
|
let livekit = livekit.clone();
|
||||||
|
|
||||||
|
move |next, _window, cx| {
|
||||||
|
if *next && *livekit.read(cx) == Some(false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flags.update(cx, |flags, cx| {
|
||||||
|
flags.livekit = *next;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -116,6 +176,8 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
let name = name.clone();
|
let name = name.clone();
|
||||||
let about = about.clone();
|
let about = about.clone();
|
||||||
let picture = picture.clone();
|
let picture = picture.clone();
|
||||||
|
let parent = parent.clone();
|
||||||
|
let children = children.clone();
|
||||||
let flags = flags.clone();
|
let flags = flags.clone();
|
||||||
|
|
||||||
move |_event, window, cx| {
|
move |_event, window, cx| {
|
||||||
@@ -129,6 +191,40 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
next.about = value(&about, cx);
|
next.about = value(&about, cx);
|
||||||
next.picture = value(&picture, cx);
|
next.picture = value(&picture, cx);
|
||||||
|
|
||||||
|
match value(&parent, cx).map(GroupId::new) {
|
||||||
|
Some(Ok(id)) => next.parent = Some(id),
|
||||||
|
Some(Err(error)) => {
|
||||||
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
None => next.parent = None,
|
||||||
|
}
|
||||||
|
|
||||||
|
next.children = children.read(cx).clone();
|
||||||
|
|
||||||
|
// The relay must reject these anyway; catch them early.
|
||||||
|
if let Some(id) = &next.parent {
|
||||||
|
let key = group
|
||||||
|
.read_with(cx, |group, _cx| group.key().id().as_str().to_owned())
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
if key.as_deref() == Some(id.as_str()) {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error("A group can't be its own parent"),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if next.children.iter().any(|child| child == id) {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error("The parent is one of this group's children"),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let current = flags.read(cx);
|
let current = flags.read(cx);
|
||||||
next.private = current.private;
|
next.private = current.private;
|
||||||
next.restricted = current.restricted;
|
next.restricted = current.restricted;
|
||||||
@@ -147,6 +243,88 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NIP-29: relays announce LiveKit support with a 204 at this well-known path.
|
||||||
|
async fn probe_livekit(http: Arc<dyn HttpClient>, relay: RelayUrl) -> bool {
|
||||||
|
let base = relay
|
||||||
|
.as_str()
|
||||||
|
.replacen("wss://", "https://", 1)
|
||||||
|
.replacen("ws://", "http://", 1);
|
||||||
|
let url = format!("{}/.well-known/nip29/livekit", base.trim_end_matches('/'));
|
||||||
|
|
||||||
|
match http.get(&url, AsyncBody::default(), true).await {
|
||||||
|
Ok(response) => response.status() == 204,
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
|
||||||
|
let list = children.read(cx);
|
||||||
|
|
||||||
|
let mut section = v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_style(TextStyle::Callout)
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Children");
|
||||||
|
|
||||||
|
if list.is_empty() {
|
||||||
|
return section
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_faint)
|
||||||
|
.child("No subgroups"),
|
||||||
|
)
|
||||||
|
.into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ix, child) in list.iter().enumerate() {
|
||||||
|
let order = h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.gap_1()
|
||||||
|
.when(ix > 0, |this| {
|
||||||
|
this.child(move_button(children.clone(), ix, ix - 1, "up"))
|
||||||
|
})
|
||||||
|
.when(ix + 1 < list.len(), |this| {
|
||||||
|
this.child(move_button(children.clone(), ix, ix + 1, "down"))
|
||||||
|
});
|
||||||
|
|
||||||
|
section = section.child(
|
||||||
|
h_flex()
|
||||||
|
.justify_between()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
|
.truncate()
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
.child(SharedString::from(child.as_str().to_owned())),
|
||||||
|
)
|
||||||
|
.child(order),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_button(
|
||||||
|
children: Entity<Vec<GroupId>>,
|
||||||
|
ix: usize,
|
||||||
|
target: usize,
|
||||||
|
direction: &'static str,
|
||||||
|
) -> Button {
|
||||||
|
Button::new(SharedString::from(format!("child-{direction}-{ix}")))
|
||||||
|
.label(if direction == "up" { "Up" } else { "Down" })
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(move |_event, _window, cx| {
|
||||||
|
children.update(cx, |children, cx| {
|
||||||
|
if target < children.len() {
|
||||||
|
children.swap(ix, target);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn toggle(
|
fn toggle(
|
||||||
flags: &Entity<Flags>,
|
flags: &Entity<Flags>,
|
||||||
select: fn(&mut Flags) -> &mut bool,
|
select: fn(&mut Flags) -> &mut bool,
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ use ui::input::{Input, InputState};
|
|||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::{WindowExtension, v_flex};
|
use ui::{WindowExtension, v_flex};
|
||||||
|
|
||||||
const RUN_WINDOW_SECS: u64 = 300;
|
|
||||||
|
|
||||||
pub(crate) fn display_name(group: &Group) -> SharedString {
|
pub(crate) fn display_name(group: &Group) -> SharedString {
|
||||||
group
|
group
|
||||||
.metadata()
|
.metadata()
|
||||||
@@ -37,16 +35,13 @@ pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
if !matches!((current, previous), (Row::Message(_), Row::Message(_))) {
|
match (current, previous) {
|
||||||
return true;
|
(Row::Message(current), Row::Message(previous)) => ui::message::opens_run(
|
||||||
|
Some((previous.author, previous.created_at.as_secs())),
|
||||||
|
(current.author, current.created_at.as_secs()),
|
||||||
|
),
|
||||||
|
_ => true,
|
||||||
}
|
}
|
||||||
|
|
||||||
current.author() != previous.author()
|
|
||||||
|| current
|
|
||||||
.created_at()
|
|
||||||
.as_secs()
|
|
||||||
.saturating_sub(previous.created_at().as_secs())
|
|
||||||
> RUN_WINDOW_SECS
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
pub(crate) fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
||||||
|
|||||||
+13
-4
@@ -11,7 +11,7 @@ use gpui::{
|
|||||||
SharedString, Styled, Subscription, Task, Window, div, px, relative,
|
SharedString, Styled, Subscription, Task, Window, div, px, relative,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::Directory;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
|
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
|
||||||
@@ -63,6 +63,8 @@ pub struct DeviceRegistry {
|
|||||||
pub(crate) pending_request: bool,
|
pub(crate) pending_request: bool,
|
||||||
/// Whether an announcement has been made for this device
|
/// Whether an announcement has been made for this device
|
||||||
pub(crate) announcement_existed: Arc<AtomicBool>,
|
pub(crate) announcement_existed: Arc<AtomicBool>,
|
||||||
|
/// Person directory for requesters shown in notifications
|
||||||
|
directory: Entity<Directory>,
|
||||||
/// Relay notification listener
|
/// Relay notification listener
|
||||||
notification_listener: Option<Task<Result<(), Error>>>,
|
notification_listener: Option<Task<Result<(), Error>>>,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
@@ -122,6 +124,7 @@ impl DeviceRegistry {
|
|||||||
signer,
|
signer,
|
||||||
pending_request: false,
|
pending_request: false,
|
||||||
announcement_existed: Arc::new(AtomicBool::new(false)),
|
announcement_existed: Arc::new(AtomicBool::new(false)),
|
||||||
|
directory: Directory::new(cx),
|
||||||
notification_listener: None,
|
notification_listener: None,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
@@ -690,7 +693,11 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a notification for the encryption request.
|
/// Build a notification for the encryption request.
|
||||||
fn notification(&self, event: Event, cx: &Context<Self>) -> Result<Notification, Error> {
|
fn notification(
|
||||||
|
&mut self,
|
||||||
|
event: Event,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Result<Notification, Error> {
|
||||||
const MSG: &str = "You've requested an encryption key from another device. \
|
const MSG: &str = "You've requested an encryption key from another device. \
|
||||||
Approve to allow Coop to share with it. Before approving, \
|
Approve to allow Coop to share with it. Before approving, \
|
||||||
compare the code with the one shown on the requesting \
|
compare the code with the one shown on the requesting \
|
||||||
@@ -705,8 +712,8 @@ impl DeviceRegistry {
|
|||||||
.context("Request has no valid requester key")?;
|
.context("Request has no valid requester key")?;
|
||||||
|
|
||||||
let code = authorization_code(&requester);
|
let code = authorization_code(&requester);
|
||||||
let persons = PersonRegistry::global(cx);
|
let directory = self.directory.clone();
|
||||||
let profile = persons.read(cx).get(&requester, cx);
|
directory.update(cx, |directory, cx| directory.track([requester], cx));
|
||||||
let code_label = code.clone();
|
let code_label = code.clone();
|
||||||
|
|
||||||
let entity = cx.entity().downgrade();
|
let entity = cx.entity().downgrade();
|
||||||
@@ -719,6 +726,8 @@ impl DeviceRegistry {
|
|||||||
.with_kind(NotificationKind::Info)
|
.with_kind(NotificationKind::Info)
|
||||||
.title("Encryption Key Request")
|
.title("Encryption Key Request")
|
||||||
.content(move |_this, _window, cx| {
|
.content(move |_this, _window, cx| {
|
||||||
|
let profile = directory.read(cx).person(&requester, cx);
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap(px(theme::SPACE))
|
.gap(px(theme::SPACE))
|
||||||
.text_style(TextStyle::Callout)
|
.text_style(TextStyle::Callout)
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
use std::cmp::Reverse;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use common::emojis_from_tags;
|
||||||
|
use gpui::{App, AppContext, Context, Entity, Subscription, Task};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use state::NostrRegistry;
|
||||||
|
|
||||||
|
use crate::{Person, PersonEvent, PersonRegistry};
|
||||||
|
|
||||||
|
/// Directory of persons tracked by a view
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Directory {
|
||||||
|
persons: HashMap<PublicKey, Person>,
|
||||||
|
/// Keys awaiting their first update, guarded for render-time access
|
||||||
|
pending: RwLock<HashSet<PublicKey>>,
|
||||||
|
tasks: SmallVec<[Task<()>; 2]>,
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Directory {
|
||||||
|
/// Create a directory following the registry's events
|
||||||
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||||
|
let registry = PersonRegistry::global(cx);
|
||||||
|
|
||||||
|
cx.new(|cx| {
|
||||||
|
let subscription = cx.subscribe(®istry, |this: &mut Self, _registry, event, cx| {
|
||||||
|
this.handle_event(event.clone(), cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
persons: HashMap::new(),
|
||||||
|
pending: RwLock::new(HashSet::new()),
|
||||||
|
tasks: SmallVec::new(),
|
||||||
|
_subscriptions: smallvec![subscription],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load known profiles from the database, request the rest
|
||||||
|
pub fn track(&mut self, keys: impl IntoIterator<Item = PublicKey>, cx: &mut Context<Self>) {
|
||||||
|
/// Profiles and emoji lists loaded from the database
|
||||||
|
type Loaded = (Vec<Person>, Vec<(PublicKey, Vec<(String, Url)>)>);
|
||||||
|
|
||||||
|
let missing: Vec<PublicKey> = keys
|
||||||
|
.into_iter()
|
||||||
|
.filter(|key| {
|
||||||
|
!self.persons.contains_key(key)
|
||||||
|
&& !self.with_pending(|pending| pending.contains(key))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if missing.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept events that arrive while the database query is running
|
||||||
|
self.with_pending(|pending| {
|
||||||
|
pending.extend(missing.iter().copied());
|
||||||
|
});
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let authors = missing.clone();
|
||||||
|
|
||||||
|
let task: Task<Result<Loaded, Error>> = cx.background_spawn(async move {
|
||||||
|
let mut metadata_events: Vec<Event> = client
|
||||||
|
.database()
|
||||||
|
.query(Filter::new().kind(Kind::Metadata).authors(authors.clone()))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Keep only the newest event per author
|
||||||
|
metadata_events.sort_by_key(|event| Reverse(event.created_at));
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
|
||||||
|
let persons = metadata_events
|
||||||
|
.into_iter()
|
||||||
|
.filter(|event| seen.insert(event.pubkey))
|
||||||
|
.map(|event| {
|
||||||
|
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||||
|
Person::new(event.pubkey, metadata)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut emoji_events: Vec<Event> = client
|
||||||
|
.database()
|
||||||
|
.query(Filter::new().kind(Kind::Emojis).authors(authors))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Keep only the newest event per author
|
||||||
|
emoji_events.sort_by_key(|event| Reverse(event.created_at));
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
|
||||||
|
let emojis = emoji_events
|
||||||
|
.into_iter()
|
||||||
|
.filter(|event| seen.insert(event.pubkey))
|
||||||
|
.map(|event| (event.pubkey, emojis_from_tags(&event.tags)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok((persons, emojis))
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let loaded = task.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
let remaining = match loaded {
|
||||||
|
Ok((persons, emojis)) => {
|
||||||
|
let found: HashSet<PublicKey> =
|
||||||
|
persons.iter().map(Person::public_key).collect();
|
||||||
|
this.bulk_insert(persons, cx);
|
||||||
|
|
||||||
|
// Emojis apply on top of loaded or already received data
|
||||||
|
let mut updated = false;
|
||||||
|
|
||||||
|
for (public_key, emojis) in emojis {
|
||||||
|
if emojis.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let person = this
|
||||||
|
.persons
|
||||||
|
.entry(public_key)
|
||||||
|
.or_insert_with(|| Person::new(public_key, Metadata::default()));
|
||||||
|
person.set_emojis(emojis);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
missing
|
||||||
|
.into_iter()
|
||||||
|
.filter(|key| !found.contains(key))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to load persons from the database: {e}");
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !remaining.is_empty() {
|
||||||
|
let registry = PersonRegistry::global(cx);
|
||||||
|
registry.read(cx).fetch(remaining);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The person for a key, or a placeholder that queues a request
|
||||||
|
pub fn person(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||||
|
if let Some(person) = self.persons.get(public_key) {
|
||||||
|
return person.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.with_pending(|pending| pending.insert(*public_key)) {
|
||||||
|
let registry = PersonRegistry::global(cx);
|
||||||
|
registry.read(cx).fetch([*public_key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Person::new(*public_key, Metadata::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge event data into the tracked person
|
||||||
|
fn handle_event(&mut self, event: PersonEvent, cx: &mut Context<Self>) {
|
||||||
|
let public_key = match &event {
|
||||||
|
PersonEvent::Metadata(person) => person.public_key(),
|
||||||
|
PersonEvent::Emojis { public_key, .. }
|
||||||
|
| PersonEvent::Announcement { public_key, .. }
|
||||||
|
| PersonEvent::MessagingRelays { public_key, .. } => *public_key,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.persons.contains_key(&public_key)
|
||||||
|
&& !self.with_pending(|pending| pending.remove(&public_key))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let person = self
|
||||||
|
.persons
|
||||||
|
.entry(public_key)
|
||||||
|
.or_insert_with(|| Person::new(public_key, Metadata::default()));
|
||||||
|
|
||||||
|
match event {
|
||||||
|
PersonEvent::Metadata(updated) => person.set_metadata(updated.metadata()),
|
||||||
|
PersonEvent::Emojis { emojis, .. } => person.set_emojis(emojis),
|
||||||
|
PersonEvent::Announcement { announcement, .. } => {
|
||||||
|
person.set_announcement(announcement);
|
||||||
|
}
|
||||||
|
PersonEvent::MessagingRelays { relays, .. } => person.set_messaging_relays(relays),
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert loaded persons, keeping data already received from relays
|
||||||
|
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||||
|
let mut inserted = false;
|
||||||
|
|
||||||
|
for person in persons {
|
||||||
|
let public_key = person.public_key();
|
||||||
|
|
||||||
|
if self.persons.contains_key(&public_key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.persons.insert(public_key, person);
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if inserted {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `then` with the pending set, tolerating poisoning: it is only a dedupe cache
|
||||||
|
fn with_pending<R>(&self, then: impl FnOnce(&mut HashSet<PublicKey>) -> R) -> R {
|
||||||
|
let mut pending = match self.pending.write() {
|
||||||
|
Ok(pending) => pending,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
then(&mut pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
-115
@@ -2,15 +2,17 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::EventExt;
|
use common::emojis_from_tags;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
||||||
|
|
||||||
|
mod directory;
|
||||||
mod person;
|
mod person;
|
||||||
|
|
||||||
|
pub use directory::*;
|
||||||
pub use person::*;
|
pub use person::*;
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
@@ -21,27 +23,46 @@ struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
|||||||
|
|
||||||
impl Global for GlobalPersonRegistry {}
|
impl Global for GlobalPersonRegistry {}
|
||||||
|
|
||||||
|
/// A change to a person's data
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PersonEvent {
|
||||||
|
/// Profile metadata
|
||||||
|
Metadata(Box<Person>),
|
||||||
|
/// Custom emoji list (NIP-30)
|
||||||
|
Emojis {
|
||||||
|
public_key: PublicKey,
|
||||||
|
emojis: Vec<(String, Url)>,
|
||||||
|
},
|
||||||
|
/// Profile encryption keys announcement (NIP-4e)
|
||||||
|
Announcement {
|
||||||
|
public_key: PublicKey,
|
||||||
|
announcement: Announcement,
|
||||||
|
},
|
||||||
|
/// Messaging relay list (NIP-65)
|
||||||
|
MessagingRelays {
|
||||||
|
public_key: PublicKey,
|
||||||
|
relays: Vec<RelayUrl>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PersonEvent> for PersonRegistry {}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum Dispatch {
|
enum Dispatch {
|
||||||
Person(Person),
|
Person(Person),
|
||||||
Announcement(Event),
|
Announcement(Event),
|
||||||
Relays(Event),
|
Relays(Event),
|
||||||
|
Emojis(Event),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Person Registry
|
/// Person Registry
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PersonRegistry {
|
pub struct PersonRegistry {
|
||||||
/// Collection of all persons (user profiles)
|
/// Public keys with a metadata request in flight
|
||||||
persons: HashMap<PublicKey, Entity<Person>>,
|
in_flight: RwLock<HashSet<PublicKey>>,
|
||||||
|
|
||||||
/// Set of public keys that have been seen
|
|
||||||
seen: RwLock<HashSet<PublicKey>>,
|
|
||||||
|
|
||||||
/// Sender for requesting metadata
|
|
||||||
sender: flume::Sender<PublicKey>,
|
sender: flume::Sender<PublicKey>,
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
|
||||||
tasks: SmallVec<[Task<()>; 4]>,
|
tasks: SmallVec<[Task<()>; 4]>,
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PersonRegistry {
|
impl PersonRegistry {
|
||||||
@@ -57,7 +78,6 @@ impl PersonRegistry {
|
|||||||
|
|
||||||
/// Create a new person registry instance
|
/// Create a new person registry instance
|
||||||
fn new(cx: &mut Context<Self>) -> Self {
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let entity = cx.entity().downgrade();
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
@@ -90,22 +110,26 @@ impl PersonRegistry {
|
|||||||
Dispatch::Relays(event) => {
|
Dispatch::Relays(event) => {
|
||||||
this.set_messaging_relays(&event, cx);
|
this.set_messaging_relays(&event, cx);
|
||||||
}
|
}
|
||||||
|
Dispatch::Emojis(event) => {
|
||||||
|
this.set_emojis(&event, cx);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Load all user profiles from the database
|
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||||
cx.defer(move |cx| {
|
if event.signer_changed() {
|
||||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
this.get_emojis(cx);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
persons: HashMap::new(),
|
in_flight: RwLock::new(HashSet::new()),
|
||||||
seen: RwLock::new(HashSet::new()),
|
|
||||||
sender: metadata_tx,
|
sender: metadata_tx,
|
||||||
tasks,
|
tasks,
|
||||||
|
_subscriptions: smallvec![subscription],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,25 +154,21 @@ impl PersonRegistry {
|
|||||||
Kind::Metadata => {
|
Kind::Metadata => {
|
||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
let person = Person::new(event.pubkey, metadata);
|
let person = Person::new(event.pubkey, metadata);
|
||||||
|
|
||||||
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
||||||
log::warn!("PersonRegistry channel closed, dropping metadata event");
|
log::warn!("Channel closed, dropping metadata event");
|
||||||
}
|
|
||||||
}
|
|
||||||
Kind::ContactList => {
|
|
||||||
let public_keys = event.extract_public_keys();
|
|
||||||
if let Err(e) = get_metadata(client, public_keys).await {
|
|
||||||
log::warn!("Failed to get metadata for contact list: {e}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Kind::InboxRelays => {
|
Kind::InboxRelays => {
|
||||||
tx.send_async(Dispatch::Relays(event.into_owned()))
|
_ = tx.send_async(Dispatch::Relays(event.into_owned())).await;
|
||||||
.await
|
}
|
||||||
.ok();
|
Kind::Emojis => {
|
||||||
|
_ = tx.send_async(Dispatch::Emojis(event.into_owned())).await;
|
||||||
}
|
}
|
||||||
Kind::Custom(10044) => {
|
Kind::Custom(10044) => {
|
||||||
tx.send_async(Dispatch::Announcement(event.into_owned()))
|
_ = tx
|
||||||
.await
|
.send_async(Dispatch::Announcement(event.into_owned()))
|
||||||
.ok();
|
.await;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -185,119 +205,101 @@ impl PersonRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load all user profiles from the database
|
/// Subscribe to the signed-in user's emoji list
|
||||||
fn load(&mut self, cx: &mut Context<Self>) {
|
fn get_emojis(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let task: Task<Result<Vec<Person>, Error>> = cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
let events = client.database().query(filter).await?;
|
let filter = Filter::new().kind(Kind::Emojis).author(public_key).limit(1);
|
||||||
let persons = events
|
|
||||||
.into_iter()
|
|
||||||
.map(|event| {
|
|
||||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
|
||||||
Person::new(event.pubkey, metadata)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(persons)
|
if let Err(e) = client.subscribe(vec![filter]).close_on(opts).await {
|
||||||
});
|
log::warn!("Failed to subscribe to emoji list: {e}");
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
||||||
if let Ok(persons) = task.await {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.bulk_insert(persons, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set profile encryption keys announcement
|
/// Set profile encryption keys announcement
|
||||||
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
|
fn set_announcement(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||||
let Some(announcement) = Announcement::from_event(event) else {
|
let Some(announcement) = Announcement::from_event(event) else {
|
||||||
log::warn!("Ignoring announcement with missing or invalid key tag");
|
log::warn!("Ignoring announcement with missing or invalid key tag");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
cx.emit(PersonEvent::Announcement {
|
||||||
person.update(cx, |person, cx| {
|
public_key: event.pubkey,
|
||||||
person.set_announcement(announcement);
|
announcement,
|
||||||
cx.notify();
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let person =
|
|
||||||
Person::new(event.pubkey, Metadata::default()).with_announcement(announcement);
|
|
||||||
self.insert(person, cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set messaging relays for a person
|
/// Set messaging relays for a person
|
||||||
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
|
fn set_messaging_relays(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).collect();
|
cx.emit(PersonEvent::MessagingRelays {
|
||||||
|
public_key: event.pubkey,
|
||||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
relays: nip17::extract_relay_list(event).collect(),
|
||||||
person.update(cx, |person, cx| {
|
});
|
||||||
person.set_messaging_relays(urls);
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let person = Person::new(event.pubkey, Metadata::default()).with_messaging_relays(urls);
|
|
||||||
self.insert(person, cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert batch of persons
|
/// Set custom emoji for a person from their emoji list event
|
||||||
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
fn set_emojis(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||||
for person in persons.into_iter() {
|
self.set_emojis_for(event.pubkey, emojis_from_tags(&event.tags), cx);
|
||||||
let public_key = person.public_key();
|
|
||||||
self.persons
|
|
||||||
.entry(public_key)
|
|
||||||
.or_insert_with(|| cx.new(|_| person));
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert or update a person
|
/// Set custom emoji for a person
|
||||||
pub fn insert(&mut self, person: Person, cx: &mut App) {
|
fn set_emojis_for(
|
||||||
|
&mut self,
|
||||||
|
public_key: PublicKey,
|
||||||
|
emojis: Vec<(String, Url)>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
cx.emit(PersonEvent::Emojis { public_key, emojis });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish a person's profile metadata to every directory
|
||||||
|
pub fn insert(&mut self, person: Person, cx: &mut Context<Self>) {
|
||||||
let public_key = person.public_key();
|
let public_key = person.public_key();
|
||||||
|
self.release_in_flight(&public_key);
|
||||||
|
|
||||||
match self.persons.get(&public_key) {
|
cx.emit(PersonEvent::Metadata(Box::new(person)));
|
||||||
Some(this) => {
|
}
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_metadata(person.metadata());
|
/// Queue a batched metadata request for keys without one in flight
|
||||||
cx.notify();
|
pub(crate) fn fetch<I>(&self, keys: I)
|
||||||
});
|
where
|
||||||
|
I: IntoIterator<Item = PublicKey>,
|
||||||
|
{
|
||||||
|
let mut queue = Vec::new();
|
||||||
|
{
|
||||||
|
let mut in_flight = match self.in_flight.write() {
|
||||||
|
Ok(in_flight) => in_flight,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
if in_flight.insert(key) {
|
||||||
|
queue.push(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => {
|
}
|
||||||
self.persons.insert(public_key, cx.new(|_| person));
|
|
||||||
|
for public_key in queue {
|
||||||
|
if let Err(error) = self.sender.send(public_key) {
|
||||||
|
log::warn!("Failed to queue metadata request: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get single person by public key
|
/// Allow future fetches for a key once its metadata has arrived
|
||||||
pub fn get(&self, public_key: &PublicKey, cx: &App) -> Person {
|
fn release_in_flight(&self, public_key: &PublicKey) {
|
||||||
if let Some(person) = self.persons.get(public_key) {
|
let mut in_flight = match self.in_flight.write() {
|
||||||
return person.read(cx).clone();
|
Ok(in_flight) => in_flight,
|
||||||
}
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
let public_key = *public_key;
|
in_flight.remove(public_key);
|
||||||
|
|
||||||
if self.seen.write().unwrap().insert(public_key) {
|
|
||||||
let sender = self.sender.clone();
|
|
||||||
|
|
||||||
// Spawn background task to request metadata
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
if let Err(e) = sender.send_async(public_key).await {
|
|
||||||
log::warn!("Failed to send public key for metadata request: {e}");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a temporary profile with default metadata
|
|
||||||
Person::new(public_key, Metadata::default())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-18
@@ -10,13 +10,12 @@ use state::Announcement;
|
|||||||
pub struct Person {
|
pub struct Person {
|
||||||
/// Public Key
|
/// Public Key
|
||||||
public_key: PublicKey,
|
public_key: PublicKey,
|
||||||
|
|
||||||
/// Metadata (profile)
|
/// Metadata (profile)
|
||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
|
/// Custom emoji (NIP-30) from the person's emoji list
|
||||||
|
emojis: Vec<(String, Url)>,
|
||||||
/// Dekey (NIP-4e) announcement
|
/// Dekey (NIP-4e) announcement
|
||||||
announcement: Option<Announcement>,
|
announcement: Option<Announcement>,
|
||||||
|
|
||||||
/// Messaging relays
|
/// Messaging relays
|
||||||
messaging_relays: Vec<RelayUrl>,
|
messaging_relays: Vec<RelayUrl>,
|
||||||
}
|
}
|
||||||
@@ -52,26 +51,12 @@ impl Person {
|
|||||||
Self {
|
Self {
|
||||||
public_key,
|
public_key,
|
||||||
metadata,
|
metadata,
|
||||||
|
emojis: vec![],
|
||||||
announcement: None,
|
announcement: None,
|
||||||
messaging_relays: vec![],
|
messaging_relays: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build profile encryption keys announcement
|
|
||||||
pub(crate) fn with_announcement(mut self, announcement: Announcement) -> Self {
|
|
||||||
self.announcement = Some(announcement);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build profile messaging relays
|
|
||||||
pub(crate) fn with_messaging_relays<I>(mut self, relays: I) -> Self
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = RelayUrl>,
|
|
||||||
{
|
|
||||||
self.messaging_relays = relays.into_iter().collect();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get profile public key
|
/// Get profile public key
|
||||||
pub fn public_key(&self) -> PublicKey {
|
pub fn public_key(&self) -> PublicKey {
|
||||||
self.public_key
|
self.public_key
|
||||||
@@ -82,6 +67,30 @@ impl Person {
|
|||||||
self.metadata.clone()
|
self.metadata.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Custom emoji (NIP-30) as (shortcode, image url) pairs
|
||||||
|
pub fn emojis(&self) -> &[(String, Url)] {
|
||||||
|
&self.emojis
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set custom emoji from the person's emoji list
|
||||||
|
pub(crate) fn set_emojis(&mut self, emojis: Vec<(String, Url)>) {
|
||||||
|
self.emojis = emojis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An `emoji` tag for every custom emoji referenced as `:shortcode:` in `content`
|
||||||
|
pub fn emoji_tags(&self, content: &str) -> Vec<Tag> {
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
|
||||||
|
for (shortcode, url) in &self.emojis {
|
||||||
|
let pattern = format!(":{shortcode}:");
|
||||||
|
if content.contains(&pattern) {
|
||||||
|
tags.push(Tag::custom("emoji", [shortcode.clone(), url.to_string()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
/// Get profile encryption keys announcement
|
/// Get profile encryption keys announcement
|
||||||
pub fn announcement(&self) -> Option<Announcement> {
|
pub fn announcement(&self) -> Option<Announcement> {
|
||||||
self.announcement.clone()
|
self.announcement.clone()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -12,6 +14,8 @@ pub fn init(cx: &mut App) {
|
|||||||
|
|
||||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||||
|
/// Built-in image shown behind chat and group panels
|
||||||
|
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||||
|
|
||||||
macro_rules! setting_accessors {
|
macro_rules! setting_accessors {
|
||||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||||
@@ -56,6 +60,8 @@ setting_accessors! {
|
|||||||
pub screening: bool,
|
pub screening: bool,
|
||||||
pub nip4e: bool,
|
pub nip4e: bool,
|
||||||
pub file_server: Url,
|
pub file_server: Url,
|
||||||
|
pub panel_background: bool,
|
||||||
|
pub panel_background_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
setting_getters! {
|
setting_getters! {
|
||||||
@@ -114,12 +120,8 @@ impl RoomConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Settings
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
/// Which appearance to paint: follow the OS, or pin light or dark.
|
|
||||||
/// `alias` reads the pre-bezel `theme_mode` key so old config files keep
|
|
||||||
/// their pinned choice.
|
|
||||||
#[serde(default, alias = "theme_mode")]
|
#[serde(default, alias = "theme_mode")]
|
||||||
pub appearance_mode: AppearanceMode,
|
pub appearance_mode: AppearanceMode,
|
||||||
|
|
||||||
@@ -132,6 +134,14 @@ pub struct Settings {
|
|||||||
/// Enable decoupling encryption key
|
/// Enable decoupling encryption key
|
||||||
pub nip4e: bool,
|
pub nip4e: bool,
|
||||||
|
|
||||||
|
/// Show an image behind chat and group panels
|
||||||
|
#[serde(default)]
|
||||||
|
pub panel_background: bool,
|
||||||
|
|
||||||
|
/// Custom image for the panel background; the built-in one when unset
|
||||||
|
#[serde(default)]
|
||||||
|
pub panel_background_path: Option<PathBuf>,
|
||||||
|
|
||||||
/// Trusted relays; Coop will automatically authenticate with these relays
|
/// Trusted relays; Coop will automatically authenticate with these relays
|
||||||
pub trusted_relays: Vec<String>,
|
pub trusted_relays: Vec<String>,
|
||||||
|
|
||||||
@@ -154,6 +164,8 @@ impl Default for Settings {
|
|||||||
hide_avatar: false,
|
hide_avatar: false,
|
||||||
screening: true,
|
screening: true,
|
||||||
nip4e: false,
|
nip4e: false,
|
||||||
|
panel_background: true,
|
||||||
|
panel_background_path: None,
|
||||||
trusted_relays: vec![],
|
trusted_relays: vec![],
|
||||||
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
||||||
pinned_rooms: vec![],
|
pinned_rooms: vec![],
|
||||||
@@ -272,6 +284,17 @@ impl AppSettings {
|
|||||||
self.inner.read(cx).nip4e
|
self.inner.read(cx).nip4e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The image to show behind chat and group panels, when enabled
|
||||||
|
pub fn panel_background_source(cx: &App) -> Option<ImageSource> {
|
||||||
|
if !Self::get_panel_background(cx) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(match Self::get_panel_background_path(cx) {
|
||||||
|
Some(path) => path.into(),
|
||||||
|
None => DEFAULT_BACKGROUND.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if the chat room is pinned
|
/// Check if the chat room is pinned
|
||||||
pub fn pinned_room(&self, id: u64, cx: &App) -> bool {
|
pub fn pinned_room(&self, id: u64, cx: &App) -> bool {
|
||||||
self.inner.read(cx).pinned_rooms.contains(&id)
|
self.inner.read(cx).pinned_rooms.contains(&id)
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ pub const CLIENT_NAME: &str = "Coop";
|
|||||||
pub const APP_ID: &str = "su.reya.coop";
|
pub const APP_ID: &str = "su.reya.coop";
|
||||||
|
|
||||||
/// Keyring name
|
/// Keyring name
|
||||||
pub(crate) const MASTER_KEYRING: &str = "Coop Master Key";
|
pub const USER_KEYRING: &str = "Coop Safe Storage";
|
||||||
pub const USER_KEYRING: &str = "Coop User Credential";
|
|
||||||
|
|
||||||
/// Default timeout for subscription
|
/// Default timeout for subscription
|
||||||
pub const TIMEOUT: u64 = 2;
|
pub const TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
|
/// Default timeout for Nostr Connect
|
||||||
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
/// Default delay for searching
|
/// Default delay for searching
|
||||||
pub const FIND_DELAY: u64 = 600;
|
pub const FIND_DELAY: u64 = 600;
|
||||||
|
|
||||||
@@ -31,9 +33,8 @@ pub(crate) const INDEXER_RELAYS: [&str; 2] =
|
|||||||
pub(crate) const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
pub(crate) const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||||
|
|
||||||
/// Default bootstrap relays
|
/// Default bootstrap relays
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||||
"wss://relay.ditto.pub",
|
"wss://relay.ditto.pub",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://relay.nostr.net",
|
|
||||||
"wss://profiles.nostr1.com",
|
"wss://profiles.nostr1.com",
|
||||||
];
|
];
|
||||||
|
|||||||
+255
-88
@@ -4,7 +4,7 @@ use anyhow::{Error, anyhow};
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use browser_signer_proxy::prelude::*;
|
use browser_signer_proxy::prelude::*;
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
@@ -53,6 +53,8 @@ pub enum StateEvent {
|
|||||||
NoSigner,
|
NoSigner,
|
||||||
/// The signer has changed
|
/// The signer has changed
|
||||||
SignerChanged,
|
SignerChanged,
|
||||||
|
/// A stored credential needs a passphrase to unlock
|
||||||
|
PassphraseRequired,
|
||||||
/// An error occurred
|
/// An error occurred
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
@@ -75,16 +77,12 @@ impl StateEvent {
|
|||||||
pub struct NostrRegistry {
|
pub struct NostrRegistry {
|
||||||
/// Nostr client
|
/// Nostr client
|
||||||
client: Client,
|
client: Client,
|
||||||
|
|
||||||
/// Universal signer
|
/// Universal signer
|
||||||
signer: UniversalSigner,
|
signer: UniversalSigner,
|
||||||
|
|
||||||
/// Current user's public key
|
/// Current user's public key
|
||||||
current_user: Option<PublicKey>,
|
current_user: Option<PublicKey>,
|
||||||
|
/// Whether the stored credential needs a passphrase to unlock
|
||||||
/// Whether the initial credential check has concluded
|
passphrase_required: bool,
|
||||||
ready: bool,
|
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
/// Tasks for asynchronous operations
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
@@ -126,6 +124,8 @@ impl NostrRegistry {
|
|||||||
.gossip(NostrGossipMemory::unbounded())
|
.gossip(NostrGossipMemory::unbounded())
|
||||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||||
.connect_timeout(Duration::from_secs(10))
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.verify_subscriptions(true)
|
||||||
|
.ban_relay_on_mismatch(true)
|
||||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||||
timeout: Duration::from_secs(600),
|
timeout: Duration::from_secs(600),
|
||||||
})
|
})
|
||||||
@@ -137,14 +137,13 @@ impl NostrRegistry {
|
|||||||
this.connect_bootstrap_relays(cx);
|
this.connect_bootstrap_relays(cx);
|
||||||
|
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
this.mark_ready(cx);
|
|
||||||
cx.emit(StateEvent::NoSigner);
|
cx.emit(StateEvent::NoSigner);
|
||||||
} else if let Some(secret) = cli_key {
|
} else if let Some(secret) = cli_key {
|
||||||
// Use CLI-provided key -- same path as get_user_credential
|
// Use CLI-provided key -- same path as restore_session
|
||||||
let keys = Keys::new(secret);
|
let keys = Keys::new(secret);
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
} else {
|
} else {
|
||||||
this.get_user_credential(cx);
|
this.restore_session(cx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -153,7 +152,7 @@ impl NostrRegistry {
|
|||||||
client,
|
client,
|
||||||
signer,
|
signer,
|
||||||
current_user: None,
|
current_user: None,
|
||||||
ready: false,
|
passphrase_required: false,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,17 +172,8 @@ impl NostrRegistry {
|
|||||||
self.current_user
|
self.current_user
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_ready(&mut self, cx: &mut Context<Self>) {
|
|
||||||
if self.ready {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.ready = true;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the signer
|
/// Update the signer
|
||||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
||||||
@@ -196,7 +186,8 @@ impl NostrRegistry {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.signer.swap_inner(new_signer);
|
this.signer.swap_inner(new_signer);
|
||||||
this.current_user = Some(public_key);
|
this.current_user = Some(public_key);
|
||||||
this.mark_ready(cx);
|
this.passphrase_required = false;
|
||||||
|
|
||||||
cx.emit(StateEvent::SignerChanged);
|
cx.emit(StateEvent::SignerChanged);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
@@ -247,93 +238,249 @@ impl NostrRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check the user's credential and set the signer if valid
|
/// Restore the signer from the stored credential
|
||||||
fn get_user_credential(&mut self, cx: &mut Context<Self>) {
|
fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||||
let master_keyring = self.get_master_key(cx);
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match user_keyring.await {
|
let result = async {
|
||||||
Ok(Some((_username, secret))) => {
|
match user_keyring.await {
|
||||||
let content = String::from_utf8(secret)?;
|
Ok(Some((_username, secret))) => {
|
||||||
|
let content = String::from_utf8(secret)?;
|
||||||
|
|
||||||
if content.starts_with("nsec1") {
|
if content.starts_with("nsec1") {
|
||||||
let secret_key = SecretKey::parse(&content)?;
|
let keys = Keys::new(SecretKey::parse(&content)?);
|
||||||
let keys = Keys::new(secret_key);
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
} else if content.starts_with("bunker://") {
|
||||||
|
// The master key used for Nostr Connect is embedded in
|
||||||
|
// the stored URI so reconnects reuse the same local key
|
||||||
|
let (base, keys) = extract_master_key(&content);
|
||||||
|
let uri = NostrConnectUri::parse(base)?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
// Construct the nostr connect signer
|
||||||
this.set_signer(keys, cx);
|
let mut signer = NostrConnect::new(
|
||||||
cx.notify();
|
uri,
|
||||||
|
keys,
|
||||||
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Handle auth url with the default browser
|
||||||
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(signer, cx);
|
||||||
|
})?;
|
||||||
|
} else if content.starts_with("ncryptsec1") {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.passphrase_required = true;
|
||||||
|
cx.emit(StateEvent::PassphraseRequired);
|
||||||
|
})?;
|
||||||
|
} else if content == "proxy" {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.connect_proxy(cx);
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
this.update(cx, |_, cx| {
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
this.update(cx, |_, cx| {
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
})?;
|
})?;
|
||||||
} else if content.starts_with("bunker://") {
|
|
||||||
let keys = master_keyring.await;
|
|
||||||
let timeout = Duration::from_secs(30);
|
|
||||||
let uri = NostrConnectUri::parse(content)?;
|
|
||||||
|
|
||||||
// Construct the nostr connect signer
|
|
||||||
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_signer(signer, cx);
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
} 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, |this, cx| {
|
Ok::<(), Error>(())
|
||||||
this.mark_ready(cx);
|
}
|
||||||
cx.emit(StateEvent::NoSigner);
|
.await;
|
||||||
})?;
|
|
||||||
}
|
if let Err(e) = result {
|
||||||
|
this.update(cx, |_, cx| {
|
||||||
|
cx.emit(StateEvent::error(e.to_string()));
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the master key that used for Nostr Connect
|
/// Unlock the stored encrypted key with its passphrase
|
||||||
pub fn get_master_key(&self, cx: &App) -> Task<Keys> {
|
pub fn restore_with_passphrase(
|
||||||
if cfg!(target_arch = "wasm32") {
|
&mut self,
|
||||||
return cx.background_spawn(async move { Keys::generate() });
|
password: &str,
|
||||||
}
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let password = password.to_owned();
|
||||||
|
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||||
|
|
||||||
let task = cx.read_credentials(MASTER_KEYRING);
|
cx.spawn(async move |this, cx| {
|
||||||
|
let content = user_keyring
|
||||||
|
.await?
|
||||||
|
.map(|(_username, secret)| String::from_utf8(secret))
|
||||||
|
.transpose()?
|
||||||
|
.ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?;
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
if !content.starts_with("ncryptsec1") {
|
||||||
let (keys, new_key) = match task.await {
|
return Err(anyhow!("stored credential is not passphrase-encrypted"));
|
||||||
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
|
|
||||||
Ok(secret_key) => (Keys::new(secret_key), false),
|
|
||||||
_ => (Keys::generate(), true),
|
|
||||||
},
|
|
||||||
_ => (Keys::generate(), true),
|
|
||||||
};
|
|
||||||
|
|
||||||
if new_key {
|
|
||||||
let keys_clone = keys.clone();
|
|
||||||
let username = keys_clone.public_key().to_hex();
|
|
||||||
let password = keys_clone.secret_key().to_secret_bytes();
|
|
||||||
|
|
||||||
cx.update(|cx| {
|
|
||||||
let task = cx.write_credentials(MASTER_KEYRING, &username, &password);
|
|
||||||
cx.background_spawn(async move { task.await.ok() }).detach();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keys
|
let decrypt_task = cx.background_spawn(async move {
|
||||||
|
let encrypted = EncryptedSecretKey::from_bech32(&content)?;
|
||||||
|
let secret = encrypted.decrypt(&password)?;
|
||||||
|
Ok::<_, Error>(Keys::new(secret))
|
||||||
|
});
|
||||||
|
|
||||||
|
let keys = decrypt_task.await?;
|
||||||
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(public_key)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Import an `nsec1...` secret key, storing it NIP-49 encrypted with `password`
|
||||||
|
pub fn import_nsec(
|
||||||
|
&mut self,
|
||||||
|
nsec: &str,
|
||||||
|
password: &str,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let keys = match SecretKey::parse(nsec.trim()) {
|
||||||
|
Ok(secret) => Keys::new(secret),
|
||||||
|
Err(e) => return Task::ready(Err(anyhow!(e))),
|
||||||
|
};
|
||||||
|
|
||||||
|
if password.is_empty() {
|
||||||
|
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = password.to_owned();
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let job = cx.background_spawn(async move {
|
||||||
|
let encrypted =
|
||||||
|
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?;
|
||||||
|
let ncryptsec = encrypted.to_bech32()?;
|
||||||
|
Ok::<_, Error>((keys, ncryptsec))
|
||||||
|
});
|
||||||
|
|
||||||
|
let (keys, ncryptsec) = job.await?;
|
||||||
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
|
let write = cx.update(|cx| {
|
||||||
|
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
|
||||||
|
});
|
||||||
|
write.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(public_key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import an NIP-49 encrypted secret key (`ncryptsec1...`), decrypting it with `password`
|
||||||
|
pub fn import_ncryptsec(
|
||||||
|
&mut self,
|
||||||
|
ncryptsec: &str,
|
||||||
|
password: &str,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let ncryptsec = ncryptsec.trim().to_owned();
|
||||||
|
|
||||||
|
if password.is_empty() {
|
||||||
|
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = password.to_owned();
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let stored = ncryptsec.clone();
|
||||||
|
let decrypt_task = cx.background_spawn(async move {
|
||||||
|
let encrypted = EncryptedSecretKey::from_bech32(&ncryptsec)?;
|
||||||
|
let secret = encrypted.decrypt(&password)?;
|
||||||
|
Ok::<_, Error>(Keys::new(secret))
|
||||||
|
});
|
||||||
|
|
||||||
|
let keys = decrypt_task.await?;
|
||||||
|
let public_key = keys.public_key();
|
||||||
|
|
||||||
|
let write = cx.update(|cx| {
|
||||||
|
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes())
|
||||||
|
});
|
||||||
|
write.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(public_key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a `bunker://...` URI (NIP-46), connecting to the remote signer
|
||||||
|
pub fn import_bunker(
|
||||||
|
&mut self,
|
||||||
|
uri: &str,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PublicKey, Error>> {
|
||||||
|
let uri_string = uri.trim().to_owned();
|
||||||
|
|
||||||
|
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
||||||
|
Ok(uri) => uri,
|
||||||
|
Err(e) => return Task::ready(Err(anyhow!(e))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let credential = with_master_key(&uri_string, &keys);
|
||||||
|
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
// Construct the nostr connect signer
|
||||||
|
let mut signer = NostrConnect::new(
|
||||||
|
connect_uri,
|
||||||
|
keys,
|
||||||
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Handle auth url with the default browser
|
||||||
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
|
// Verify the bunker responds before persisting the credential
|
||||||
|
let public_key = signer.get_public_key_async().await?;
|
||||||
|
write.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(signer, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(public_key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign out and remove the stored credential
|
||||||
|
pub fn sign_out(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.current_user = None;
|
||||||
|
self.passphrase_required = false;
|
||||||
|
|
||||||
|
cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx);
|
||||||
|
cx.emit(StateEvent::SignerChanged);
|
||||||
|
cx.emit(StateEvent::NoSigner);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Start the browser proxy
|
/// Start the browser proxy
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -514,3 +661,23 @@ impl NostrRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract the base URI and the master key from a bunker credential with `master=<nsec>`
|
||||||
|
fn extract_master_key(credential: &str) -> (&str, Keys) {
|
||||||
|
match credential.split_once("master=") {
|
||||||
|
Some((base, nsec)) => {
|
||||||
|
let keys = SecretKey::parse(nsec)
|
||||||
|
.map(Keys::new)
|
||||||
|
.unwrap_or_else(|_| Keys::generate());
|
||||||
|
(base.trim_end_matches(['?', '&']), keys)
|
||||||
|
}
|
||||||
|
None => (credential, Keys::generate()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed the master key in a bunker URI so reconnects reuse the same local key
|
||||||
|
fn with_master_key(uri: &str, keys: &Keys) -> String {
|
||||||
|
let separator = if uri.contains('?') { '&' } else { '?' };
|
||||||
|
let nsec = keys.secret_key().to_bech32().expect("infallible");
|
||||||
|
format!("{uri}{separator}master={nsec}")
|
||||||
|
}
|
||||||
|
|||||||
+38
-41
@@ -5,32 +5,9 @@ use std::pin::Pin;
|
|||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use nostr_connect::client::AuthUrlHandler;
|
use nostr_connect::client::AuthUrlHandler;
|
||||||
|
use nostr_sdk::error::Error as SignerError;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
|
||||||
|
|
||||||
impl fmt::Display for UniversalSignerError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Error for UniversalSignerError {
|
|
||||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
|
||||||
Some(&*self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UniversalSignerError {
|
|
||||||
pub fn new<E>(err: E) -> Self
|
|
||||||
where
|
|
||||||
E: Error + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
UniversalSignerError(Box::new(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct UniversalSigner {
|
pub struct UniversalSigner {
|
||||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||||
@@ -50,7 +27,7 @@ impl UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Swap the inner signer in-place. All clones see the new signer.
|
/// Swap the inner signer in-place. All clones see the new signer.
|
||||||
pub(crate) fn swap_inner<T>(&self, new_signer: T)
|
pub fn swap_inner<T>(&self, new_signer: T)
|
||||||
where
|
where
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||||
@@ -59,26 +36,46 @@ impl UniversalSigner {
|
|||||||
{
|
{
|
||||||
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
|
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encrypt a payload to the given key with NIP-44.
|
||||||
|
pub async fn nip44_encrypt(
|
||||||
|
&self,
|
||||||
|
public_key: &PublicKey,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<String, SignerError> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
inner.nip44_encrypt_async(public_key, content).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a NIP-44 payload from the given key.
|
||||||
|
pub async fn nip44_decrypt(
|
||||||
|
&self,
|
||||||
|
public_key: &PublicKey,
|
||||||
|
payload: &str,
|
||||||
|
) -> Result<String, SignerError> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
inner.nip44_decrypt_async(public_key, payload).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
unsigned: UnsignedEvent,
|
unsigned: UnsignedEvent,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>>;
|
||||||
fn nip44_encrypt_async<'a>(
|
fn nip44_encrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||||
fn nip44_decrypt_async<'a>(
|
fn nip44_decrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
payload: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -93,22 +90,22 @@ where
|
|||||||
{
|
{
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
unsigned: UnsignedEvent,
|
unsigned: UnsignedEvent,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,11 +113,11 @@ where
|
|||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,17 +125,17 @@ where
|
|||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
payload: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
.map_err(SignerError::other)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncGetPublicKey for UniversalSigner {
|
impl AsyncGetPublicKey for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
@@ -149,7 +146,7 @@ impl AsyncGetPublicKey for UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncSignEvent for UniversalSigner {
|
impl AsyncSignEvent for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
@@ -161,7 +158,7 @@ impl AsyncSignEvent for UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncNip44 for UniversalSigner {
|
impl AsyncNip44 for UniversalSigner {
|
||||||
type Error = UniversalSignerError;
|
type Error = SignerError;
|
||||||
|
|
||||||
fn nip44_encrypt_async<'a>(
|
fn nip44_encrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
@@ -191,7 +188,7 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
|
|||||||
auth_url: Url,
|
auth_url: Url,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
webbrowser::open(auth_url.as_str()).unwrap();
|
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-24
@@ -6,10 +6,10 @@ use std::sync::Arc;
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity,
|
AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity,
|
||||||
GlobalElementId, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId,
|
GlobalElementId, ImageSource, InspectorElementId, InteractiveElement as _, IntoElement,
|
||||||
MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, ScrollHandle,
|
LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render,
|
||||||
SharedString, Stateful, StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _,
|
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, Style, StyleRefinement,
|
||||||
WeakEntity, Window, actions, div, px,
|
Styled as _, WeakEntity, Window, actions, div, px,
|
||||||
};
|
};
|
||||||
pub use gpui_base::dock::{DockArea, DockLayout, DockPlacement};
|
pub use gpui_base::dock::{DockArea, DockLayout, DockPlacement};
|
||||||
use gpui_base::dock::{
|
use gpui_base::dock::{
|
||||||
@@ -24,7 +24,7 @@ use crate::resizable::{resize_handle, resize_handle_appearance};
|
|||||||
use crate::tab::Tab;
|
use crate::tab::Tab;
|
||||||
use crate::tab::tab_bar::TabBar;
|
use crate::tab::tab_bar::TabBar;
|
||||||
use crate::title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers, window_controls};
|
use crate::title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers, window_controls};
|
||||||
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
|
use crate::{IconName, Selectable, Sizable, StyledExt, background, h_flex, v_flex};
|
||||||
|
|
||||||
mod panel;
|
mod panel;
|
||||||
pub use panel::*;
|
pub use panel::*;
|
||||||
@@ -431,6 +431,11 @@ impl TabGroupSkin {
|
|||||||
let tabs_count = group.panels().len();
|
let tabs_count = group.panels().len();
|
||||||
let is_title_bar = self.is_title_bar_group(group, cx);
|
let is_title_bar = self.is_title_bar_group(group, cx);
|
||||||
|
|
||||||
|
let wallpaper = group
|
||||||
|
.active_panel()
|
||||||
|
.and_then(|panel| panel_background(panel, cx))
|
||||||
|
.is_some();
|
||||||
|
|
||||||
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
|
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
|
||||||
let displayed_ix = displayed.and_then(|displayed| {
|
let displayed_ix = displayed.and_then(|displayed| {
|
||||||
group
|
group
|
||||||
@@ -467,7 +472,7 @@ impl TabGroupSkin {
|
|||||||
let bar = TabBar::new("tab-bar")
|
let bar = TabBar::new("tab-bar")
|
||||||
.track_scroll(&self.scroll_handle)
|
.track_scroll(&self.scroll_handle)
|
||||||
.h(TABBAR_HEIGHT)
|
.h(TABBAR_HEIGHT)
|
||||||
.bg(cx.theme().bg)
|
.when(!wallpaper, |this| this.bg(cx.theme().bg))
|
||||||
.when(needs_traffic_light_padding, |this| {
|
.when(needs_traffic_light_padding, |this| {
|
||||||
this.pl(px(TRAFFIC_LIGHT_PADDING))
|
this.pl(px(TRAFFIC_LIGHT_PADDING))
|
||||||
})
|
})
|
||||||
@@ -569,7 +574,7 @@ impl TabGroupSkin {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.h(TABBAR_HEIGHT)
|
.h(TABBAR_HEIGHT)
|
||||||
.w_full()
|
.w_full()
|
||||||
.bg(cx.theme().bg)
|
.when(!wallpaper, |this| this.bg(cx.theme().bg))
|
||||||
.child(bar.flex_1())
|
.child(bar.flex_1())
|
||||||
.child(window_controls())
|
.child(window_controls())
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
@@ -647,23 +652,31 @@ impl TabGroupSkin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TabGroupRenderer for TabGroupSkin {
|
impl TabGroupRenderer for TabGroupSkin {
|
||||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, _cx: &mut App) -> Stateful<Div> {
|
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||||
div().id("tab-panel").when(!group.is_collapsed(), |this| {
|
let wallpaper = group
|
||||||
this.on_action({
|
.active_panel()
|
||||||
let group = TabGroupContext::clone(group);
|
.and_then(|panel| panel_background(panel, cx));
|
||||||
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
|
|
||||||
|
div()
|
||||||
|
.id("tab-panel")
|
||||||
|
.relative()
|
||||||
|
.when_some(wallpaper, |this, source| this.child(background(source, cx)))
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.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(
|
fn render_tab_bar(
|
||||||
@@ -710,6 +723,11 @@ impl TabGroupRenderer for TabGroupSkin {
|
|||||||
return Empty.into_any_element();
|
return Empty.into_any_element();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let wallpaper = group
|
||||||
|
.active_panel()
|
||||||
|
.and_then(|active| panel_background(active, cx))
|
||||||
|
.is_some();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.id("tab-content")
|
.id("tab-content")
|
||||||
.group("")
|
.group("")
|
||||||
@@ -718,7 +736,7 @@ impl TabGroupRenderer for TabGroupSkin {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
.bg(cx.theme().bg)
|
.when(!wallpaper, |this| this.bg(cx.theme().bg))
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
|
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
|
||||||
)
|
)
|
||||||
@@ -781,3 +799,7 @@ fn panel_title(panel: &Arc<dyn gpui_base::dock::PanelView>, cx: &App) -> AnyElem
|
|||||||
None => SharedString::from(panel.panel_name(cx)).into_any_element(),
|
None => SharedString::from(panel.panel_name(cx)).into_any_element(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn panel_background(panel: &Arc<dyn gpui_base::dock::PanelView>, cx: &App) -> Option<ImageSource> {
|
||||||
|
PanelHandle::of(panel).and_then(|handle| handle.panel().background(cx))
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::any::Any;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Render,
|
AnyElement, AnyView, App, Context, Element, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
SharedString, Window,
|
ImageSource, Render, SharedString, Window,
|
||||||
};
|
};
|
||||||
use gpui_base::dock::{PanelId, PanelState};
|
use gpui_base::dock::{PanelId, PanelState};
|
||||||
|
|
||||||
@@ -39,6 +39,9 @@ pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
|||||||
/// Set active state of the panel.
|
/// Set active state of the panel.
|
||||||
fn set_active(&self, _active: bool, _window: &mut Window, _cx: &mut App) {}
|
fn set_active(&self, _active: bool, _window: &mut Window, _cx: &mut App) {}
|
||||||
|
|
||||||
|
/// Called when the panel leaves the dock.
|
||||||
|
fn on_removed(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
|
||||||
|
|
||||||
/// Set zoomed state of the panel.
|
/// Set zoomed state of the panel.
|
||||||
fn set_zoomed(&self, _zoomed: bool, _cx: &mut App) {}
|
fn set_zoomed(&self, _zoomed: bool, _cx: &mut App) {}
|
||||||
|
|
||||||
@@ -46,6 +49,11 @@ pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
|||||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An optional image drawn behind the panel and its tab bar.
|
||||||
|
fn background(&self, _cx: &App) -> Option<ImageSource> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) trait PanelView: 'static + Send + Sync {
|
pub(crate) trait PanelView: 'static + Send + Sync {
|
||||||
@@ -54,8 +62,10 @@ pub(crate) trait PanelView: 'static + Send + Sync {
|
|||||||
fn closable(&self, cx: &App) -> bool;
|
fn closable(&self, cx: &App) -> bool;
|
||||||
fn zoomable(&self, cx: &App) -> bool;
|
fn zoomable(&self, cx: &App) -> bool;
|
||||||
fn visible(&self, cx: &App) -> bool;
|
fn visible(&self, cx: &App) -> bool;
|
||||||
|
fn background(&self, cx: &App) -> Option<ImageSource>;
|
||||||
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App);
|
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App);
|
||||||
fn set_zoomed(&self, zoomed: bool, cx: &mut App);
|
fn set_zoomed(&self, zoomed: bool, cx: &mut App);
|
||||||
|
fn on_removed(&self, window: &mut Window, cx: &mut App);
|
||||||
fn view(&self) -> AnyView;
|
fn view(&self) -> AnyView;
|
||||||
fn focus_handle(&self, cx: &App) -> FocusHandle;
|
fn focus_handle(&self, cx: &App) -> FocusHandle;
|
||||||
}
|
}
|
||||||
@@ -81,6 +91,10 @@ impl<T: Panel> PanelView for Entity<T> {
|
|||||||
self.read(cx).visible(cx)
|
self.read(cx).visible(cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn background(&self, cx: &App) -> Option<ImageSource> {
|
||||||
|
self.read(cx).background(cx)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
|
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
|
||||||
self.update(cx, |this, cx| {
|
self.update(cx, |this, cx| {
|
||||||
this.set_active(active, window, cx);
|
this.set_active(active, window, cx);
|
||||||
@@ -93,6 +107,12 @@ impl<T: Panel> PanelView for Entity<T> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_removed(&self, window: &mut Window, cx: &mut App) {
|
||||||
|
self.update(cx, |this, cx| {
|
||||||
|
this.on_removed(window, cx);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn view(&self) -> AnyView {
|
fn view(&self) -> AnyView {
|
||||||
self.clone().into()
|
self.clone().into()
|
||||||
}
|
}
|
||||||
@@ -163,7 +183,9 @@ impl gpui_base::dock::PanelView for PanelHandle {
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_removed(&self, _: &mut Window, _: &mut App) {}
|
fn on_removed(&self, window: &mut Window, cx: &mut App) {
|
||||||
|
self.panel.on_removed(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
fn view(&self) -> AnyView {
|
fn view(&self) -> AnyView {
|
||||||
self.panel.view()
|
self.panel.view()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, StyleRefinement,
|
AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, SharedUri,
|
||||||
Styled as _, Window,
|
StyleRefinement, Styled as _, Window,
|
||||||
};
|
};
|
||||||
use gpui_base::{TextView, TextViewStyle};
|
use gpui_base::{TextView, TextViewStyle};
|
||||||
use theme::ActiveTheme as _;
|
use theme::ActiveTheme as _;
|
||||||
@@ -12,6 +12,8 @@ use theme::ActiveTheme as _;
|
|||||||
pub struct InlineReplacement {
|
pub struct InlineReplacement {
|
||||||
pub range: Range<usize>,
|
pub range: Range<usize>,
|
||||||
pub text: SharedString,
|
pub text: SharedString,
|
||||||
|
/// When set, the span renders as this image (NIP-30 custom emoji)
|
||||||
|
pub image: Option<SharedUri>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InlineReplacement {
|
impl InlineReplacement {
|
||||||
@@ -19,6 +21,19 @@ impl InlineReplacement {
|
|||||||
Self {
|
Self {
|
||||||
range,
|
range,
|
||||||
text: text.into(),
|
text: text.into(),
|
||||||
|
image: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn image(
|
||||||
|
range: Range<usize>,
|
||||||
|
alt: impl Into<SharedString>,
|
||||||
|
url: impl Into<SharedUri>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
range,
|
||||||
|
text: alt.into(),
|
||||||
|
image: Some(url.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,11 +61,17 @@ impl RenderedText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
text.push_str(&content[last..range.start]);
|
text.push_str(&content[last..range.start]);
|
||||||
text.push_str(&format!(
|
match &replacement.image {
|
||||||
"[{}](<{}>)",
|
Some(url) => text.push_str(&format!(
|
||||||
escape_link_text(&replacement.text),
|
"",
|
||||||
&content[range.start..range.end]
|
escape_link_text(&replacement.text)
|
||||||
));
|
)),
|
||||||
|
None => text.push_str(&format!(
|
||||||
|
"[{}](<{}>)",
|
||||||
|
escape_link_text(&replacement.text),
|
||||||
|
&content[range.start..range.end]
|
||||||
|
)),
|
||||||
|
}
|
||||||
last = range.end;
|
last = range.end;
|
||||||
}
|
}
|
||||||
text.push_str(&content[last..]);
|
text.push_str(&content[last..]);
|
||||||
@@ -92,6 +113,8 @@ impl RenderedText {
|
|||||||
.on_link_click(|url, _, _, cx| {
|
.on_link_click(|url, _, _, cx| {
|
||||||
if is_web_url(url) {
|
if is_web_url(url) {
|
||||||
cx.open_url(url);
|
cx.open_url(url);
|
||||||
|
} else if let Some(bech32) = nevent_bech32(url) {
|
||||||
|
cx.open_url(&format!("https://njump.me/{bech32}"));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
@@ -115,3 +138,9 @@ fn is_web_url(url: &str) -> bool {
|
|||||||
let url = url.to_ascii_lowercase();
|
let url = url.to_ascii_lowercase();
|
||||||
url.starts_with("http://") || url.starts_with("https://")
|
url.starts_with("http://") || url.starts_with("https://")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extracts the bech32 from a NIP-19 event reference (`nevent1...` or `nostr:nevent1...`).
|
||||||
|
fn nevent_bech32(url: &str) -> Option<&str> {
|
||||||
|
let bech32 = url.strip_prefix("nostr:").unwrap_or(url);
|
||||||
|
bech32.starts_with("nevent1").then_some(bech32)
|
||||||
|
}
|
||||||
|
|||||||
+245
-5
@@ -1,16 +1,31 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
|
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
|
||||||
MouseDownEvent, ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString, SharedUri,
|
||||||
div, px,
|
StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div, img, px,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
use theme::{ActiveTheme, TextStyle, Typeset as _, bubble_radius, control_radius};
|
||||||
|
|
||||||
use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex};
|
use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex};
|
||||||
|
|
||||||
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
|
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
|
||||||
type DoubleClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
type ClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
||||||
|
|
||||||
|
/// Messages from the same author within this window are grouped into one run.
|
||||||
|
pub const RUN_WINDOW_SECS: u64 = 300;
|
||||||
|
|
||||||
|
/// Whether a message starts a new run, given the previous message's author and unix time.
|
||||||
|
pub fn opens_run<T: PartialEq>(previous: Option<(T, u64)>, current: (T, u64)) -> bool {
|
||||||
|
match previous {
|
||||||
|
None => true,
|
||||||
|
Some((previous_author, previous_at)) => {
|
||||||
|
previous_author != current.0 || current.1.saturating_sub(previous_at) > RUN_WINDOW_SECS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A single message row in a chat-like list.
|
/// A single message row in a chat-like list.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
@@ -26,7 +41,7 @@ pub struct MessageRow {
|
|||||||
body: SmallVec<[AnyElement; 4]>,
|
body: SmallVec<[AnyElement; 4]>,
|
||||||
overlays: SmallVec<[AnyElement; 2]>,
|
overlays: SmallVec<[AnyElement; 2]>,
|
||||||
on_mouse_down: Option<(MouseButton, MouseDownListener)>,
|
on_mouse_down: Option<(MouseButton, MouseDownListener)>,
|
||||||
on_double_click: Option<DoubleClickListener>,
|
on_double_click: Option<ClickListener>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageRow {
|
impl MessageRow {
|
||||||
@@ -217,6 +232,231 @@ impl RenderOnce for MessageRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A quoted preview of a message this message replies to.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct ReplyPreview {
|
||||||
|
id: ElementId,
|
||||||
|
style: StyleRefinement,
|
||||||
|
author: SharedString,
|
||||||
|
preview: SharedString,
|
||||||
|
on_click: Option<ClickListener>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReplyPreview {
|
||||||
|
/// Create a reply preview with the given element id, author and snippet.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
author: impl Into<SharedString>,
|
||||||
|
preview: impl Into<SharedString>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
author: author.into(),
|
||||||
|
preview: preview.into(),
|
||||||
|
on_click: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a click anywhere on the preview.
|
||||||
|
#[must_use]
|
||||||
|
pub fn on_click(
|
||||||
|
mut self,
|
||||||
|
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.on_click = Some(Box::new(listener));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for ReplyPreview {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for ReplyPreview {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let Self {
|
||||||
|
id,
|
||||||
|
style,
|
||||||
|
author,
|
||||||
|
preview,
|
||||||
|
on_click,
|
||||||
|
} = self;
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(id)
|
||||||
|
.w_full()
|
||||||
|
.px_2()
|
||||||
|
.border_l_2()
|
||||||
|
.border_color(cx.theme().element_active)
|
||||||
|
.text_style(TextStyle::Callout)
|
||||||
|
.refine_style(&style)
|
||||||
|
.child(div().font_semibold().child(author))
|
||||||
|
.child(div().w_full().text_ellipsis().line_clamp(1).child(preview))
|
||||||
|
.hover(|this| this.bg(cx.theme().surface_raised))
|
||||||
|
.when_some(on_click, |this, on_click| this.on_click(on_click))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The image media of a message: one large image or a wrapping row of thumbnails.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct MessageMedia {
|
||||||
|
style: StyleRefinement,
|
||||||
|
media: Vec<SharedUri>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageMedia {
|
||||||
|
/// Create media from image URLs.
|
||||||
|
pub fn new(media: &[SharedUri]) -> Self {
|
||||||
|
Self {
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
media: media.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for MessageMedia {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for MessageMedia {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let Self { style, media } = self;
|
||||||
|
|
||||||
|
if media.is_empty() {
|
||||||
|
return div().into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let [only] = media.as_slice() {
|
||||||
|
return div()
|
||||||
|
.refine_style(&style)
|
||||||
|
.child(
|
||||||
|
img(only.clone())
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border_faint)
|
||||||
|
.h(px(250.))
|
||||||
|
.object_fit(ObjectFit::Cover)
|
||||||
|
.rounded(px(bubble_radius())),
|
||||||
|
)
|
||||||
|
.into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
div()
|
||||||
|
.w_full()
|
||||||
|
.flex_1()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.flex_wrap()
|
||||||
|
.gap(px(theme::SPACE))
|
||||||
|
.refine_style(&style)
|
||||||
|
.children(media.into_iter().enumerate().map(|(ix, item)| {
|
||||||
|
div().id(format!("media-{ix}")).flex_shrink_0().child(
|
||||||
|
img(item)
|
||||||
|
.h_32()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border_faint)
|
||||||
|
.rounded(px(bubble_radius())),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reactions of one emoji with the count of authors and whether the current user reacted.
|
||||||
|
pub struct ReactionGroup {
|
||||||
|
pub emoji: SharedString,
|
||||||
|
/// Image of a custom emoji (NIP-30) reaction
|
||||||
|
pub image: Option<SharedUri>,
|
||||||
|
pub count: usize,
|
||||||
|
pub mine: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group (emoji, image, author) triples by emoji, sorted by emoji.
|
||||||
|
pub fn group_reactions<A: PartialEq>(
|
||||||
|
reactions: impl IntoIterator<Item = (SharedString, Option<SharedUri>, A)>,
|
||||||
|
me: Option<A>,
|
||||||
|
) -> Vec<ReactionGroup> {
|
||||||
|
let mut groups: BTreeMap<SharedString, (usize, bool, Option<SharedUri>)> = BTreeMap::new();
|
||||||
|
|
||||||
|
for (emoji, image, author) in reactions {
|
||||||
|
let group = groups.entry(emoji).or_default();
|
||||||
|
group.0 += 1;
|
||||||
|
group.1 |= me.as_ref() == Some(&author);
|
||||||
|
if group.2.is_none() {
|
||||||
|
group.2 = image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups
|
||||||
|
.into_iter()
|
||||||
|
.map(|(emoji, (count, mine, image))| ReactionGroup {
|
||||||
|
emoji,
|
||||||
|
image,
|
||||||
|
count,
|
||||||
|
mine,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reaction chips shown under a message.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct ReactionBar {
|
||||||
|
style: StyleRefinement,
|
||||||
|
groups: SmallVec<[ReactionGroup; 4]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReactionBar {
|
||||||
|
/// Create a reaction bar from grouped reactions.
|
||||||
|
pub fn new(groups: impl IntoIterator<Item = ReactionGroup>) -> Self {
|
||||||
|
Self {
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
groups: groups.into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for ReactionBar {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for ReactionBar {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let Self { style, groups } = self;
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.mt_2()
|
||||||
|
.gap_1()
|
||||||
|
.refine_style(&style)
|
||||||
|
.children(groups.into_iter().map(|group| {
|
||||||
|
h_flex()
|
||||||
|
.gap(px(theme::SPACE))
|
||||||
|
.py_0p5()
|
||||||
|
.px_1()
|
||||||
|
.rounded(px(control_radius()))
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.border_1()
|
||||||
|
.when(group.mine, |this| {
|
||||||
|
this.text_color(cx.theme().text)
|
||||||
|
.bg(cx.theme().surface_raised)
|
||||||
|
.border_color(cx.theme().element_active)
|
||||||
|
})
|
||||||
|
.when(!group.mine, |this| this.border_color(cx.theme().border))
|
||||||
|
.when(group.image.is_none(), |this| this.child(group.emoji))
|
||||||
|
.when_some(group.image, |this, image| {
|
||||||
|
this.child(img(image).size(px(16.)).flex_shrink_0())
|
||||||
|
})
|
||||||
|
.child(SharedString::from(group.count.to_string()))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A welcome message shown at the top of a message list.
|
/// A welcome message shown at the top of a message list.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct WelcomeMessage {
|
pub struct WelcomeMessage {
|
||||||
|
|||||||
+18
-2
@@ -1,4 +1,7 @@
|
|||||||
use gpui::{App, Div, Pixels, Refineable, StyleRefinement, Styled, div, px};
|
use gpui::{
|
||||||
|
App, Div, ImageSource, ObjectFit, ParentElement, Pixels, Refineable, StyleRefinement, Styled,
|
||||||
|
StyledImage, div, img, px,
|
||||||
|
};
|
||||||
pub use gpui_base::component_traits::{Disableable, Selectable};
|
pub use gpui_base::component_traits::{Disableable, Selectable};
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius, surface_shadows};
|
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius, surface_shadows};
|
||||||
|
|
||||||
@@ -14,7 +17,20 @@ pub fn v_flex() -> Div {
|
|||||||
|
|
||||||
/// Returns a `Div` as divider.
|
/// Returns a `Div` as divider.
|
||||||
pub fn divider(cx: &App) -> Div {
|
pub fn divider(cx: &App) -> Div {
|
||||||
div().my_1().w_full().h_px().bg(cx.theme().border_faint)
|
div().w_full().h_px().bg(cx.theme().border_faint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a full-bleed image with a wash for readability, to show behind panel content.
|
||||||
|
pub fn background<I>(image: I, cx: &App) -> Div
|
||||||
|
where
|
||||||
|
I: Into<ImageSource>,
|
||||||
|
{
|
||||||
|
let wash = if cx.theme().is_dark() { 0.5 } else { 0.2 };
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.inset_0()
|
||||||
|
.child(img(image).size_full().object_fit(ObjectFit::Cover))
|
||||||
|
.child(div().absolute().inset_0().bg(cx.theme().bg.opacity(wash)))
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! font_weight {
|
macro_rules! font_weight {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use anyhow::{Error, anyhow};
|
use anyhow::Error;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
@@ -6,11 +6,11 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
|
use state::NostrRegistry;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius};
|
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{Input, InputEvent, InputState};
|
||||||
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
|
use ui::{Disableable, WindowExtension, v_flex};
|
||||||
|
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
||||||
@@ -25,146 +25,157 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a passphrase-only dialog to unlock the stored encrypted key
|
||||||
|
pub fn open_unlock(window: &mut Window, cx: &mut App) {
|
||||||
|
let import = cx.new(|cx| ImportIdentity::new_unlock(window, cx));
|
||||||
|
|
||||||
|
window.open_dialog(cx, move |this, _window, _cx| {
|
||||||
|
this.width(px(450.))
|
||||||
|
.show_close(false)
|
||||||
|
.overlay_closable(false)
|
||||||
|
.keyboard(false)
|
||||||
|
.title("Unlock your identity")
|
||||||
|
.child(import.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum Mode {
|
||||||
|
/// Import a credential (nsec, ncryptsec or bunker)
|
||||||
|
Import,
|
||||||
|
/// Unlock the stored encrypted key with its passphrase
|
||||||
|
Unlock,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ImportIdentity {
|
pub struct ImportIdentity {
|
||||||
|
mode: Mode,
|
||||||
/// Secret key input
|
/// Secret key input
|
||||||
key_input: Entity<InputState>,
|
key_input: Entity<InputState>,
|
||||||
/// Password input (if required)
|
/// Passphrase input
|
||||||
pass_input: Entity<InputState>,
|
pass_input: Entity<InputState>,
|
||||||
|
/// Repeated passphrase input when encrypting a plaintext key
|
||||||
|
repass_input: Entity<InputState>,
|
||||||
/// Error message
|
/// Error message
|
||||||
error: Entity<Option<SharedString>>,
|
error: Entity<Option<SharedString>>,
|
||||||
/// Whether the user is currently loading
|
/// Whether the user is currently loading
|
||||||
loading: bool,
|
loading: bool,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
_subscription: Option<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImportIdentity {
|
impl ImportIdentity {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("nsec or bunker://"));
|
Self::build(Mode::Import, window, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_unlock(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
Self::build(Mode::Unlock, window, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
|
||||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
|
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
let error = cx.new(|_| None);
|
let error = cx.new(|_| None);
|
||||||
|
|
||||||
let input_subscription =
|
let subscriptions = vec![
|
||||||
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| {
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
this.login(window, cx);
|
this.login(cx);
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
|
cx.subscribe_in(&pass_input, window, |this, _input, event, _window, cx| {
|
||||||
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
|
this.login(cx);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
cx.subscribe_in(&repass_input, window, |this, _input, event, _window, cx| {
|
||||||
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
|
this.login(cx);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
mode,
|
||||||
key_input,
|
key_input,
|
||||||
pass_input,
|
pass_input,
|
||||||
|
repass_input,
|
||||||
error,
|
error,
|
||||||
loading: false,
|
loading: false,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscription: Some(input_subscription),
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn login(&mut self, cx: &mut Context<Self>) {
|
||||||
let value = self.key_input.read(cx).value();
|
let password = self.pass_input.read(cx).value().to_string();
|
||||||
let password = self.pass_input.read(cx).value();
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
// Set loading state
|
if self.mode == Mode::Unlock {
|
||||||
|
if password.is_empty() {
|
||||||
|
self.set_error("Passphrase is required", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_loading(true, cx);
|
||||||
|
let task = nostr.update(cx, |this, cx| this.restore_with_passphrase(&password, cx));
|
||||||
|
self.finish(task, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = self.key_input.read(cx).value().trim().to_string();
|
||||||
self.set_loading(true, cx);
|
self.set_loading(true, cx);
|
||||||
|
|
||||||
if value.starts_with("ncryptsec1") {
|
|
||||||
self.ncryptsec(value, password, window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if value.starts_with("bunker://") {
|
if value.starts_with("bunker://") {
|
||||||
match NostrConnectUri::parse(value) {
|
let task = nostr.update(cx, |this, cx| this.import_bunker(&value, cx));
|
||||||
Ok(uri) => {
|
self.finish(task, cx);
|
||||||
self.bunker(uri, window, cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.set_error(e.to_string(), cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(secret) = SecretKey::parse(&value) {
|
if value.starts_with("ncryptsec1") {
|
||||||
let keys = Keys::new(secret);
|
if password.is_empty() {
|
||||||
let nostr = NostrRegistry::global(cx);
|
self.set_error("Passphrase is required", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update the signer
|
let task = nostr.update(cx, |this, cx| this.import_ncryptsec(&value, &password, cx));
|
||||||
nostr.update(cx, |this, cx| {
|
self.finish(task, cx);
|
||||||
this.set_signer(keys, cx);
|
return;
|
||||||
});
|
|
||||||
} else {
|
|
||||||
self.set_error("Invalid key", cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A plaintext key is encrypted with the passphrase before it is stored
|
||||||
|
if value.starts_with("nsec1") || SecretKey::parse(&value).is_ok() {
|
||||||
|
if password.is_empty() {
|
||||||
|
self.set_error("Passphrase is required", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let repass = self.repass_input.read(cx).value().to_string();
|
||||||
|
|
||||||
|
if password != repass {
|
||||||
|
self.set_error("Passphrases do not match", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = nostr.update(cx, |this, cx| this.import_nsec(&value, &password, cx));
|
||||||
|
self.finish(task, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_error("Enter an nsec, ncryptsec or bunker://", cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
|
/// Run the import task, surfacing errors while success closes the dialog
|
||||||
where
|
/// through the `SignerChanged` event
|
||||||
S: Into<String>,
|
fn finish(&mut self, task: Task<Result<PublicKey, Error>>, cx: &mut Context<Self>) {
|
||||||
{
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let nostr = NostrRegistry::global(cx);
|
if let Err(e) = task.await {
|
||||||
let content: String = content.into();
|
this.update(cx, |this, cx| {
|
||||||
let password: String = pwd.into();
|
this.set_error(e.to_string(), cx);
|
||||||
|
})?;
|
||||||
if password.is_empty() {
|
|
||||||
self.set_error("Password is required", cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(enc) = EncryptedSecretKey::from_bech32(&content) else {
|
|
||||||
self.set_error("Secret Key is invalid", cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Decrypt in the background to ensure it doesn't block the UI
|
|
||||||
let task = cx.background_spawn(async move {
|
|
||||||
if let Ok(content) = enc.decrypt(&password) {
|
|
||||||
Ok(Keys::new(content))
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Invalid password"))
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match task.await {
|
|
||||||
Ok(keys) => {
|
|
||||||
nostr.update_in(cx, |this, window, cx| {
|
|
||||||
this.set_signer(keys, cx);
|
|
||||||
window.close_dialog(cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_error(e.to_string(), cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bunker(&mut self, uri: NostrConnectUri, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let master_keys = nostr.read(cx).get_master_key(cx);
|
|
||||||
let password = uri.to_string();
|
|
||||||
let save = cx.write_credentials(USER_KEYRING, "bunker", password.as_bytes());
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
let keys = master_keys.await;
|
|
||||||
let timeout = Duration::from_secs(30);
|
|
||||||
|
|
||||||
// Construct the nostr connect signer
|
|
||||||
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(signer, cx);
|
|
||||||
cx.background_spawn(async move { save.await.ok() }).detach();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
@@ -214,36 +225,62 @@ impl ImportIdentity {
|
|||||||
impl Render for ImportIdentity {
|
impl Render for ImportIdentity {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
||||||
const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
|
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
|
||||||
|
|
||||||
let is_wasm = cfg!(target_arch = "wasm32");
|
let is_wasm = cfg!(target_arch = "wasm32");
|
||||||
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
|
let is_unlock = self.mode == Mode::Unlock;
|
||||||
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
|
|
||||||
let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://");
|
let value = self.key_input.read(cx).value().trim().to_string();
|
||||||
|
let require_password = value.starts_with("ncryptsec1");
|
||||||
|
let require_encrypt = value.starts_with("nsec1");
|
||||||
|
let key_warning = require_password || require_encrypt;
|
||||||
|
let bunker_warning = value.starts_with("bunker://");
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.gap(px(theme::SPACE))
|
.gap_4()
|
||||||
.text_style(TextStyle::Callout)
|
.text_style(TextStyle::Callout)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap(px(theme::SPACE))
|
||||||
.child(
|
.when(!is_unlock, |this| {
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child("Use secret key or bunker connection")
|
|
||||||
.child(Input::new(&self.key_input)),
|
|
||||||
)
|
|
||||||
.when(require_password, |this| {
|
|
||||||
this.child(
|
this.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child("Decrypt Password:")
|
.child("Use secret key or bunker connection")
|
||||||
|
.child(Input::new(&self.key_input)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(is_unlock || require_password, |this| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(if is_unlock {
|
||||||
|
"Passphrase to unlock your identity:"
|
||||||
|
} else {
|
||||||
|
"Decrypt Password:"
|
||||||
|
})
|
||||||
.child(Input::new(&self.pass_input)),
|
.child(Input::new(&self.pass_input)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.when(require_encrypt, |this| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Passphrase to encrypt your key on this device:")
|
||||||
|
.child(Input::new(&self.pass_input)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Repeat Passphrase:")
|
||||||
|
.child(Input::new(&self.repass_input)),
|
||||||
|
)
|
||||||
|
})
|
||||||
.when(bunker_warning, |this| {
|
.when(bunker_warning, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
@@ -271,21 +308,19 @@ impl Render for ImportIdentity {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("login")
|
Button::new("login")
|
||||||
.label("Continue")
|
.label(if is_unlock { "Unlock" } else { "Continue" })
|
||||||
.primary()
|
.primary()
|
||||||
.font_semibold()
|
|
||||||
.loading(self.loading)
|
.loading(self.loading)
|
||||||
.disabled(self.loading)
|
.disabled(self.loading)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
this.login(window, cx);
|
this.login(cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.when(!is_wasm, |this| {
|
.when(!is_wasm && !is_unlock, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("proxy")
|
Button::new("proxy")
|
||||||
.label("Connect via Web Extension (Experimental)")
|
.label("Connect via Web Extension (Experimental)")
|
||||||
.ghost()
|
.ghost()
|
||||||
.loading(self.loading)
|
|
||||||
.disabled(self.loading)
|
.disabled(self.loading)
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
this.proxy(cx);
|
this.proxy(cx);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use gpui::{
|
|||||||
Window, div, px, uniform_list,
|
Window, div, px, uniform_list,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::Directory;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||||
@@ -39,6 +39,8 @@ pub struct NewChat {
|
|||||||
input: Entity<InputState>,
|
input: Entity<InputState>,
|
||||||
/// Current user's contacts
|
/// Current user's contacts
|
||||||
contacts: Option<Vec<PublicKey>>,
|
contacts: Option<Vec<PublicKey>>,
|
||||||
|
/// Person directory for contacts and selected members
|
||||||
|
directory: Entity<Directory>,
|
||||||
/// Public keys selected as members of the chat
|
/// Public keys selected as members of the chat
|
||||||
members: Vec<PublicKey>,
|
members: Vec<PublicKey>,
|
||||||
/// Whether a NIP-05 address is being resolved
|
/// Whether a NIP-05 address is being resolved
|
||||||
@@ -69,6 +71,7 @@ impl NewChat {
|
|||||||
dock,
|
dock,
|
||||||
input,
|
input,
|
||||||
contacts: None,
|
contacts: None,
|
||||||
|
directory: Directory::new(cx),
|
||||||
members: Vec::new(),
|
members: Vec::new(),
|
||||||
resolving: false,
|
resolving: false,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -112,6 +115,9 @@ impl NewChat {
|
|||||||
match task.await {
|
match task.await {
|
||||||
Ok(contacts) => {
|
Ok(contacts) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
this.directory.update(cx, |directory, cx| {
|
||||||
|
directory.track(contacts.iter().copied(), cx)
|
||||||
|
});
|
||||||
this.contacts = Some(contacts);
|
this.contacts = Some(contacts);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
@@ -133,7 +139,6 @@ impl NewChat {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let query = self.input.read(cx).value().trim().to_lowercase();
|
let query = self.input.read(cx).value().trim().to_lowercase();
|
||||||
|
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
@@ -146,7 +151,7 @@ impl NewChat {
|
|||||||
if public_key.to_hex().to_lowercase().contains(&query) {
|
if public_key.to_hex().to_lowercase().contains(&query) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let name = persons.read(cx).get(public_key, cx).name();
|
let name = self.directory.read(cx).person(public_key, cx).name();
|
||||||
name.to_lowercase().contains(&query)
|
name.to_lowercase().contains(&query)
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -318,7 +323,6 @@ impl NewChat {
|
|||||||
contacts: &[PublicKey],
|
contacts: &[PublicKey],
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Vec<AnyElement> {
|
) -> Vec<AnyElement> {
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let start = range.start;
|
let start = range.start;
|
||||||
|
|
||||||
contacts
|
contacts
|
||||||
@@ -327,7 +331,7 @@ impl NewChat {
|
|||||||
.flatten()
|
.flatten()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(offset, public_key)| {
|
.map(|(offset, public_key)| {
|
||||||
let profile = persons.read(cx).get(public_key, cx);
|
let profile = self.directory.read(cx).person(public_key, cx);
|
||||||
let public_key = *public_key;
|
let public_key = *public_key;
|
||||||
|
|
||||||
Nav::new(start + offset)
|
Nav::new(start + offset)
|
||||||
@@ -350,13 +354,11 @@ impl NewChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_members(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_members(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.flex_wrap()
|
.flex_wrap()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.children(self.members.iter().map(|public_key| {
|
.children(self.members.iter().map(|public_key| {
|
||||||
let profile = persons.read(cx).get(public_key, cx);
|
let profile = self.directory.read(cx).person(public_key, cx);
|
||||||
let public_key = *public_key;
|
let public_key = *public_key;
|
||||||
let id = SharedString::from(format!("m-{}", public_key.to_hex()));
|
let id = SharedString::from(format!("m-{}", public_key.to_hex()));
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,621 @@
|
|||||||
use std::rc::Rc;
|
use anyhow::Error;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{App, AppContext, ClickEvent, Div, Entity, ParentElement, Styled, Window, px};
|
use gpui::{
|
||||||
use nip29::{GroupId, GroupKey, GroupMetadata, GroupsRegistry};
|
App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement,
|
||||||
|
ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement as _,
|
||||||
|
Styled, Subscription, Task, WeakEntity, Window, div, px, rems,
|
||||||
|
};
|
||||||
|
use gpui_base::{Popup, Select};
|
||||||
|
use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
use settings::AppSettings;
|
||||||
use ui::button::{Button, ButtonVariants, IconPosition};
|
use state::upload;
|
||||||
use ui::input::{Input, InputState};
|
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, control_radius};
|
||||||
use ui::notification::Notification;
|
use ui::avatar::Avatar;
|
||||||
use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex};
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::dock::{DockArea, DockPlacement, PanelHandle};
|
||||||
|
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||||
|
use ui::switch::Switch;
|
||||||
|
use ui::{
|
||||||
|
Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex,
|
||||||
|
};
|
||||||
|
|
||||||
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
|
const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"];
|
||||||
|
const PRIVATE_DESC: &str = "Only members can read group messages";
|
||||||
|
const CLOSED_DESC: &str = "Join requests are ignored (invite-only)";
|
||||||
|
const RESTRICTED_DESC: &str = "Only members can post messages";
|
||||||
|
const HIDDEN_DESC: &str = "Hidden from non-members, not discoverable";
|
||||||
|
const LIST_PRIVATE_DESC: &str = "Kept encrypted in your group list so others can't see you joined";
|
||||||
|
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||||
let name = cx.new(|cx| InputState::new(window, cx).placeholder("Group name"));
|
open_with(dock, None, window, cx);
|
||||||
let id = cx.new(|cx| InputState::new(window, cx).placeholder("Group id"));
|
}
|
||||||
let relay = cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com"));
|
|
||||||
|
|
||||||
let submit: SubmitFn = {
|
pub fn open_channel(
|
||||||
let (name, id, relay) = (name.clone(), id.clone(), relay.clone());
|
dock: WeakEntity<DockArea>,
|
||||||
Rc::new(move |_, window, cx| create(&name, &id, &relay, window, cx))
|
parent: GroupKey,
|
||||||
};
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
open_with(dock, Some(parent), window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, cx| {
|
fn open_with(
|
||||||
this.width(px(380.))
|
dock: WeakEntity<DockArea>,
|
||||||
.title("New group")
|
parent: Option<GroupKey>,
|
||||||
.on_ok({
|
window: &mut Window,
|
||||||
let submit = submit.clone();
|
cx: &mut App,
|
||||||
move |event, window, cx| submit(event, window, cx)
|
) {
|
||||||
|
let is_channel = parent.is_some();
|
||||||
|
let view = cx.new(|cx| NewGroup::new(dock, parent, window, cx));
|
||||||
|
|
||||||
|
window.open_dialog(cx, {
|
||||||
|
let view = view.clone();
|
||||||
|
|
||||||
|
move |this, _window, _cx| {
|
||||||
|
let title = if is_channel {
|
||||||
|
"Create Channel"
|
||||||
|
} else {
|
||||||
|
"Create a Group"
|
||||||
|
};
|
||||||
|
|
||||||
|
this.width(px(460.)).title(title).child(view.clone())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewGroup {
|
||||||
|
dock: WeakEntity<DockArea>,
|
||||||
|
/// The parent group when creating a channel; the relay is locked to its own.
|
||||||
|
parent: Option<GroupKey>,
|
||||||
|
name_input: Entity<InputState>,
|
||||||
|
about_input: Entity<TextareaState>,
|
||||||
|
custom_relay_input: Entity<InputState>,
|
||||||
|
/// The relays offered in the relay select.
|
||||||
|
relays: Vec<RelayUrl>,
|
||||||
|
/// The selected relay; `None` selects the custom relay field.
|
||||||
|
selected_relay: Option<RelayUrl>,
|
||||||
|
/// Whether the relay select popup is open.
|
||||||
|
relay_select_open: bool,
|
||||||
|
relay_options_focus: FocusHandle,
|
||||||
|
/// Keep the group in the NIP-44 encrypted section of the user's group list.
|
||||||
|
list_private: bool,
|
||||||
|
/// The uploaded group picture, ready to be published with the metadata.
|
||||||
|
picture: Option<SharedString>,
|
||||||
|
/// Whether a picture upload is in flight.
|
||||||
|
uploading: bool,
|
||||||
|
private: bool,
|
||||||
|
closed: bool,
|
||||||
|
restricted: bool,
|
||||||
|
hidden: bool,
|
||||||
|
creating: bool,
|
||||||
|
error: Option<SharedString>,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewGroup {
|
||||||
|
fn new(
|
||||||
|
dock: WeakEntity<DockArea>,
|
||||||
|
parent: Option<GroupKey>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
|
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("#example"));
|
||||||
|
|
||||||
|
let custom_relay_input =
|
||||||
|
cx.new(|cx| InputState::new(window, cx).placeholder("relay.example.com"));
|
||||||
|
|
||||||
|
let about_input = cx.new(|cx| {
|
||||||
|
TextareaState::new(window, cx)
|
||||||
|
.auto_grow(3, 5)
|
||||||
|
.placeholder("What is this group about?")
|
||||||
|
});
|
||||||
|
|
||||||
|
let relay_options_focus = cx.focus_handle();
|
||||||
|
|
||||||
|
let relays: Vec<RelayUrl> = DEFAULT_RELAYS
|
||||||
|
.iter()
|
||||||
|
.filter_map(|url| match RelayUrl::parse(url) {
|
||||||
|
Ok(relay) => Some(relay),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("the default group relay {url} is invalid: {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
.collect();
|
||||||
|
let selected_relay = relays.first().cloned();
|
||||||
|
|
||||||
|
let registry = GroupsRegistry::global(cx);
|
||||||
|
let mut subscriptions = vec![];
|
||||||
|
|
||||||
|
subscriptions.push(cx.subscribe_in(
|
||||||
|
®istry,
|
||||||
|
window,
|
||||||
|
|this, _registry, event, window, cx| match event {
|
||||||
|
GroupsEvent::Created { key, .. } => {
|
||||||
|
this.creating = false;
|
||||||
|
this.open_group(key.clone(), window, cx);
|
||||||
|
}
|
||||||
|
GroupsEvent::Error(error) if this.creating => {
|
||||||
|
this.creating = false;
|
||||||
|
this.set_error(friendly_error(error), cx);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
// Clear stale errors as the user edits the failing fields.
|
||||||
|
for input in [&name_input, &custom_relay_input] {
|
||||||
|
subscriptions.push(cx.subscribe_in(
|
||||||
|
input,
|
||||||
|
window,
|
||||||
|
|this, _input, event, _window, cx| {
|
||||||
|
if let InputEvent::Change = event {
|
||||||
|
this.clear_error(cx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
dock,
|
||||||
|
parent,
|
||||||
|
name_input,
|
||||||
|
about_input,
|
||||||
|
custom_relay_input,
|
||||||
|
relays,
|
||||||
|
selected_relay,
|
||||||
|
relay_select_open: false,
|
||||||
|
relay_options_focus,
|
||||||
|
list_private: false,
|
||||||
|
picture: None,
|
||||||
|
uploading: false,
|
||||||
|
private: false,
|
||||||
|
closed: false,
|
||||||
|
restricted: false,
|
||||||
|
hidden: false,
|
||||||
|
creating: false,
|
||||||
|
error: None,
|
||||||
|
tasks: vec![],
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The relay the group will be created on.
|
||||||
|
fn effective_relay(&self, cx: &App) -> Result<RelayUrl, SharedString> {
|
||||||
|
let Some(selected) = &self.selected_relay else {
|
||||||
|
let raw = self.custom_relay_input.read(cx).value().trim().to_owned();
|
||||||
|
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err("Enter a relay to create the group on.".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = if raw.starts_with("wss://") || raw.starts_with("ws://") {
|
||||||
|
raw
|
||||||
|
} else {
|
||||||
|
format!("wss://{raw}")
|
||||||
|
};
|
||||||
|
|
||||||
|
return RelayUrl::parse(&url).map_err(|_| {
|
||||||
|
SharedString::from("Enter a valid relay URL (e.g. relay.example.com).")
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(selected.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.creating || self.uploading {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let registry = GroupsRegistry::global(cx);
|
||||||
|
let name = self.name_input.read(cx).value().trim().to_owned();
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
self.set_error("Group name is required.", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let relay = match &self.parent {
|
||||||
|
Some(parent) => parent.relay().clone(),
|
||||||
|
None => match self.effective_relay(cx) {
|
||||||
|
Ok(relay) => relay,
|
||||||
|
Err(error) => {
|
||||||
|
self.set_error(error, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let about = self.about_input.read(cx).value().trim().to_owned();
|
||||||
|
|
||||||
|
let options = GroupCreateOptions {
|
||||||
|
name,
|
||||||
|
about: (!about.is_empty()).then_some(about),
|
||||||
|
picture: self.picture.as_ref().map(|picture| picture.to_string()),
|
||||||
|
private: self.private,
|
||||||
|
closed: self.closed,
|
||||||
|
restricted: self.restricted,
|
||||||
|
hidden: self.hidden,
|
||||||
|
list_private: self.list_private,
|
||||||
|
custom_id: None,
|
||||||
|
parent: self.parent.as_ref().map(|parent| parent.id().clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.creating = true;
|
||||||
|
self.error = None;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
registry.update(cx, |registry, cx| {
|
||||||
|
registry.create(relay, options, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_group(&self, key: GroupKey, window: &mut Window, cx: &mut App) {
|
||||||
|
let registry = GroupsRegistry::global(cx);
|
||||||
|
let group = registry.update(cx, |registry, cx| registry.open(key, cx));
|
||||||
|
|
||||||
|
ui::dock::add_panel_to(
|
||||||
|
&self.dock,
|
||||||
|
PanelHandle::new(nip29_ui::init(group, window, cx)),
|
||||||
|
DockPlacement::Center,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
|
||||||
|
window.close_dialog(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_relay(&mut self, relay: Option<RelayUrl>, cx: &mut Context<Self>) {
|
||||||
|
self.selected_relay = relay;
|
||||||
|
self.error = None;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_relay_select_open(&mut self, open: bool, cx: &mut Context<Self>) {
|
||||||
|
self.relay_select_open = open;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate_relay_option(&mut self, index: usize, cx: &mut Context<Self>) {
|
||||||
|
if index < self.relays.len() {
|
||||||
|
self.select_relay(Some(self.relays[index].clone()), cx);
|
||||||
|
} else {
|
||||||
|
self.select_relay(None, cx);
|
||||||
|
}
|
||||||
|
self.set_relay_select_open(false, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
||||||
|
self.uploading = uploading;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
|
||||||
|
let path = cx.prompt_for_paths(PathPromptOptions {
|
||||||
|
files: true,
|
||||||
|
directories: false,
|
||||||
|
multiple: false,
|
||||||
|
prompt: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
// Selecting no file means the prompt was cancelled
|
||||||
|
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_uploading(true, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match upload(server, path, cx).await {
|
||||||
|
Ok(url) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.picture = Some(url.to_string().into());
|
||||||
|
this.set_uploading(false, cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_uploading(false, cx);
|
||||||
|
this.set_error(friendly_error(&error.to_string()), cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||||
|
self.error = Some(message.into());
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_error(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.error.take().is_some() {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_relay(&self, selected: &str, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
if let Some(parent) = &self.parent {
|
||||||
|
return v_flex().gap_1p5().child(field_label("Relay", cx)).child(
|
||||||
|
div()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(format!(
|
||||||
|
"Created on {} (same relay as the parent group).",
|
||||||
|
relay_label(parent.relay())
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let this = cx.entity().downgrade();
|
||||||
|
let open = self.relay_select_open;
|
||||||
|
|
||||||
|
let trigger = h_flex()
|
||||||
|
.id("relay-trigger")
|
||||||
|
.justify_between()
|
||||||
|
.w_full()
|
||||||
|
.h_8()
|
||||||
|
.px_3()
|
||||||
|
.rounded(px(button_radius()))
|
||||||
|
.text_style(TextStyle::Callout)
|
||||||
|
.cursor_default()
|
||||||
|
.bg(if open {
|
||||||
|
cx.theme().element_active
|
||||||
|
} else {
|
||||||
|
cx.theme().surface_raised
|
||||||
|
})
|
||||||
|
.when(!open, |this| {
|
||||||
|
this.hover(|this| this.bg(cx.theme().element_hover))
|
||||||
|
.active(|this| this.bg(cx.theme().element_active))
|
||||||
|
})
|
||||||
|
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||||
|
this.relay_select_open = !open;
|
||||||
|
cx.notify();
|
||||||
|
}))
|
||||||
|
.child(selected.to_owned())
|
||||||
|
.child(
|
||||||
|
Icon::new(IconName::ChevronDown)
|
||||||
|
.small()
|
||||||
|
.text_color(cx.theme().text_muted),
|
||||||
|
);
|
||||||
|
|
||||||
|
let select = Select::new("relay-select")
|
||||||
|
.open(open)
|
||||||
|
.on_open_change({
|
||||||
|
let this = this.clone();
|
||||||
|
move |open, _, cx| {
|
||||||
|
let _ = this.update(cx, |this, cx| {
|
||||||
|
this.set_relay_select_open(open, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.content_focus_handle(&self.relay_options_focus)
|
||||||
|
.accessibility_label("Relay")
|
||||||
|
.accessibility_value(selected.to_owned())
|
||||||
|
.w_full()
|
||||||
|
.child(trigger);
|
||||||
|
|
||||||
|
let options = v_flex()
|
||||||
|
.id("relay-options")
|
||||||
|
.occlude()
|
||||||
|
.track_focus(&self.relay_options_focus)
|
||||||
|
.key_context("Select")
|
||||||
|
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
|
||||||
|
this.set_relay_select_open(false, cx);
|
||||||
|
}))
|
||||||
|
.popover_style(cx)
|
||||||
|
.p_1()
|
||||||
|
.gap_y_1()
|
||||||
|
.min_w(rems(8.))
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
.children(self.relays.iter().enumerate().map(|(index, relay)| {
|
||||||
|
h_flex()
|
||||||
|
.id(SharedString::from(format!("option-{relay}")))
|
||||||
|
.px_1p5()
|
||||||
|
.py_1()
|
||||||
|
.rounded(px(button_radius()))
|
||||||
|
.text_style(TextStyle::Callout)
|
||||||
|
.cursor_default()
|
||||||
|
.hover(|this| this.bg(cx.theme().element_hover))
|
||||||
|
.child(relay_label(relay))
|
||||||
|
.on_click(cx.listener(move |this, _, _, cx| {
|
||||||
|
this.activate_relay_option(index, cx);
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
.child(divider(cx))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.id("relay-add")
|
||||||
|
.px_1p5()
|
||||||
|
.py_1()
|
||||||
|
.rounded(px(button_radius()))
|
||||||
|
.text_style(TextStyle::Callout)
|
||||||
|
.cursor_default()
|
||||||
|
.child("Custom relay")
|
||||||
|
.hover(|this| this.bg(cx.theme().element_hover))
|
||||||
|
.on_click(cx.listener(|this, _, _, cx| {
|
||||||
|
this.activate_relay_option(this.relays.len(), cx);
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(field_label("Relay", cx))
|
||||||
|
.when(self.selected_relay.is_none(), |this| {
|
||||||
|
this.child(Input::new(&self.custom_relay_input))
|
||||||
|
})
|
||||||
|
.child(Popup::new("relay-popup", select).when(open, |this| this.content(options)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_access(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let private = Switch::new("private")
|
||||||
|
.label("Private")
|
||||||
|
.description(PRIVATE_DESC)
|
||||||
|
.checked(self.private)
|
||||||
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
|
this.private = *checked;
|
||||||
|
cx.notify();
|
||||||
|
}));
|
||||||
|
|
||||||
|
let closed = Switch::new("closed")
|
||||||
|
.label("Closed")
|
||||||
|
.description(CLOSED_DESC)
|
||||||
|
.checked(self.closed)
|
||||||
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
|
this.closed = *checked;
|
||||||
|
cx.notify();
|
||||||
|
}));
|
||||||
|
|
||||||
|
let restricted = Switch::new("restricted")
|
||||||
|
.label("Restricted")
|
||||||
|
.description(RESTRICTED_DESC)
|
||||||
|
.checked(self.restricted)
|
||||||
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
|
this.restricted = *checked;
|
||||||
|
cx.notify();
|
||||||
|
}));
|
||||||
|
|
||||||
|
let hidden = Switch::new("hidden")
|
||||||
|
.label("Hidden")
|
||||||
|
.description(HIDDEN_DESC)
|
||||||
|
.checked(self.hidden)
|
||||||
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
|
this.hidden = *checked;
|
||||||
|
cx.notify();
|
||||||
|
}));
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(field_label("Access settings", cx))
|
||||||
|
.child(switch_row(private, cx))
|
||||||
|
.child(switch_row(closed, cx))
|
||||||
|
.child(switch_row(restricted, cx))
|
||||||
|
.child(switch_row(hidden, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_list_setting(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let list_private = Switch::new("list-private")
|
||||||
|
.label("Private on my list")
|
||||||
|
.description(LIST_PRIVATE_DESC)
|
||||||
|
.checked(self.list_private)
|
||||||
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
|
this.list_private = *checked;
|
||||||
|
cx.notify();
|
||||||
|
}));
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(field_label("List", cx))
|
||||||
|
.child(switch_row(list_private, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_error(
|
||||||
|
&self,
|
||||||
|
error: &str,
|
||||||
|
web_url: Option<&str>,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.items_start()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.text_color(cx.theme().danger)
|
||||||
|
.child(error.to_owned()),
|
||||||
|
)
|
||||||
|
.when(shows_relay_website(error), |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.id("relay-website")
|
||||||
|
.cursor_pointer()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.underline()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.text_color(cx.theme().danger)
|
||||||
|
.child("Open relay website")
|
||||||
|
.on_click({
|
||||||
|
let web_url = web_url.map(str::to_owned);
|
||||||
|
|
||||||
|
move |_, _window, cx| {
|
||||||
|
if let Some(url) = &web_url {
|
||||||
|
cx.open_url(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for NewGroup {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let name = self.name_input.read(cx).value().trim().to_owned();
|
||||||
|
|
||||||
|
let seed: String = if name.is_empty() {
|
||||||
|
"group".to_owned()
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
|
||||||
|
let selected = self
|
||||||
|
.selected_relay
|
||||||
|
.as_ref()
|
||||||
|
.map(relay_label)
|
||||||
|
.unwrap_or_else(|| "Add relay".into());
|
||||||
|
|
||||||
|
let web_url = self
|
||||||
|
.effective_relay(cx)
|
||||||
|
.ok()
|
||||||
|
.map(|relay| relay_web_url(&relay));
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_4()
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.id("group-dialog-content")
|
||||||
|
.h(px(500.))
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.child(field("Name", &name, cx))
|
.when_some(self.error.clone(), |this, error| {
|
||||||
.child(field("Group id", &id, cx))
|
this.child(self.render_error(&error, web_url.as_deref(), cx))
|
||||||
.child(field("Relay", &relay, cx)),
|
})
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_3()
|
||||||
|
.child(Avatar::new(self.picture.clone()).seed(seed).large())
|
||||||
|
.child(
|
||||||
|
Button::new("upload-picture")
|
||||||
|
.label(if self.picture.is_some() {
|
||||||
|
"Change picture"
|
||||||
|
} else {
|
||||||
|
"Add a picture"
|
||||||
|
})
|
||||||
|
.xsmall()
|
||||||
|
.ghost()
|
||||||
|
.disabled(self.uploading)
|
||||||
|
.loading(self.uploading)
|
||||||
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
|
this.upload(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(field("Name", Input::new(&self.name_input), cx))
|
||||||
|
.child(self.render_relay(&selected, cx))
|
||||||
|
.child(field("About", Textarea::new(&self.about_input), cx))
|
||||||
|
.child(self.render_access(cx))
|
||||||
|
.child(self.render_list_setting(cx))
|
||||||
|
.overflow_y_scroll(),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -47,95 +629,89 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("create")
|
Button::new("create")
|
||||||
.icon(IconName::ArrowRight)
|
.label(if self.parent.is_some() {
|
||||||
.icon_position(IconPosition::End)
|
"Create Channel"
|
||||||
.label("Create")
|
} else {
|
||||||
|
"Create Group"
|
||||||
|
})
|
||||||
.primary()
|
.primary()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.on_click({
|
.loading(self.creating)
|
||||||
let submit = submit.clone();
|
.disabled(self.creating || self.uploading)
|
||||||
move |event, window, cx| {
|
.on_click(cx.listener(|this, _event, _window, cx| this.create(cx))),
|
||||||
if submit(event, window, cx) {
|
|
||||||
window.close_dialog(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
fn field(text: &'static str, input: impl IntoElement, cx: &App) -> Div {
|
||||||
v_flex()
|
v_flex().gap_1p5().child(field_label(text, cx)).child(input)
|
||||||
.gap_1()
|
}
|
||||||
.text_style(TextStyle::Callout)
|
|
||||||
|
fn field_label(text: &'static str, cx: &App) -> Div {
|
||||||
|
div()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(label)
|
.font_semibold()
|
||||||
.child(Input::new(input))
|
.child(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create(
|
fn switch_row(switch: impl IntoElement, cx: &App) -> Div {
|
||||||
name: &Entity<InputState>,
|
div()
|
||||||
id: &Entity<InputState>,
|
.w_full()
|
||||||
relay: &Entity<InputState>,
|
.p_2()
|
||||||
window: &mut Window,
|
.rounded(px(control_radius()))
|
||||||
cx: &mut App,
|
.bg(cx.theme().surface_raised)
|
||||||
) -> bool {
|
.child(switch)
|
||||||
let name_value = name.read(cx).value().trim().to_owned();
|
}
|
||||||
let raw_id = id.read(cx).value().trim().to_owned();
|
|
||||||
let raw_relay = relay.read(cx).value().trim().to_owned();
|
|
||||||
|
|
||||||
if name_value.is_empty() {
|
fn relay_label(relay: &RelayUrl) -> SharedString {
|
||||||
window.push_notification(Notification::error("Enter a group name"), cx);
|
relay
|
||||||
return false;
|
.as_str()
|
||||||
}
|
.trim_start_matches("wss://")
|
||||||
|
.trim_start_matches("ws://")
|
||||||
|
.to_owned()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
let id = if raw_id.is_empty() {
|
fn relay_web_url(relay: &RelayUrl) -> String {
|
||||||
slug(&name_value)
|
relay
|
||||||
|
.as_str()
|
||||||
|
.replacen("wss://", "https://", 1)
|
||||||
|
.replacen("ws://", "http://", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the relay's website likely resolves the rejection.
|
||||||
|
fn shows_relay_website(error: &str) -> bool {
|
||||||
|
let error = error.to_lowercase();
|
||||||
|
[
|
||||||
|
"website",
|
||||||
|
"not allowed",
|
||||||
|
"restricted",
|
||||||
|
"authorization",
|
||||||
|
"auth-required",
|
||||||
|
"blocked",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|hint| error.contains(hint))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map raw relay rejection reasons to user-facing copy (nostrord parity).
|
||||||
|
fn friendly_error(raw: &str) -> SharedString {
|
||||||
|
let lowered = raw.to_lowercase();
|
||||||
|
|
||||||
|
if lowered.contains("blocked:") {
|
||||||
|
"Group creation on this relay must be done via the relay's website.".into()
|
||||||
|
} else if lowered.contains("auth-required")
|
||||||
|
|| lowered.contains("not allowed")
|
||||||
|
|| lowered.contains("restricted")
|
||||||
|
{
|
||||||
|
"This relay requires authorization to create groups.".into()
|
||||||
|
} else if lowered.contains("did not respond") || lowered.contains("timeout") {
|
||||||
|
"Relay did not respond. Try again.".into()
|
||||||
|
} else if lowered.contains("not connected") {
|
||||||
|
"Not connected to relay. Try again.".into()
|
||||||
} else {
|
} else {
|
||||||
raw_id
|
raw.into()
|
||||||
};
|
|
||||||
|
|
||||||
let id = match GroupId::new(id) {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(error) => {
|
|
||||||
window.push_notification(Notification::error(error.to_string()), cx);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let relay = match RelayUrl::parse(&raw_relay) {
|
|
||||||
Ok(relay) => relay,
|
|
||||||
Err(error) => {
|
|
||||||
window.push_notification(Notification::error(error.to_string()), cx);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut metadata = GroupMetadata::default();
|
|
||||||
metadata.name = Some(name_value);
|
|
||||||
|
|
||||||
let registry = GroupsRegistry::global(cx);
|
|
||||||
registry.update(cx, |registry, cx| {
|
|
||||||
registry.create(GroupKey::new(relay, id), metadata, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn slug(name: &str) -> String {
|
|
||||||
let mut slug = String::with_capacity(name.len());
|
|
||||||
let mut separator = false;
|
|
||||||
|
|
||||||
for character in name.chars() {
|
|
||||||
if character.is_ascii_alphanumeric() {
|
|
||||||
slug.push(character.to_ascii_lowercase());
|
|
||||||
separator = false;
|
|
||||||
} else if !separator && !slug.is_empty() {
|
|
||||||
slug.push('-');
|
|
||||||
separator = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
slug.trim_end_matches('-').to_owned()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry, shorten_pubkey};
|
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use state::{NostrRegistry, upload};
|
use state::{NostrRegistry, upload};
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
||||||
@@ -34,6 +34,8 @@ struct ProfileDialog {
|
|||||||
public_key: PublicKey,
|
public_key: PublicKey,
|
||||||
/// Whether the profile belongs to the signed-in user
|
/// Whether the profile belongs to the signed-in user
|
||||||
is_current_user: bool,
|
is_current_user: bool,
|
||||||
|
/// Person directory for the profile
|
||||||
|
directory: Entity<Directory>,
|
||||||
/// Whether the metadata inputs are shown
|
/// Whether the metadata inputs are shown
|
||||||
editing: bool,
|
editing: bool,
|
||||||
/// User's name text input
|
/// User's name text input
|
||||||
@@ -74,9 +76,15 @@ impl ProfileDialog {
|
|||||||
.placeholder("A short introduce about you.")
|
.placeholder("A short introduce about you.")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let directory = Directory::new(cx);
|
||||||
|
directory.update(cx, |directory, cx| {
|
||||||
|
directory.track([public_key], cx);
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
public_key,
|
public_key,
|
||||||
is_current_user,
|
is_current_user,
|
||||||
|
directory,
|
||||||
editing: false,
|
editing: false,
|
||||||
name_input,
|
name_input,
|
||||||
avatar_input,
|
avatar_input,
|
||||||
@@ -90,8 +98,7 @@ impl ProfileDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn profile(&self, cx: &App) -> Person {
|
fn profile(&self, cx: &App) -> Person {
|
||||||
let persons = PersonRegistry::global(cx);
|
self.directory.read(cx).person(&self.public_key, cx)
|
||||||
persons.read(cx).get(&self.public_key, cx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn set_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -238,9 +245,8 @@ impl ProfileDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn save(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn save(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let public_key = self.public_key;
|
let public_key = self.public_key;
|
||||||
let old_metadata = persons.read(cx).get(&public_key, cx).metadata();
|
let old_metadata = self.directory.read(cx).person(&public_key, cx).metadata();
|
||||||
|
|
||||||
// Extract all new metadata fields
|
// Extract all new metadata fields
|
||||||
let avatar = self.avatar_input.read(cx).value();
|
let avatar = self.avatar_input.read(cx).value();
|
||||||
@@ -274,9 +280,9 @@ impl ProfileDialog {
|
|||||||
match task.await {
|
match task.await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
// Update the registry
|
// Publish the new metadata to every directory
|
||||||
persons.update(cx, |this, cx| {
|
PersonRegistry::global(cx).update(cx, |registry, cx| {
|
||||||
this.insert(Person::new(public_key, new_metadata), cx);
|
registry.insert(Person::new(public_key, new_metadata), cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.set_updating(false, cx);
|
this.set_updating(false, cx);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry, shorten_pubkey};
|
use person::{Directory, Person, shorten_pubkey};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
|
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
||||||
@@ -33,6 +33,8 @@ pub struct Screening {
|
|||||||
last_active: Option<Timestamp>,
|
last_active: Option<Timestamp>,
|
||||||
/// All mutual contacts of the person being screened.
|
/// All mutual contacts of the person being screened.
|
||||||
mutual_contacts: Vec<PublicKey>,
|
mutual_contacts: Vec<PublicKey>,
|
||||||
|
/// Person directory for the person and their mutual contacts
|
||||||
|
directory: Entity<Directory>,
|
||||||
tasks: SmallVec<[Task<()>; 3]>,
|
tasks: SmallVec<[Task<()>; 3]>,
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
}
|
}
|
||||||
@@ -53,12 +55,16 @@ impl Screening {
|
|||||||
this.verify_identifier(cx);
|
this.verify_identifier(cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let directory = Directory::new(cx);
|
||||||
|
directory.update(cx, |directory, cx| directory.track([public_key], cx));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
public_key,
|
public_key,
|
||||||
verified: false,
|
verified: false,
|
||||||
followed: false,
|
followed: false,
|
||||||
last_active: None,
|
last_active: None,
|
||||||
mutual_contacts: vec![],
|
mutual_contacts: vec![],
|
||||||
|
directory,
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -208,8 +214,7 @@ impl Screening {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn profile(&self, cx: &Context<Self>) -> Person {
|
fn profile(&self, cx: &Context<Self>) -> Person {
|
||||||
let persons = PersonRegistry::global(cx);
|
self.directory.read(cx).person(&self.public_key, cx)
|
||||||
persons.read(cx).get(&self.public_key, cx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
|
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
|
||||||
@@ -260,22 +265,23 @@ impl Screening {
|
|||||||
|
|
||||||
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let contacts = self.mutual_contacts.clone();
|
let contacts = self.mutual_contacts.clone();
|
||||||
|
let directory = self.directory.clone();
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, _cx| {
|
window.open_dialog(cx, move |this, _window, _cx| {
|
||||||
let contacts = contacts.clone();
|
let contacts = contacts.clone();
|
||||||
|
let directory = directory.clone();
|
||||||
let total = contacts.len();
|
let total = contacts.len();
|
||||||
|
|
||||||
this.title("Mutual contacts").child(
|
this.title("Mutual contacts").child(
|
||||||
v_flex().gap_1().pb_2().child(
|
v_flex().gap_1().pb_2().child(
|
||||||
uniform_list("contacts", total, move |range, _window, cx| {
|
uniform_list("contacts", total, move |range, _window, cx| {
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let mut items = Vec::with_capacity(total);
|
let mut items = Vec::with_capacity(total);
|
||||||
|
|
||||||
for ix in range {
|
for ix in range {
|
||||||
let Some(contact) = contacts.get(ix) else {
|
let Some(contact) = contacts.get(ix) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let profile = persons.read(cx).get(contact, cx);
|
let profile = directory.read(cx).person(contact, cx);
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use gpui::http_client::Url;
|
use gpui::http_client::Url;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render,
|
||||||
Window, div,
|
SharedString, Styled, Window, div,
|
||||||
};
|
};
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use theme::{ActiveTheme, AppExt as _, AppearanceMode, TextStyle, Typeset as _};
|
use theme::{ActiveTheme, AppExt as _, AppearanceMode, TextStyle, Typeset as _};
|
||||||
@@ -11,7 +11,7 @@ use ui::input::{Input, InputState};
|
|||||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::switch::Switch;
|
use ui::switch::Switch;
|
||||||
use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex};
|
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
||||||
cx.new(|cx| Preferences::new(window, cx))
|
cx.new(|cx| Preferences::new(window, cx))
|
||||||
@@ -52,6 +52,32 @@ impl Preferences {
|
|||||||
AppSettings::update_appearance_mode(mode, cx);
|
AppSettings::update_appearance_mode(mode, cx);
|
||||||
cx.set_appearance_mode(mode);
|
cx.set_appearance_mode(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the user for an image to show behind chat and group panels
|
||||||
|
fn choose_background(cx: &mut App) {
|
||||||
|
let task = cx.prompt_for_paths(PathPromptOptions {
|
||||||
|
files: true,
|
||||||
|
directories: false,
|
||||||
|
multiple: false,
|
||||||
|
prompt: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
let Ok(Ok(Some(mut paths))) = task.await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(path) = paths.pop() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
AppSettings::update_panel_background_path(Some(path), cx);
|
||||||
|
cx.refresh_windows();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Preferences {
|
impl Render for Preferences {
|
||||||
@@ -60,11 +86,19 @@ impl Render for Preferences {
|
|||||||
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
||||||
const MODE: &str = "Follow the OS, or always use light or dark.";
|
const MODE: &str = "Follow the OS, or always use light or dark.";
|
||||||
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
||||||
|
const BACKGROUND: &str = "Show an image behind chat and group panels.";
|
||||||
|
|
||||||
let screening = AppSettings::get_screening(cx);
|
let screening = AppSettings::get_screening(cx);
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
let nip4e = AppSettings::get_nip4e(cx);
|
let nip4e = AppSettings::get_nip4e(cx);
|
||||||
let appearance_mode = AppSettings::get_appearance_mode(cx);
|
let appearance_mode = AppSettings::get_appearance_mode(cx);
|
||||||
|
let panel_background = AppSettings::get_panel_background(cx);
|
||||||
|
let panel_background_path = AppSettings::get_panel_background_path(cx);
|
||||||
|
|
||||||
|
let background_label = match &panel_background_path {
|
||||||
|
Some(path) => path.display().to_string(),
|
||||||
|
None => String::from("Default Background"),
|
||||||
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_4()
|
.gap_4()
|
||||||
@@ -132,6 +166,73 @@ impl Render for Preferences {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
GroupBox::new()
|
||||||
|
.id("background")
|
||||||
|
.title("Background")
|
||||||
|
.fill()
|
||||||
|
.child(
|
||||||
|
Switch::new("panel-background")
|
||||||
|
.label("Panel background")
|
||||||
|
.description(BACKGROUND)
|
||||||
|
.checked(panel_background)
|
||||||
|
.on_click(move |_, _window, cx| {
|
||||||
|
AppSettings::update_panel_background(!panel_background, cx);
|
||||||
|
cx.refresh_windows();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_3()
|
||||||
|
.justify_between()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_style(TextStyle::Callout)
|
||||||
|
.child(SharedString::from("Image")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.truncate()
|
||||||
|
.child(SharedString::from(background_label)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
Button::new("choose-background")
|
||||||
|
.label("Choose image")
|
||||||
|
.ghost_alt()
|
||||||
|
.small()
|
||||||
|
.disabled(!panel_background)
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
Self::choose_background(cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("reset-background")
|
||||||
|
.label("Reset")
|
||||||
|
.ghost_alt()
|
||||||
|
.small()
|
||||||
|
.disabled(
|
||||||
|
!panel_background
|
||||||
|
|| panel_background_path.is_none(),
|
||||||
|
)
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
AppSettings::update_panel_background_path(None, cx);
|
||||||
|
cx.refresh_windows();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
GroupBox::new()
|
GroupBox::new()
|
||||||
.id("experiments")
|
.id("experiments")
|
||||||
|
|||||||
+49
-30
@@ -14,14 +14,14 @@ use nostr_sdk::prelude::*;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{NostrRegistry, StateEvent};
|
use state::{NostrRegistry, StateEvent};
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _, SIDEBAR_WIDTH, Theme};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, TextStyle, Theme, Typeset as _};
|
||||||
use ui::button::Button;
|
use ui::button::Button;
|
||||||
use ui::dock::{self, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
use ui::dock::{self, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||||
use ui::notification::{Notification, NotificationKind};
|
use ui::notification::{Notification, NotificationKind};
|
||||||
use ui::{Root, WindowExtension, v_flex};
|
use ui::{Root, WindowExtension, v_flex};
|
||||||
|
|
||||||
use crate::dialogs::restore::RestoreEncryption;
|
use crate::dialogs::restore::RestoreEncryption;
|
||||||
use crate::dialogs::{join_group, new_chat, new_group, profile, relays, settings};
|
use crate::dialogs::{import, join_group, new_chat, new_group, profile, relays, settings};
|
||||||
use crate::panels::{backup, browse, contact_list, greeter, inbox};
|
use crate::panels::{backup, browse, contact_list, greeter, inbox};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@ impl Workspace {
|
|||||||
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||||
let left_sidebar = sidebar.clone();
|
let left_sidebar = sidebar.clone();
|
||||||
|
|
||||||
|
let workspace = cx.entity().downgrade();
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
@@ -87,10 +88,10 @@ impl Workspace {
|
|||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe to the nostr events
|
// Subscribe to the nostr events
|
||||||
cx.subscribe_in(&nostr, window, move |_this, _state, event, window, cx| {
|
cx.subscribe_in(&nostr, window, move |_, _, event, window, cx| match event {
|
||||||
if let StateEvent::SignerChanged = event {
|
StateEvent::SignerChanged => window.close_all_dialogs(cx),
|
||||||
window.close_all_dialogs(cx);
|
StateEvent::PassphraseRequired => import::open_unlock(window, cx),
|
||||||
}
|
_ => {}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -183,6 +184,27 @@ impl Workspace {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
App::on_action(cx, {
|
||||||
|
let workspace = workspace.clone();
|
||||||
|
move |action, cx| {
|
||||||
|
let Some(workspace) = workspace.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
workspace.update(cx, |this, cx| {
|
||||||
|
this.show_group_meta(action, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
App::on_action(cx, move |action, cx| {
|
||||||
|
let Some(workspace) = workspace.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
workspace.update(cx, |this, cx| {
|
||||||
|
this.hide_group_meta(action, cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
cx.defer_in(window, move |this, window, cx| {
|
cx.defer_in(window, move |this, window, cx| {
|
||||||
let sidebar = PanelHandle::new(left_sidebar);
|
let sidebar = PanelHandle::new(left_sidebar);
|
||||||
let greeter = PanelHandle::new(greeter::init(window, cx));
|
let greeter = PanelHandle::new(greeter::init(window, cx));
|
||||||
@@ -198,29 +220,6 @@ impl Workspace {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen app-wide, so a group panel can show or hide the sidebar without holding it.
|
|
||||||
let workspace = cx.entity().downgrade();
|
|
||||||
|
|
||||||
App::on_action(cx, {
|
|
||||||
let workspace = workspace.clone();
|
|
||||||
|
|
||||||
move |action: &nip29_ui::ShowGroupMeta, cx| {
|
|
||||||
let Some(workspace) = workspace.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
workspace.update(cx, |this, cx| this.show_group_meta(action, cx));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
App::on_action(cx, move |action: &nip29_ui::HideGroupMeta, cx| {
|
|
||||||
let Some(workspace) = workspace.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
workspace.update(cx, |this, cx| this.hide_group_meta(action, cx));
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
dock,
|
dock,
|
||||||
sidebar,
|
sidebar,
|
||||||
@@ -298,7 +297,7 @@ impl Workspace {
|
|||||||
new_chat::open(self.dock.downgrade(), window, cx);
|
new_chat::open(self.dock.downgrade(), window, cx);
|
||||||
}
|
}
|
||||||
Command::NewGroup => {
|
Command::NewGroup => {
|
||||||
new_group::open(window, cx);
|
new_group::open(self.dock.downgrade(), window, cx);
|
||||||
}
|
}
|
||||||
Command::JoinGroup => {
|
Command::JoinGroup => {
|
||||||
join_group::open(window, cx);
|
join_group::open(window, cx);
|
||||||
@@ -370,6 +369,25 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the channel creation dialog under the action's group.
|
||||||
|
fn on_create_channel(
|
||||||
|
&mut self,
|
||||||
|
action: &nip29_ui::CreateChannel,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let Ok(id) = GroupId::new(action.parent.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
new_group::open_channel(
|
||||||
|
self.dock.downgrade(),
|
||||||
|
GroupKey::new(action.relay.clone(), id),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Show the sidebar's information for the group a panel belongs to.
|
/// Show the sidebar's information for the group a panel belongs to.
|
||||||
fn show_group_meta(&mut self, action: &nip29_ui::ShowGroupMeta, cx: &mut Context<Self>) {
|
fn show_group_meta(&mut self, action: &nip29_ui::ShowGroupMeta, cx: &mut Context<Self>) {
|
||||||
let Ok(id) = GroupId::new(action.group.clone()) else {
|
let Ok(id) = GroupId::new(action.group.clone()) else {
|
||||||
@@ -453,6 +471,7 @@ impl Render for Workspace {
|
|||||||
div()
|
div()
|
||||||
.id("workspace")
|
.id("workspace")
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
|
.on_action(cx.listener(Self::on_create_channel))
|
||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(self.dock.clone())
|
.child(self.dock.clone())
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::Directory;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
|
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
|
||||||
@@ -41,6 +41,9 @@ pub struct ContactListPanel {
|
|||||||
/// All contacts
|
/// All contacts
|
||||||
contacts: HashSet<PublicKey>,
|
contacts: HashSet<PublicKey>,
|
||||||
|
|
||||||
|
/// Person directory for the contacts
|
||||||
|
directory: Entity<Directory>,
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
@@ -73,6 +76,7 @@ impl ContactListPanel {
|
|||||||
input,
|
input,
|
||||||
updating: false,
|
updating: false,
|
||||||
contacts: HashSet::new(),
|
contacts: HashSet::new(),
|
||||||
|
directory: Directory::new(cx),
|
||||||
error: None,
|
error: None,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
@@ -110,6 +114,9 @@ impl ContactListPanel {
|
|||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
this.directory.update(cx, |directory, cx| {
|
||||||
|
directory.track(public_keys.iter().copied(), cx)
|
||||||
|
});
|
||||||
this.contacts.extend(public_keys);
|
this.contacts.extend(public_keys);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
@@ -123,6 +130,8 @@ impl ContactListPanel {
|
|||||||
|
|
||||||
if let Ok(public_key) = PublicKey::parse(&value) {
|
if let Ok(public_key) = PublicKey::parse(&value) {
|
||||||
if self.contacts.insert(public_key) {
|
if self.contacts.insert(public_key) {
|
||||||
|
self.directory
|
||||||
|
.update(cx, |directory, cx| directory.track([public_key], cx));
|
||||||
self.input.update(cx, |this, cx| {
|
self.input.update(cx, |this, cx| {
|
||||||
this.set_value("", window, cx);
|
this.set_value("", window, cx);
|
||||||
});
|
});
|
||||||
@@ -218,11 +227,10 @@ impl ContactListPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
|
||||||
for (ix, public_key) in self.contacts.iter().enumerate() {
|
for (ix, public_key) in self.contacts.iter().enumerate() {
|
||||||
let profile = persons.read(cx).get(public_key, cx);
|
let profile = self.directory.read(cx).person(public_key, cx);
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use nip29::Group;
|
use nip29::Group;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||||
@@ -51,6 +50,19 @@ impl Sidebar {
|
|||||||
pub(super) fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
|
pub(super) fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
|
||||||
self.group = Some(group.downgrade());
|
self.group = Some(group.downgrade());
|
||||||
self.group_open = true;
|
self.group_open = true;
|
||||||
|
|
||||||
|
let group = group.read(cx);
|
||||||
|
let mut keys: Vec<PublicKey> = group.members().iter().copied().collect();
|
||||||
|
keys.extend(
|
||||||
|
group
|
||||||
|
.admins()
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|(public_key, _)| *public_key),
|
||||||
|
);
|
||||||
|
self.directory
|
||||||
|
.update(cx, |directory, cx| directory.track(keys, cx));
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,12 +265,17 @@ impl Sidebar {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let person = persons.read(cx).get(public_key, cx);
|
let person = self.directory.read(cx).person(public_key, cx);
|
||||||
|
let group = group.clone();
|
||||||
|
|
||||||
let public_key = *public_key;
|
let public_key = *public_key;
|
||||||
let member_is_admin = !roles.is_empty();
|
let member_is_admin = !roles.is_empty();
|
||||||
|
|
||||||
let group = group.clone();
|
let is_self = NostrRegistry::global(cx)
|
||||||
|
.read(cx)
|
||||||
|
.current_user()
|
||||||
|
.is_some_and(|me| me == public_key);
|
||||||
|
|
||||||
Nav::new(public_key.to_hex())
|
Nav::new(public_key.to_hex())
|
||||||
.label(person.name())
|
.label(person.name())
|
||||||
@@ -294,7 +311,7 @@ impl Sidebar {
|
|||||||
cx.write_to_clipboard(ClipboardItem::new_string(npub));
|
cx.write_to_clipboard(ClipboardItem::new_string(npub));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if !admin {
|
if !admin || is_self {
|
||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use gpui::{
|
|||||||
Stateful, Styled, div, px,
|
Stateful, Styled, div, px,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
|
||||||
use theme::TABBAR_HEIGHT;
|
use theme::TABBAR_HEIGHT;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -22,8 +21,7 @@ impl Sidebar {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Stateful<Div> {
|
) -> Stateful<Div> {
|
||||||
let updater = AutoUpdater::try_global(cx);
|
let updater = AutoUpdater::try_global(cx);
|
||||||
let persons = PersonRegistry::global(cx);
|
let profile = self.directory.read(cx).person(current_user, cx);
|
||||||
let profile = persons.read(cx).get(current_user, cx);
|
|
||||||
let avatar = profile.avatar();
|
let avatar = profile.avatar();
|
||||||
let avatar_seed = profile.avatar_seed();
|
let avatar_seed = profile.avatar_seed();
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ impl Sidebar {
|
|||||||
match task.await {
|
match task.await {
|
||||||
Ok(contacts) => {
|
Ok(contacts) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
this.directory.update(cx, |directory, cx| {
|
||||||
|
directory.track(contacts.iter().copied(), cx)
|
||||||
|
});
|
||||||
this.contacts = Some(contacts);
|
this.contacts = Some(contacts);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use gpui::{
|
|||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
|
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use person::Directory;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
||||||
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _};
|
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _};
|
||||||
@@ -61,6 +62,8 @@ pub struct Sidebar {
|
|||||||
contacts_open: bool,
|
contacts_open: bool,
|
||||||
/// The signed-in user's contacts, loaded when the contact picker opens.
|
/// The signed-in user's contacts, loaded when the contact picker opens.
|
||||||
contacts: Option<Vec<PublicKey>>,
|
contacts: Option<Vec<PublicKey>>,
|
||||||
|
/// Person directory for contacts, search hits, and group members
|
||||||
|
directory: Entity<Directory>,
|
||||||
/// Contacts selected in the contact picker.
|
/// Contacts selected in the contact picker.
|
||||||
selected_contacts: HashSet<PublicKey>,
|
selected_contacts: HashSet<PublicKey>,
|
||||||
/// Scroll state of the contact picker's list.
|
/// Scroll state of the contact picker's list.
|
||||||
@@ -127,21 +130,39 @@ impl Sidebar {
|
|||||||
GroupsEvent::Error(error) => {
|
GroupsEvent::Error(error) => {
|
||||||
window.push_notification(Notification::error(error.clone()), cx);
|
window.push_notification(Notification::error(error.clone()), cx);
|
||||||
}
|
}
|
||||||
|
GroupsEvent::Created {
|
||||||
|
warning: Some(warning),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
window.push_notification(Notification::warning(warning.clone()), cx);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
cx.subscribe_in(&nostr, window, |this, _, event, window, cx| {
|
cx.subscribe_in(
|
||||||
if let StateEvent::NoSigner = event {
|
&nostr,
|
||||||
this.banner = onboarding::pick_banner();
|
window,
|
||||||
if this.search_open {
|
|this, state, event, window, cx| match event {
|
||||||
this.toggle_search(window, cx);
|
StateEvent::NoSigner => {
|
||||||
|
this.banner = onboarding::pick_banner();
|
||||||
|
if this.search_open {
|
||||||
|
this.toggle_search(window, cx);
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
cx.notify();
|
StateEvent::SignerChanged => {
|
||||||
}
|
if let Some(current_user) = state.read(cx).current_user() {
|
||||||
}),
|
this.directory.update(cx, |directory, cx| {
|
||||||
|
directory.track([current_user], cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
subscriptions.push(cx.subscribe_in(
|
subscriptions.push(cx.subscribe_in(
|
||||||
@@ -190,6 +211,7 @@ impl Sidebar {
|
|||||||
chats_open: None,
|
chats_open: None,
|
||||||
contacts_open: false,
|
contacts_open: false,
|
||||||
contacts: None,
|
contacts: None,
|
||||||
|
directory: Directory::new(cx),
|
||||||
selected_contacts: HashSet::new(),
|
selected_contacts: HashSet::new(),
|
||||||
contacts_scroll: UniformListScrollHandle::new(),
|
contacts_scroll: UniformListScrollHandle::new(),
|
||||||
requests_open: false,
|
requests_open: false,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use nip29::Group;
|
use nip29::Group;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
@@ -196,8 +195,7 @@ impl Sidebar {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
SidebarRow::Contact { public_key } => {
|
SidebarRow::Contact { public_key } => {
|
||||||
let persons = PersonRegistry::global(cx);
|
let person = self.directory.read(cx).person(public_key, cx);
|
||||||
let person = persons.read(cx).get(public_key, cx);
|
|
||||||
let selected = self.selected_contacts.contains(public_key);
|
let selected = self.selected_contacts.contains(public_key);
|
||||||
let sidebar = cx.entity().downgrade();
|
let sidebar = cx.entity().downgrade();
|
||||||
let public_key = public_key.to_owned();
|
let public_key = public_key.to_owned();
|
||||||
@@ -224,8 +222,7 @@ impl Sidebar {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
SidebarRow::Profile { public_key } => {
|
SidebarRow::Profile { public_key } => {
|
||||||
let persons = PersonRegistry::global(cx);
|
let person = self.directory.read(cx).person(public_key, cx);
|
||||||
let person = persons.read(cx).get(public_key, cx);
|
|
||||||
let public_key = public_key.to_owned();
|
let public_key = public_key.to_owned();
|
||||||
|
|
||||||
Nav::new(index)
|
Nav::new(index)
|
||||||
@@ -330,7 +327,7 @@ impl Sidebar {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
SidebarRow::Message { hit } => {
|
SidebarRow::Message { hit } => {
|
||||||
let person = PersonRegistry::global(cx).read(cx).get(&hit.author, cx);
|
let person = self.directory.read(cx).person(&hit.author, cx);
|
||||||
let name = hit.room.read(cx).display_name(cx);
|
let name = hit.room.read(cx).display_name(cx);
|
||||||
let detail = format!("{} · {}", name, hit.created_at.to_ago());
|
let detail = format!("{} · {}", name, hit.created_at.to_ago());
|
||||||
let dock = self.dock.clone();
|
let dock = self.dock.clone();
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ impl Sidebar {
|
|||||||
.chain(groups.into_iter().map(SearchResult::Group))
|
.chain(groups.into_iter().map(SearchResult::Group))
|
||||||
.chain(messages.into_iter().map(SearchResult::Message))
|
.chain(messages.into_iter().map(SearchResult::Message))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let authors = self.results.iter().filter_map(|result| match result {
|
||||||
|
SearchResult::Message(hit) => Some(hit.author),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
self.directory
|
||||||
|
.update(cx, |directory, cx| directory.track(authors, cx));
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +141,7 @@ impl Sidebar {
|
|||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.global_finding = false;
|
this.global_finding = false;
|
||||||
this.global_results = Some(match result {
|
let keys: Vec<PublicKey> = match result {
|
||||||
Ok(profiles) => {
|
Ok(profiles) => {
|
||||||
// Relays may return the same profile more than once
|
// Relays may return the same profile more than once
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
@@ -146,7 +154,12 @@ impl Sidebar {
|
|||||||
log::debug!("Global search failed: {error}");
|
log::debug!("Global search failed: {error}");
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.directory.update(cx, |directory, cx| {
|
||||||
|
directory.track(keys.iter().copied(), cx)
|
||||||
});
|
});
|
||||||
|
this.global_results = Some(keys);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ product-name = "Coop"
|
|||||||
description = "Chat Freely, Stay Private on Nostr"
|
description = "Chat Freely, Stay Private on Nostr"
|
||||||
identifier = "su.reya.coop"
|
identifier = "su.reya.coop"
|
||||||
category = "SocialNetworking"
|
category = "SocialNetworking"
|
||||||
version = "1.0.2"
|
version = "2.0.0-alpha"
|
||||||
out-dir = "../dist"
|
out-dir = "../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
|
|||||||
+22
-2
@@ -1,7 +1,6 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Open items from the search and lazy-loading work. Each entry notes the known
|
Open items. Each entry notes the known tradeoff and where the fix would land.
|
||||||
tradeoff and where the fix would land.
|
|
||||||
|
|
||||||
## nip29
|
## nip29
|
||||||
|
|
||||||
@@ -17,3 +16,24 @@ tradeoff and where the fix would land.
|
|||||||
gossip relays, but the deeper fix is a per-relay scoped subscription using
|
gossip relays, but the deeper fix is a per-relay scoped subscription using
|
||||||
an `a` tag with `10009:<admin-pk>` coordinates. The exact coordinate format
|
an `a` tag with `10009:<admin-pk>` coordinates. The exact coordinate format
|
||||||
each relay accepts needs to be tested first.
|
each relay accepts needs to be tested first.
|
||||||
|
- [ ] **LiveKit AV.** The `/.well-known/nip29/livekit` probe gates the edit
|
||||||
|
toggle, but the token flow (`/.well-known/nip29/livekit/<id>` with NIP-98
|
||||||
|
kind 27235) and a `kind:39004` participant subscription are missing — both
|
||||||
|
only matter once an AV client is on the roadmap.
|
||||||
|
- [ ] **Threads pane.** Replies to kind 11 messages send and render
|
||||||
|
(composer target + reply-context line), but there is no dedicated threads
|
||||||
|
view like nostord's.
|
||||||
|
- [ ] **NIP-11 `nip29.subgroups` gating.** The subgroup UI (parent edit,
|
||||||
|
child reorder) is always shown; relays that don't support subgroups reject
|
||||||
|
the 9002. Reading the relay information document would gate it.
|
||||||
|
- [ ] **Invite sync window.** The moderation subscription replays the last
|
||||||
|
100 moderation events, so invites older than that window appear in the
|
||||||
|
list only once they enter the local envelope cache.
|
||||||
|
- [ ] **`previous` mismatches are advisory.** Incoming events whose
|
||||||
|
`previous` references weren't seen from the relay are logged
|
||||||
|
(`Group::absorb`) but still rendered: partial history makes dropping
|
||||||
|
false-positive-prone. Late-publication rejection remains relay-side, per
|
||||||
|
the spec.
|
||||||
|
- [ ] **Download stall watchdog.** The auto-updater's streamed download
|
||||||
|
(`auto_update::download`) relies on the HTTP client's defaults; a
|
||||||
|
mid-transfer stall hangs the progress loop instead of erroring out.
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.96"
|
channel = "1.98"
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
components = ["rustfmt", "clippy"]
|
components = ["rustfmt", "clippy"]
|
||||||
targets = [
|
targets = [
|
||||||
|
|||||||
Reference in New Issue
Block a user