feat: nip4e (#188)

* encryption keys

* .

* .

* move nip4e to device crate

* .

* .

* use i18n for device crate

* refactor

* refactor

* .

* add reset button

* send message with encryption keys

* clean up

* .

* choose signer

* fix

* update i18n

* fix sending
This commit is contained in:
reya
2025-10-26 18:10:40 +07:00
committed by GitHub
parent 83687e5448
commit 15bbe82a87
29 changed files with 1856 additions and 851 deletions

View File

@@ -64,7 +64,7 @@ pub trait RenderedTimestamp {
impl RenderedTimestamp for Timestamp {
fn to_human_time(&self) -> SharedString {
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return SharedString::from("9999"),
};
@@ -85,7 +85,7 @@ impl RenderedTimestamp for Timestamp {
}
fn to_ago(&self) -> SharedString {
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return SharedString::from("1m"),
};

View File

@@ -33,6 +33,7 @@ title_bar = { path = "../title_bar" }
theme = { path = "../theme" }
common = { path = "../common" }
states = { path = "../states" }
key_store = { path = "../key_store" }
registry = { path = "../registry" }
settings = { path = "../settings" }
auto_update = { path = "../auto_update" }

View File

@@ -1,8 +1,9 @@
use std::sync::Mutex;
use gpui::{actions, App, AppContext};
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_connect::prelude::*;
use registry::keystore::KeyItem;
use registry::Registry;
use states::app_state;
@@ -48,7 +49,7 @@ pub fn load_embedded_fonts(cx: &App) {
pub fn reset(cx: &mut App) {
let registry = Registry::global(cx);
let keystore = registry.read(cx).keystore();
let backend = KeyStore::global(cx).read(cx).backend();
cx.spawn(async move |cx| {
cx.background_spawn(async move {
@@ -57,12 +58,12 @@ pub fn reset(cx: &mut App) {
})
.await;
keystore
backend
.delete_credentials(&KeyItem::User.to_string(), cx)
.await
.ok();
keystore
backend
.delete_credentials(&KeyItem::Bunker.to_string(), cx)
.await
.ok();

View File

@@ -4,7 +4,7 @@ use std::sync::Arc;
use anyhow::{anyhow, Error};
use auto_update::AutoUpdater;
use common::display::RenderedProfile;
use common::display::{shorten_pubkey, RenderedProfile};
use common::event::EventUtils;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -14,14 +14,15 @@ use gpui::{
};
use i18n::{shared_t, t};
use itertools::Itertools;
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use registry::keystore::KeyItem;
use registry::{Registry, RegistryEvent};
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use states::constants::{BOOTSTRAP_RELAYS, DEFAULT_SIDEBAR_WIDTH};
use states::state::{AuthRequest, SignalKind, UnwrappingStatus};
use states::state::{Announcement, AuthRequest, Response, SignalKind, UnwrappingStatus};
use states::{app_state, default_nip17_relays, default_nip65_relays};
use theme::{ActiveTheme, Theme, ThemeMode};
use title_bar::TitleBar;
@@ -74,7 +75,7 @@ pub struct ChatSpace {
nip65_ready: bool,
/// All subscriptions for observing the app state
_subscriptions: SmallVec<[Subscription; 4]>,
_subscriptions: SmallVec<[Subscription; 3]>,
/// All long running tasks
_tasks: SmallVec<[Task<()>; 5]>,
@@ -83,7 +84,7 @@ pub struct ChatSpace {
impl ChatSpace {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let registry = Registry::global(cx);
let status = registry.read(cx).unwrapping_status.clone();
let keystore = KeyStore::global(cx);
let title_bar = cx.new(|_| TitleBar::new());
let dock = cx.new(|cx| DockArea::new(window, cx));
@@ -100,57 +101,38 @@ impl ChatSpace {
);
subscriptions.push(
// Observe the keystore
cx.observe_in(&registry, window, |this, registry, window, cx| {
let has_keyring = registry.read(cx).initialized_keystore;
let use_filestore = registry.read(cx).is_using_file_keystore();
let not_logged_in = registry.read(cx).signer_pubkey().is_none();
// Observe device changes
cx.observe_in(&keystore, window, move |this, state, window, cx| {
if state.read(cx).initialized {
let backend = state.read(cx).backend();
if use_filestore && not_logged_in {
this.render_keyring_installation(window, cx);
}
if state.read(cx).initialized {
if state.read(cx).is_using_file_keystore() {
this.render_keyring_installation(window, cx);
}
if has_keyring && not_logged_in {
let keystore = registry.read(cx).keystore();
cx.spawn_in(window, async move |this, cx| {
let result = backend
.read_credentials(&KeyItem::User.to_string(), cx)
.await;
cx.spawn_in(window, async move |this, cx| {
let result = keystore
.read_credentials(&KeyItem::User.to_string(), cx)
.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Some((user, secret))) => {
let public_key = PublicKey::parse(&user).unwrap();
let secret = String::from_utf8(secret).unwrap();
this.update_in(cx, |this, window, cx| {
match result {
Ok(Some((user, secret))) => {
let public_key = PublicKey::parse(&user).unwrap();
let secret = String::from_utf8(secret).unwrap();
this.set_account_layout(public_key, secret, window, cx);
}
_ => {
this.set_onboarding_layout(window, cx);
}
};
this.set_account_layout(public_key, secret, window, cx);
}
_ => {
this.set_onboarding_layout(window, cx);
}
};
})
.ok();
})
.ok();
})
.detach();
}
}),
);
subscriptions.push(
// Observe the global registry's events
cx.observe_in(&status, window, move |this, status, window, cx| {
let status = status.read(cx);
let all_panels = this.get_all_panel_ids(cx);
if matches!(
status,
UnwrappingStatus::Processing | UnwrappingStatus::Complete
) {
Registry::global(cx).update(cx, |this, cx| {
this.load_rooms(window, cx);
this.refresh_rooms(all_panels, cx);
});
.detach();
}
}
}),
);
@@ -238,9 +220,21 @@ impl ChatSpace {
let settings = AppSettings::global(cx);
match signal {
SignalKind::EncryptionNotSet => {
this.init_encryption(window, cx);
}
SignalKind::EncryptionSet(announcement) => {
this.load_encryption(announcement, window, cx);
}
SignalKind::EncryptionRequest(announcement) => {
this.render_request(announcement, window, cx);
}
SignalKind::EncryptionResponse(response) => {
this.receive_encryption(response, window, cx);
}
SignalKind::SignerSet(public_key) => {
// Close the latest modal if it exists
window.close_modal(cx);
// Close all opened modals
window.close_all_modals(cx);
// Load user's settings
settings.update(cx, |this, cx| {
@@ -256,15 +250,6 @@ impl ChatSpace {
// Setup the default layout for current workspace
this.set_default_layout(window, cx);
}
SignalKind::SignerUnset => {
// Clear all current chat rooms
registry.update(cx, |this, cx| {
this.reset(cx);
});
// Setup the onboarding layout for current workspace
this.set_onboarding_layout(window, cx);
}
SignalKind::Auth(req) => {
let url = &req.url;
let auto_auth = AppSettings::get_auto_auth(cx);
@@ -281,10 +266,19 @@ impl ChatSpace {
this.open_auth_request(req, window, cx);
}
}
SignalKind::GiftWrapStatus(status) => {
registry.update(cx, |this, cx| {
this.set_unwrapping_status(status, cx);
});
SignalKind::GiftWrapStatus(s) => {
if matches!(s, UnwrappingStatus::Processing | UnwrappingStatus::Complete) {
let all_panels = this.get_all_panel_ids(cx);
registry.update(cx, |this, cx| {
this.load_rooms(window, cx);
this.refresh_rooms(all_panels, cx);
if s == UnwrappingStatus::Complete {
this.set_loading(false, cx);
}
});
}
}
SignalKind::NewProfile(profile) => {
registry.update(cx, |this, cx| {
@@ -309,6 +303,92 @@ impl ChatSpace {
}
}
fn init_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
let result = app_state().init_encryption_keys().await;
this.update_in(cx, |_, window, cx| {
match result {
Ok(_) => {
window.push_notification(t!("encryption.notice"), cx);
}
Err(e) => {
// TODO: ask user to confirm re-running if failed
window.push_notification(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn load_encryption(&self, ann: Announcement, window: &Window, cx: &Context<Self>) {
log::info!("Loading encryption keys: {ann:?}");
cx.spawn_in(window, async move |this, cx| {
let state = app_state();
let result = state.load_encryption_keys(&ann).await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(_) => {
window.push_notification(t!("encryption.reinit"), cx);
}
Err(_) => {
this.request_encryption(ann, window, cx);
}
};
})
.ok();
})
.detach();
}
fn request_encryption(&self, ann: Announcement, window: &Window, cx: &Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
let result = app_state().request_encryption_keys().await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(wait_for_approval) => {
if wait_for_approval {
this.render_pending(ann, window, cx);
} else {
window.push_notification(t!("encryption.success"), cx);
}
}
Err(e) => {
// TODO: ask user to confirm re-running if failed
window.push_notification(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn receive_encryption(&self, res: Response, window: &Window, cx: &Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
let result = app_state().receive_encryption_keys(res).await;
this.update_in(cx, |_, window, cx| {
match result {
Ok(_) => {
window.push_notification(t!("encryption.success"), cx);
}
Err(e) => {
// TODO: ask user to confirm re-running if failed
window.push_notification(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn auth(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context<Self>) {
let settings = AppSettings::global(cx);
@@ -730,6 +810,132 @@ impl ChatSpace {
});
}
fn render_request(&mut self, ann: Announcement, window: &mut Window, cx: &mut Context<Self>) {
let client_name = SharedString::from(ann.client().to_string());
let target = ann.public_key();
let note = Notification::new()
.custom_id(SharedString::from(ann.id().to_hex()))
.autohide(false)
.icon(IconName::Info)
.title(shared_t!("request_encryption.label"))
.content(move |_window, cx| {
v_flex()
.gap_2()
.text_sm()
.child(shared_t!("request_encryption.body"))
.child(
v_flex()
.py_1()
.px_1p5()
.rounded_sm()
.text_xs()
.bg(cx.theme().warning_background)
.text_color(cx.theme().warning_foreground)
.child(client_name.clone()),
)
.into_any_element()
})
.action(move |_window, _cx| {
Button::new("approve")
.label(t!("common.approve"))
.small()
.primary()
.loading(false)
.disabled(false)
.on_click(move |_ev, _window, cx| {
cx.background_spawn(async move {
let state = app_state();
state.response_encryption_keys(target).await.ok();
})
.detach();
})
});
window.push_notification(note, cx);
}
fn render_pending(&mut self, ann: Announcement, window: &mut Window, cx: &mut Context<Self>) {
let client_name = SharedString::from(ann.client().to_string());
let public_key = shorten_pubkey(ann.public_key(), 8);
let view = cx.entity().downgrade();
window.open_modal(cx, move |this, _window, cx| {
let view = view.clone();
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.width(px(460.))
.button_props(
ModalButtonProps::default()
.cancel_text(t!("common.reset"))
.ok_text(t!("common.hide")),
)
.title(shared_t!("pending_encryption.label"))
.child(
v_flex()
.gap_2()
.text_sm()
.child(
v_flex()
.justify_center()
.items_center()
.text_center()
.h_16()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.font_semibold()
.child(client_name.clone())
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(&public_key)),
),
)
.child(shared_t!("pending_encryption.body_1", c = client_name))
.child(shared_t!("pending_encryption.body_2"))
.child(
div()
.text_xs()
.text_color(cx.theme().warning_foreground)
.child(shared_t!("pending_encryption.body_3")),
),
)
.on_cancel(move |_ev, window, cx| {
_ = view.update(cx, |this, cx| {
this.render_reset(window, cx);
});
// false to keep modal open
false
})
});
}
fn render_reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
let state = app_state();
let result = state.init_encryption_keys().await;
this.update_in(cx, |_, window, cx| {
match result {
Ok(_) => {
window.push_notification(t!("encryption.success"), cx);
window.close_all_modals(cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn render_setup_gossip_relays_modal(&mut self, window: &mut Window, cx: &mut App) {
let relays = default_nip65_relays();
@@ -937,15 +1143,15 @@ impl ChatSpace {
_window: &mut Window,
cx: &Context<Self>,
) -> impl IntoElement {
let registry = Registry::read_global(cx);
let status = registry.unwrapping_status.read(cx);
let registry = Registry::global(cx);
let status = registry.read(cx).loading;
h_flex()
.gap_2()
.h_6()
.w_full()
.child(compose_button())
.when(status != &UnwrappingStatus::Complete, |this| {
.when(status, |this| {
this.child(deferred(
h_flex()
.px_2()

View File

@@ -7,7 +7,7 @@ use gpui::{
WindowOptions,
};
use states::app_state;
use states::constants::{APP_ID, APP_NAME};
use states::constants::{APP_ID, CLIENT_NAME};
use ui::Root;
use crate::actions::{load_embedded_fonts, quit, Quit};
@@ -63,7 +63,7 @@ fn main() {
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)),
title: Some(SharedString::new_static(CLIENT_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
@@ -86,6 +86,9 @@ fn main() {
// Initialize app registry
registry::init(cx);
// Initialize backend for credentials storage
key_store::init(cx);
// Initialize settings
settings::init(cx);

View File

@@ -9,8 +9,9 @@ use gpui::{
Window,
};
use i18n::{shared_t, t};
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_connect::prelude::*;
use registry::keystore::KeyItem;
use registry::Registry;
use smallvec::{smallvec, SmallVec};
use states::app_state;
@@ -116,7 +117,7 @@ impl Account {
window: &mut Window,
cx: &mut Context<Self>,
) {
let keystore = Registry::global(cx).read(cx).keystore();
let keystore = KeyStore::global(cx).read(cx).backend();
// Handle connection in the background
cx.spawn_in(window, async move |this, cx| {

View File

@@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::time::Duration;
use common::display::{RenderedProfile, RenderedTimestamp};
@@ -17,7 +17,7 @@ use indexset::{BTreeMap, BTreeSet};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use registry::message::{Message, RenderedMessage};
use registry::room::{Room, RoomKind, RoomSignal, SendReport};
use registry::room::{Room, RoomKind, RoomSignal, SendOptions, SendReport, SignerKind};
use registry::Registry;
use serde::Deserialize;
use settings::AppSettings;
@@ -47,6 +47,10 @@ mod subject;
#[action(namespace = chat, no_json)]
pub struct SeenOn(pub EventId);
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)]
pub struct SetSigner(pub SignerKind);
pub fn init(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Chat> {
cx.new(|cx| Chat::new(room, window, cx))
}
@@ -54,7 +58,6 @@ pub fn init(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Cha
pub struct Chat {
// Chat Room
room: Entity<Room>,
relays: Entity<HashMap<PublicKey, Vec<RelayUrl>>>,
// Messages
list_state: ListState,
@@ -64,6 +67,7 @@ pub struct Chat {
// New Message
input: Entity<InputState>,
options: Entity<SendOptions>,
replies_to: Entity<HashSet<EventId>>,
// Media Attachment
@@ -75,20 +79,12 @@ pub struct Chat {
focus_handle: FocusHandle,
image_cache: Entity<RetainAllImageCache>,
_subscriptions: SmallVec<[Subscription; 4]>,
_subscriptions: SmallVec<[Subscription; 3]>,
_tasks: SmallVec<[Task<()>; 2]>,
}
impl Chat {
pub fn new(room: Entity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let attachments = cx.new(|_| vec![]);
let replies_to = cx.new(|_| HashSet::new());
let relays = cx.new(|_| {
let this: HashMap<PublicKey, Vec<RelayUrl>> = HashMap::new();
this
});
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("chat.placeholder"))
@@ -97,11 +93,16 @@ impl Chat {
.clean_on_escape()
});
let attachments = cx.new(|_| vec![]);
let replies_to = cx.new(|_| HashSet::new());
let options = cx.new(|_| SendOptions::default());
let id = room.read(cx).id.to_string().into();
let messages = BTreeSet::from([Message::system()]);
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
let connect = room.read(cx).connect(cx);
let load_messages = room.read(cx).load_messages(cx);
let get_messages = room.read(cx).get_messages(cx);
let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
@@ -109,7 +110,7 @@ impl Chat {
tasks.push(
// Load all messages belonging to this room
cx.spawn_in(window, async move |this, cx| {
let result = load_messages.await;
let result = get_messages.await;
this.update_in(cx, |this, window, cx| {
match result {
@@ -126,24 +127,11 @@ impl Chat {
);
tasks.push(
// Get messaging relays for all members
cx.spawn_in(window, async move |this, cx| {
let result = connect.await;
this.update_in(cx, |this, _window, cx| {
match result {
Ok(relays) => {
this.relays.update(cx, |this, cx| {
this.extend(relays);
cx.notify();
});
}
Err(e) => {
this.insert_warning(e.to_string(), cx);
}
};
})
.ok();
// Get messaging relays and encryption keys announcement for all members
cx.background_spawn(async move {
if let Err(e) = connect.await {
log::error!("Failed to initialize room: {e}");
}
}),
);
@@ -189,23 +177,6 @@ impl Chat {
}),
);
subscriptions.push(
// Observe the messaging relays of the room's members
cx.observe_in(&relays, window, |this, entity, _window, cx| {
let registry = Registry::global(cx);
let relays = entity.read(cx).clone();
for (public_key, urls) in relays.iter() {
if urls.is_empty() {
let profile = registry.read(cx).get_person(public_key, cx);
let content = t!("chat.nip17_not_found", u = profile.name());
this.insert_warning(content, cx);
}
}
}),
);
subscriptions.push(
// Observe when user close chat panel
cx.on_release_in(window, move |this, window, cx| {
@@ -219,19 +190,19 @@ impl Chat {
);
Self {
id: room.read(cx).id.to_string().into(),
image_cache: RetainAllImageCache::new(cx),
focus_handle: cx.focus_handle(),
rendered_texts_by_id: BTreeMap::new(),
reports_by_id: BTreeMap::new(),
relays,
id,
messages,
room,
list_state,
input,
replies_to,
attachments,
options,
rendered_texts_by_id: BTreeMap::new(),
reports_by_id: BTreeMap::new(),
uploading: false,
image_cache: RetainAllImageCache::new(cx),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
_tasks: tasks,
}
@@ -239,12 +210,12 @@ impl Chat {
/// Load all messages belonging to this room
fn load_messages(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let load_messages = self.room.read(cx).load_messages(cx);
let get_messages = self.room.read(cx).get_messages(cx);
self._tasks.push(
// Run the task in the background
cx.spawn_in(window, async move |this, cx| {
let result = load_messages.await;
let result = get_messages.await;
this.update_in(cx, |this, window, cx| {
match result {
@@ -303,9 +274,6 @@ impl Chat {
this.set_value("", window, cx);
});
// Get the backup setting
let backup = AppSettings::get_backup_messages(cx);
// Get replies_to if it's present
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
@@ -317,14 +285,17 @@ impl Chat {
let rumor_id = rumor.id.unwrap();
// Create a task for sending the message in the background
let send_message = room.send_message(rumor.clone(), backup, cx);
let opts = self.options.read(cx);
let send_message = room.send_message(&rumor, opts, cx);
// Optimistically update message list
cx.spawn_in(window, async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(100))
.await;
let delay = Duration::from_millis(100);
// Wait for the delay
cx.background_executor().timer(delay).await;
// Update the message list and reset the states
this.update_in(cx, |this, window, cx| {
this.insert_message(Message::user(rumor), true, cx);
this.remove_all_replies(cx);
@@ -339,37 +310,39 @@ impl Chat {
})
.detach();
// Continue sending the message in the background
cx.spawn_in(window, async move |this, cx| {
let result = send_message.await;
self._tasks.push(
// Continue sending the message in the background
cx.spawn_in(window, async move |this, cx| {
let result = send_message.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(reports) => {
this.room.update(cx, |this, cx| {
if this.kind != RoomKind::Ongoing {
// Update the room kind to ongoing
// But keep the room kind if send failed
if reports.iter().all(|r| !r.is_sent_success()) {
this.kind = RoomKind::Ongoing;
cx.notify();
this.update_in(cx, |this, window, cx| {
match result {
Ok(reports) => {
// Update room's status
this.room.update(cx, |this, cx| {
if this.kind != RoomKind::Ongoing {
// Update the room kind to ongoing,
// but keep the room kind if send failed
if reports.iter().all(|r| !r.is_sent_success()) {
this.kind = RoomKind::Ongoing;
cx.notify();
}
}
}
});
});
// Insert the sent reports
this.reports_by_id.insert(rumor_id, reports);
// Insert the sent reports
this.reports_by_id.insert(rumor_id, reports);
cx.notify();
cx.notify();
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
}
})
.ok();
})
.detach();
})
.ok();
}),
);
}
/// Resend a failed message
@@ -432,6 +405,7 @@ impl Chat {
}
/// Insert a warning message into the chat panel
#[allow(dead_code)]
fn insert_warning(&mut self, content: impl Into<String>, cx: &mut Context<Self>) {
let m = Message::warning(content.into());
self.insert_message(m, true, cx);
@@ -473,6 +447,10 @@ impl Chat {
registry.get_person(public_key, cx)
}
fn signer_kind(&self, cx: &App) -> SignerKind {
self.options.read(cx).signer_kind
}
fn scroll_to(&self, id: EventId) {
if let Some(ix) = self.messages.iter().position(|m| {
if let Message::User(msg) = m {
@@ -543,29 +521,24 @@ impl Chat {
})
.ok();
match Flatten::flatten(task.await.map_err(|e| e.into())) {
Ok(Some(url)) => {
this.update(cx, |this, cx| {
let result = Flatten::flatten(task.await.map_err(|e| e.into()));
this.update_in(cx, |this, window, cx| {
match result {
Ok(Some(url)) => {
this.add_attachment(url, cx);
this.set_uploading(false, cx);
})
.ok();
}
Ok(None) => {
this.update_in(cx, |this, window, cx| {
window.push_notification("Failed to upload file", cx);
}
Ok(None) => {
this.set_uploading(false, cx);
})
.ok();
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
this.set_uploading(false, cx);
})
.ok();
}
}
}
};
})
.ok();
}
Some(())
@@ -911,6 +884,27 @@ impl Chat {
),
)
})
.when(report.device_not_found, |this| {
this.child(
h_flex()
.flex_wrap()
.justify_center()
.p_2()
.h_20()
.w_full()
.text_sm()
.rounded(cx.theme().radius)
.bg(cx.theme().danger_background)
.text_color(cx.theme().danger_foreground)
.child(
div()
.flex_1()
.w_full()
.text_center()
.child(shared_t!("chat.device_not_found", u = name)),
),
)
})
.when_some(report.error.clone(), |this, error| {
this.child(
h_flex()
@@ -1291,6 +1285,13 @@ impl Chat {
})
.detach();
}
fn on_set_encryption(&mut self, ev: &SetSigner, _: &mut Window, cx: &mut Context<Self>) {
self.options.update(cx, move |this, cx| {
this.signer_kind = ev.0;
cx.notify();
});
}
}
impl Panel for Chat {
@@ -1334,8 +1335,11 @@ impl Focusable for Chat {
impl Render for Chat {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let kind = self.signer_kind(cx);
v_flex()
.on_action(cx.listener(Self::on_open_seen_on))
.on_action(cx.listener(Self::on_set_encryption))
.image_cache(self.image_cache.clone())
.size_full()
.child(
@@ -1384,9 +1388,7 @@ impl Render for Chat {
.items_end()
.gap_2p5()
.child(
div()
.flex()
.items_center()
h_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child(
@@ -1408,7 +1410,31 @@ impl Render for Chat {
.large(),
),
)
.child(TextInput::new(&self.input)),
.child(TextInput::new(&self.input))
.child(
Button::new("options")
.icon(IconName::Settings)
.ghost()
.large()
.popup_menu(move |this, _window, _cx| {
this.title("Encrypt by:")
.menu_with_check(
"Encryption Key",
matches!(kind, SignerKind::Encryption),
Box::new(SetSigner(SignerKind::Encryption)),
)
.menu_with_check(
"User's Identity",
matches!(kind, SignerKind::User),
Box::new(SetSigner(SignerKind::User)),
)
.menu_with_check(
"Auto",
matches!(kind, SignerKind::Auto),
Box::new(SetSigner(SignerKind::Auto)),
)
}),
),
),
),
)

View File

@@ -7,9 +7,9 @@ use gpui::{
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use i18n::{shared_t, t};
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_connect::prelude::*;
use registry::keystore::KeyItem;
use registry::Registry;
use smallvec::{smallvec, SmallVec};
use states::app_state;
use states::constants::BUNKER_TIMEOUT;
@@ -174,7 +174,7 @@ impl Login {
window: &mut Window,
cx: &mut Context<Self>,
) {
let keystore = Registry::global(cx).read(cx).keystore();
let keystore = KeyStore::global(cx).read(cx).backend();
let username = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_bytes();
let mut clean_uri = uri.to_string();
@@ -263,7 +263,7 @@ impl Login {
}
pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context<Self>) {
let keystore = Registry::global(cx).read(cx).keystore();
let keystore = KeyStore::global(cx).read(cx).backend();
let username = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_hex().into_bytes();

View File

@@ -7,9 +7,9 @@ use gpui::{
};
use gpui_tokio::Tokio;
use i18n::{shared_t, t};
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_sdk::prelude::*;
use registry::keystore::KeyItem;
use registry::Registry;
use settings::AppSettings;
use smol::fs;
use states::constants::BOOTSTRAP_RELAYS;
@@ -106,7 +106,7 @@ impl NewAccount {
}
pub fn set_signer(&mut self, cx: &mut Context<Self>) {
let keystore = Registry::global(cx).read(cx).keystore();
let keystore = KeyStore::global(cx).read(cx).backend();
let keys = self.temp_keys.read(cx).clone();
let username = keys.public_key().to_hex();

View File

@@ -9,12 +9,12 @@ use gpui::{
SharedString, StatefulInteractiveElement, Styled, Task, Window,
};
use i18n::{shared_t, t};
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_connect::prelude::*;
use registry::keystore::KeyItem;
use registry::Registry;
use smallvec::{smallvec, SmallVec};
use states::app_state;
use states::constants::{APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use states::constants::{CLIENT_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
@@ -81,7 +81,7 @@ impl Onboarding {
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
let uri = NostrConnectURI::client(app_keys.public_key(), vec![relay], APP_NAME);
let uri = NostrConnectURI::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
let qr_code = uri.to_string().to_qr();
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
@@ -126,7 +126,7 @@ impl Onboarding {
window: &mut Window,
cx: &mut Context<Self>,
) {
let keystore = Registry::global(cx).read(cx).keystore();
let keystore = KeyStore::global(cx).read(cx).backend();
let username = self.app_keys.public_key().to_hex();
let secret = self.app_keys.secret_key().to_secret_bytes();
let mut clean_uri = uri.to_string();

View File

@@ -52,8 +52,8 @@ impl RoomListItem {
self
}
pub fn public_key(mut self, public_key: PublicKey) -> Self {
self.public_key = Some(public_key);
pub fn public_key(mut self, public_key: &PublicKey) -> Self {
self.public_key = Some(public_key.to_owned());
self
}

View File

@@ -22,7 +22,6 @@ use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use states::app_state;
use states::constants::{BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use states::state::UnwrappingStatus;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
@@ -627,7 +626,7 @@ impl Sidebar {
.name(this.display_name(cx))
.avatar(this.display_image(proxy, cx))
.created_at(this.created_at.to_ago())
.public_key(this.members[0])
.public_key(this.members.iter().nth(0).unwrap().0)
.kind(this.kind)
.on_click(handler),
)
@@ -669,7 +668,7 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let registry = Registry::read_global(cx);
let loading = registry.unwrapping_status.read(cx) != &UnwrappingStatus::Complete;
let loading = registry.loading;
// Get rooms from either search results or the chat registry
let rooms = if let Some(results) = self.local_result.read(cx).as_ref() {

View File

@@ -0,0 +1,28 @@
[package]
name = "key_store"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
states = { path = "../states" }
ui = { path = "../ui" }
theme = { path = "../theme" }
settings = { path = "../settings" }
rust-i18n.workspace = true
i18n.workspace = true
gpui.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -14,8 +14,6 @@ use states::paths::config_dir;
pub enum KeyItem {
User,
Bunker,
Client,
Encryption,
}
impl Display for KeyItem {
@@ -23,8 +21,6 @@ impl Display for KeyItem {
match self {
Self::User => write!(f, "coop-user"),
Self::Bunker => write!(f, "coop-bunker"),
Self::Client => write!(f, "coop-client"),
Self::Encryption => write!(f, "coop-encryption"),
}
}
}
@@ -35,7 +31,7 @@ impl From<KeyItem> for String {
}
}
pub trait KeyStore: Any + Send + Sync {
pub trait KeyBackend: Any + Send + Sync {
fn name(&self) -> &str;
/// Reads the credentials from the provider.
@@ -66,7 +62,7 @@ pub trait KeyStore: Any + Send + Sync {
/// A credentials provider that stores credentials in the system keychain.
pub struct KeyringProvider;
impl KeyStore for KeyringProvider {
impl KeyBackend for KeyringProvider {
fn name(&self) -> &str {
"keyring"
}
@@ -139,7 +135,7 @@ impl Default for FileProvider {
}
}
impl KeyStore for FileProvider {
impl KeyBackend for FileProvider {
fn name(&self) -> &str {
"file"
}

View File

@@ -0,0 +1,96 @@
use std::sync::{Arc, LazyLock};
use gpui::{App, AppContext, Context, Entity, Global, Task};
use smallvec::{smallvec, SmallVec};
use crate::backend::{FileProvider, KeyBackend, KeyringProvider};
pub mod backend;
static DISABLE_KEYRING: LazyLock<bool> =
LazyLock::new(|| std::env::var("DISABLE_KEYRING").is_ok_and(|value| !value.is_empty()));
pub fn init(cx: &mut App) {
KeyStore::set_global(cx.new(KeyStore::new), cx);
}
struct GlobalKeyStore(Entity<KeyStore>);
impl Global for GlobalKeyStore {}
pub struct KeyStore {
/// Key Store for storing credentials
pub backend: Arc<dyn KeyBackend>,
/// Whether the keystore has been initialized
pub initialized: bool,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 1]>,
}
impl KeyStore {
/// Retrieve the global keys state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalKeyStore>().0.clone()
}
/// Set the global keys instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalKeyStore(state));
}
/// Create a new keys instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
// Use the file system for keystore in development or when the user specifies it
let use_file_keystore = cfg!(debug_assertions) || *DISABLE_KEYRING;
// Construct the key backend
let backend: Arc<dyn KeyBackend> = if use_file_keystore {
Arc::new(FileProvider::default())
} else {
Arc::new(KeyringProvider)
};
// Only used for testing keyring availability on the user's system
let read_credential = cx.read_credentials("Coop");
let mut tasks = smallvec![];
tasks.push(
// Verify the keyring availability
cx.spawn(async move |this, cx| {
let result = read_credential.await;
this.update(cx, |this, cx| {
if let Err(e) = result {
log::error!("Keyring error: {e}");
// For Linux:
// The user has not installed secret service on their system
// Fall back to the file provider
this.backend = Arc::new(FileProvider::default());
}
this.initialized = true;
cx.notify();
})
.ok();
}),
);
Self {
backend,
initialized: false,
_tasks: tasks,
}
}
/// Returns the key backend.
pub fn backend(&self) -> Arc<dyn KeyBackend> {
Arc::clone(&self.backend)
}
/// Returns true if the keystore is a file key backend.
pub fn is_using_file_keystore(&self) -> bool {
self.backend.name() == "file"
}
}

View File

@@ -12,16 +12,13 @@ settings = { path = "../settings" }
gpui.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
flume.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true
fuzzy-matcher = "0.3.7"
rustls = "0.23.23"

View File

@@ -1,6 +1,5 @@
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock};
use anyhow::Error;
use common::event::EventUtils;
@@ -14,19 +13,12 @@ use room::RoomKind;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use states::app_state;
use states::constants::KEYRING_URL;
use states::state::UnwrappingStatus;
use crate::keystore::{FileProvider, KeyStore, KeyringProvider};
use crate::room::Room;
pub mod keystore;
pub mod message;
pub mod room;
pub static DISABLE_KEYRING: LazyLock<bool> =
LazyLock::new(|| std::env::var("DISABLE_KEYRING").is_ok_and(|value| !value.is_empty()));
pub fn init(cx: &mut App) {
Registry::set_global(cx.new(Registry::new), cx);
}
@@ -49,14 +41,8 @@ pub struct Registry {
/// Collection of all persons (user profiles)
pub persons: HashMap<PublicKey, Entity<Profile>>,
/// Status of the unwrapping process
pub unwrapping_status: Entity<UnwrappingStatus>,
/// Key Store for storing credentials
pub keystore: Arc<dyn KeyStore>,
/// Whether the keystore has been initialized
pub initialized_keystore: bool,
/// Loading status of the registry
pub loading: bool,
/// Public Key of the currently activated signer
signer_pubkey: Option<PublicKey>,
@@ -85,39 +71,8 @@ impl Registry {
/// Create a new registry instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let unwrapping_status = cx.new(|_| UnwrappingStatus::default());
let read_credential = cx.read_credentials(KEYRING_URL);
let initialized_keystore = cfg!(debug_assertions) || *DISABLE_KEYRING;
let keystore: Arc<dyn KeyStore> = if cfg!(debug_assertions) || *DISABLE_KEYRING {
Arc::new(FileProvider::default())
} else {
Arc::new(KeyringProvider)
};
let mut tasks = smallvec![];
if !(cfg!(debug_assertions) || *DISABLE_KEYRING) {
tasks.push(
// Verify the keyring access
cx.spawn(async move |this, cx| {
let result = read_credential.await;
this.update(cx, |this, cx| {
if let Err(e) = result {
log::error!("Keyring error: {e}");
// For Linux:
// The user has not installed secret service on their system
// Fall back to the file provider
this.keystore = Arc::new(FileProvider::default());
}
this.initialized_keystore = true;
cx.notify();
})
.ok();
}),
);
}
tasks.push(
// Load all user profiles from the database
cx.spawn(async move |this, cx| {
@@ -136,12 +91,10 @@ impl Registry {
);
Self {
unwrapping_status,
keystore,
initialized_keystore,
rooms: vec![],
persons: HashMap::new(),
signer_pubkey: None,
loading: true,
_tasks: tasks,
}
}
@@ -165,16 +118,6 @@ impl Registry {
})
}
/// Returns the keystore.
pub fn keystore(&self) -> Arc<dyn KeyStore> {
Arc::clone(&self.keystore)
}
/// Returns true if the keystore is a file keystore.
pub fn is_using_file_keystore(&self) -> bool {
self.keystore.name() == "file"
}
/// Returns the public key of the currently activated signer.
pub fn signer_pubkey(&self) -> Option<PublicKey> {
self.signer_pubkey
@@ -233,6 +176,11 @@ impl Registry {
}
}
pub fn set_loading(&mut self, loading: bool, cx: &mut Context<Self>) {
self.loading = loading;
cx.notify();
}
/// Get a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
self.rooms
@@ -297,24 +245,13 @@ impl Registry {
pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).members.contains(&public_key))
.filter(|room| room.read(cx).members.contains_key(&public_key))
.cloned()
.collect()
}
/// Set the loading status of the registry.
pub fn set_unwrapping_status(&mut self, status: UnwrappingStatus, cx: &mut Context<Self>) {
self.unwrapping_status.update(cx, |this, cx| {
*this = status;
cx.notify();
});
}
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
// Reset the unwrapping status
self.set_unwrapping_status(UnwrappingStatus::default(), cx);
// Clear the current identity
self.signer_pubkey = None;
@@ -339,10 +276,7 @@ impl Registry {
let authored_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(
SingleLetterTag::lowercase(Alphabet::A),
public_key.to_hex(),
);
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
let addressed_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)

View File

@@ -7,20 +7,57 @@ use anyhow::{anyhow, Error};
use common::display::RenderedProfile;
use common::event::EventUtils;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use states::app_state;
use states::constants::SEND_RETRY;
use crate::Registry;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Deserialize, Serialize)]
pub enum SignerKind {
Encryption,
User,
#[default]
Auto,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SendOptions {
pub backup: bool,
pub signer_kind: SignerKind,
}
impl SendOptions {
pub fn new() -> Self {
Self {
backup: true,
signer_kind: SignerKind::default(),
}
}
pub fn backup(&self) -> bool {
self.backup
}
}
impl Default for SendOptions {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct SendReport {
pub receiver: PublicKey,
pub status: Option<Output<EventId>>,
pub error: Option<SharedString>,
pub on_hold: Option<Event>,
pub relays_not_found: bool,
pub device_not_found: bool,
pub on_hold: Option<Event>,
}
impl SendReport {
@@ -31,18 +68,17 @@ impl SendReport {
error: None,
on_hold: None,
relays_not_found: false,
device_not_found: false,
}
}
pub fn status(mut self, output: Output<EventId>) -> Self {
self.status = Some(output);
self.relays_not_found = false;
self
}
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
self.error = Some(error.into());
self.relays_not_found = false;
self
}
@@ -51,11 +87,16 @@ impl SendReport {
self
}
pub fn not_found(mut self) -> Self {
pub fn relays_not_found(mut self) -> Self {
self.relays_not_found = true;
self
}
pub fn device_not_found(mut self) -> Self {
self.device_not_found = true;
self
}
pub fn is_relay_error(&self) -> bool {
self.error.is_some() || self.relays_not_found
}
@@ -82,6 +123,8 @@ pub enum RoomKind {
Request,
}
type DevicePublicKey = PublicKey;
#[derive(Debug)]
pub struct Room {
pub id: u64,
@@ -89,7 +132,7 @@ pub struct Room {
/// Subject of the room
pub subject: Option<String>,
/// All members of the room
pub members: Vec<PublicKey>,
pub members: HashMap<PublicKey, Option<DevicePublicKey>>,
/// Kind
pub kind: RoomKind,
}
@@ -128,7 +171,11 @@ impl From<&Event> for Room {
let created_at = val.created_at;
// Get the members from the event's tags and event's pubkey
let members = val.all_pubkeys();
let members: HashMap<PublicKey, Option<DevicePublicKey>> = val
.all_pubkeys()
.into_iter()
.map(|public_key| (public_key, None))
.collect();
// Get subject from tags
let subject = val
@@ -152,7 +199,11 @@ impl From<&UnsignedEvent> for Room {
let created_at = val.created_at;
// Get the members from the event's tags and event's pubkey
let members = val.all_pubkeys();
let members: HashMap<PublicKey, Option<DevicePublicKey>> = val
.all_pubkeys()
.into_iter()
.map(|public_key| (public_key, None))
.collect();
// Get subject from tags
let subject = val
@@ -233,8 +284,8 @@ impl Room {
}
/// Returns the members of the room
pub fn members(&self) -> &Vec<PublicKey> {
&self.members
pub fn members(&self) -> Vec<PublicKey> {
self.members.keys().cloned().collect()
}
/// Checks if the room has more than two members (group)
@@ -264,17 +315,17 @@ impl Room {
///
/// This member is always different from the current user.
fn display_member(&self, cx: &App) -> Profile {
let registry = Registry::read_global(cx);
let registry = Registry::global(cx);
let signer_pubkey = registry.read(cx).signer_pubkey();
if let Some(public_key) = registry.signer_pubkey() {
for member in self.members() {
if member != &public_key {
return registry.get_person(member, cx);
}
}
}
let target_member = self
.members
.keys()
.find(|&member| Some(member) != signer_pubkey.as_ref())
.or_else(|| self.members.keys().next())
.expect("Room should have at least one member");
registry.get_person(&self.members[0], cx)
registry.read(cx).get_person(target_member, cx)
}
/// Merge the names of the first two members of the room.
@@ -284,7 +335,7 @@ impl Room {
if self.is_group() {
let profiles: Vec<Profile> = self
.members
.iter()
.keys()
.map(|public_key| registry.get_person(public_key, cx))
.collect();
@@ -305,91 +356,9 @@ impl Room {
}
}
/// Connects to all members's messaging relays
pub fn connect(&self, cx: &App) -> Task<Result<HashMap<PublicKey, Vec<RelayUrl>>, Error>> {
let members = self.members.clone();
cx.background_spawn(async move {
let client = app_state().client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let mut relays = HashMap::new();
let mut processed = HashSet::new();
for member in members.into_iter() {
if member == public_key {
continue;
};
relays.insert(member, vec![]);
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(member)
.limit(1);
let mut stream = client
.stream_events(filter, Duration::from_secs(10))
.await?;
if let Some(event) = stream.next().await {
if processed.insert(event.id) {
let public_key = event.pubkey;
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
// Check if at least one URL exists
if urls.is_empty() {
continue;
}
// Connect to relays
for url in urls.iter() {
client.add_relay(url).await?;
client.connect_relay(url).await?;
}
relays.entry(public_key).and_modify(|v| v.extend(urls));
}
}
}
Ok(relays)
})
}
/// Loads all messages for this room from the database
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
let conversation_id = self.id.to_string();
cx.background_spawn(async move {
let client = app_state().client();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(
SingleLetterTag::lowercase(Alphabet::C),
conversation_id.as_str(),
);
let stored = client.database().query(filter).await?;
let mut messages = Vec::with_capacity(stored.len());
for event in stored {
match UnsignedEvent::from_json(&event.content) {
Ok(rumor) => messages.push(rumor),
Err(e) => log::warn!("Failed to parse stored rumor: {e}"),
}
}
messages.sort_by_key(|message| message.created_at);
Ok(messages)
})
}
/// Emits a new message signal to the current room
pub fn emit_message(&self, gift_wrap_id: EventId, event: UnsignedEvent, cx: &mut Context<Self>) {
cx.emit(RoomSignal::NewMessage((gift_wrap_id, event)));
pub fn emit_message(&self, id: EventId, event: UnsignedEvent, cx: &mut Context<Self>) {
cx.emit(RoomSignal::NewMessage((id, event)));
}
/// Emits a signal to refresh the current room's messages.
@@ -397,9 +366,69 @@ impl Room {
cx.emit(RoomSignal::Refresh);
}
/// Get messaging relays and encryption keys announcement for each member
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let members = self.members();
cx.background_spawn(async move {
let client = app_state().client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
for member in members.into_iter() {
if member == public_key {
continue;
};
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(member)
.limit(1);
// Subscribe to get members messaging relays
client.subscribe(filter, Some(opts)).await?;
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(member)
.limit(1);
// Subscribe to get members encryption keys announcement
client.subscribe(filter, Some(opts)).await?;
}
Ok(())
})
}
/// Get all messages belonging to the room
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
let conversation_id = self.id.to_string();
cx.background_spawn(async move {
let client = app_state().client();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
let stored = client.database().query(filter).await?;
let mut messages: Vec<UnsignedEvent> = stored
.into_iter()
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
.collect();
messages.sort_by_key(|message| message.created_at);
Ok(messages)
})
}
/// Create a new message event (unsigned)
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent {
let public_key = Registry::read_global(cx).signer_pubkey().unwrap();
let registry = Registry::global(cx);
let public_key = registry.read(cx).signer_pubkey().unwrap();
let subject = self.subject.clone();
let mut tags = vec![];
@@ -407,7 +436,7 @@ impl Room {
// Add receivers
//
// NOTE: current user will be removed from the list of receivers
for member in self.members.iter() {
for (member, _) in self.members.iter() {
tags.push(Tag::public_key(member.to_owned()));
}
@@ -447,34 +476,42 @@ impl Room {
/// Create a task to send a message to all room members
pub fn send_message(
&self,
rumor: UnsignedEvent,
backup: bool,
rumor: &UnsignedEvent,
opts: &SendOptions,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let mut members = self.members.clone();
let rumor = rumor.to_owned();
let opts = opts.to_owned();
cx.background_spawn(async move {
let states = app_state();
let client = states.client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let device = states.device.read().await.encryption_keys.clone();
let user_signer = client.signer().await?;
let user_pubkey = user_signer.get_public_key().await?;
// Collect relay hints for all participants (including current user)
let mut participants = members.clone();
if !participants.contains(&public_key) {
participants.push(public_key);
let mut participants: Vec<PublicKey> = members.keys().cloned().collect();
if !participants.contains(&user_pubkey) {
participants.push(user_pubkey);
}
// Initialize relay cache
let mut relay_cache: HashMap<PublicKey, Vec<RelayUrl>> = HashMap::new();
for participant in participants.iter().cloned() {
let urls = Self::messaging_relays(participant).await;
let urls = states.messaging_relays(participant).await;
relay_cache.insert(participant, urls);
}
// Update rumor with relay hints for each receiver
let mut rumor = rumor;
let mut tags_with_hints = Vec::new();
for tag in rumor.tags.to_vec() {
for tag in rumor.tags.into_iter() {
if let Some(standard) = tag.as_standardized().cloned() {
match standard {
TagStandard::PublicKey {
@@ -483,18 +520,18 @@ impl Room {
uppercase,
..
} => {
let relay_url =
relay_cache
.get(&public_key)
.and_then(|urls| urls.first().cloned());
let relay_url = relay_cache
.get(&public_key)
.and_then(|urls| urls.first().cloned());
let updated = TagStandard::PublicKey {
public_key,
relay_url,
alias,
uppercase,
};
tags_with_hints
.push(Tag::from_standardized_without_cell(updated));
tags_with_hints.push(Tag::from_standardized_without_cell(updated));
}
_ => tags_with_hints.push(tag),
}
@@ -506,29 +543,42 @@ impl Room {
// Remove the current user's public key from the list of receivers
// Current user will be handled separately
members.retain(|&pk| pk != public_key);
let (public_key, device_pubkey) = members.remove_entry(&user_pubkey).unwrap();
// Determine the signer will be used based on the provided options
let signer = Self::select_signer(&opts.signer_kind, device, user_signer)?;
// Collect the send reports
let mut reports: Vec<SendReport> = vec![];
for receiver in members.into_iter() {
let rumor = rumor.clone();
let event = EventBuilder::gift_wrap(&signer, &receiver, rumor, vec![]).await?;
for (receiver, device_pubkey) in members.into_iter() {
let urls = relay_cache.get(&receiver).cloned().unwrap_or_default();
// Check if there are any relays to send the event to
// Check if there are any relays to send the message to
if urls.is_empty() {
reports.push(SendReport::new(receiver).not_found());
reports.push(SendReport::new(receiver).relays_not_found());
continue;
}
// Skip sending if using encryption keys but device not found
if device_pubkey.is_none() && matches!(opts.signer_kind, SignerKind::Encryption) {
reports.push(SendReport::new(receiver).device_not_found());
continue;
}
// Determine the receiver based on the signer kind
let rumor = rumor.clone();
let target = Self::select_receiver(&opts.signer_kind, receiver, device_pubkey);
let event = EventBuilder::gift_wrap(&signer, &target, rumor, vec![]).await?;
// Send the event to the messaging relays
match client.send_event_to(urls, &event).await {
Ok(output) => {
let id = output.id().to_owned();
let auth_required = output.failed.iter().any(|m| m.1.starts_with("auth-"));
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
let report = SendReport::new(receiver).status(output);
if auth_required {
if auth {
// Wait for authenticated and resent event successfully
for attempt in 0..=SEND_RETRY {
let retry_manager = states.tracker().read().await;
@@ -561,15 +611,16 @@ impl Room {
// Construct a gift wrap to back up to current user's owned messaging relays
let rumor = rumor.clone();
let event = EventBuilder::gift_wrap(&signer, &public_key, rumor, vec![]).await?;
let target = Self::select_receiver(&opts.signer_kind, public_key, device_pubkey);
let event = EventBuilder::gift_wrap(&signer, &target, rumor, vec![]).await?;
// Only send a backup message to current user if sent successfully to others
if reports.iter().all(|r| r.is_sent_success()) && backup {
if opts.backup() && reports.iter().all(|r| r.is_sent_success()) {
let urls = relay_cache.get(&public_key).cloned().unwrap_or_default();
// Check if there are any relays to send the event to
if urls.is_empty() {
reports.push(SendReport::new(public_key).not_found());
reports.push(SendReport::new(public_key).relays_not_found());
} else {
// Send the event to the messaging relays
match client.send_event_to(urls, &event).await {
@@ -596,7 +647,8 @@ impl Room {
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
cx.background_spawn(async move {
let client = app_state().client();
let states = app_state();
let client = states.client();
let mut resend_reports = vec![];
for report in reports.into_iter() {
@@ -625,11 +677,11 @@ impl Room {
// Process the on hold event if it exists
if let Some(event) = report.on_hold {
let urls = Self::messaging_relays(receiver).await;
let urls = states.messaging_relays(receiver).await;
// Check if there are any relays to send the event to
if urls.is_empty() {
resend_reports.push(SendReport::new(receiver).not_found());
resend_reports.push(SendReport::new(receiver).relays_not_found());
} else {
// Send the event to the messaging relays
match client.send_event_to(urls, &event).await {
@@ -648,36 +700,24 @@ impl Room {
})
}
/// Gets messaging relays for public key
async fn messaging_relays(public_key: PublicKey) -> Vec<RelayUrl> {
let client = app_state().client();
let mut relay_urls = vec![];
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Ok(events) = client.database().query(filter).await {
if let Some(event) = events.first_owned() {
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
// Connect to relays
for url in urls.iter() {
client.add_relay(url).await.ok();
client.connect_relay(url).await.ok();
}
relay_urls.extend(urls.into_iter().take(3).unique());
fn select_signer<T>(kind: &SignerKind, device: Option<T>, user: T) -> Result<T, Error>
where
T: NostrSigner,
{
match kind {
SignerKind::Encryption => {
Ok(device.ok_or_else(|| anyhow!("No encryption keys found"))?)
}
SignerKind::User => Ok(user),
SignerKind::Auto => Ok(device.unwrap_or(user)),
}
}
relay_urls
fn select_receiver(kind: &SignerKind, user: PublicKey, device: Option<PublicKey>) -> PublicKey {
match kind {
SignerKind::Encryption => device.unwrap(),
SignerKind::User => user,
SignerKind::Auto => device.unwrap_or(user),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
}

View File

@@ -7,11 +7,12 @@ publish.workspace = true
[dependencies]
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
dirs.workspace = true
smol.workspace = true
flume.workspace = true
log.workspace = true
anyhow.workspace = true
whoami = "1.5.2"
whoami = "1.6.1"
rustls = "0.23.23"

View File

@@ -1,9 +1,8 @@
pub const APP_NAME: &str = "Coop";
pub const CLIENT_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
pub const APP_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc4MkNFRkQ2RkVGQURGNzUKUldSMTMvcisxdThzZUZraHc4Vno3NVNJek81VkJFUEV3MkJweGFxQXhpekdSU1JIekpqMG4yemMK";
pub const APP_UPDATER_ENDPOINT: &str = "https://coop-updater.reya.su/";
pub const KEYRING_URL: &str = "Coop Safe Storage";
pub const SETTINGS_IDENTIFIER: &str = "coop:settings";
/// Bootstrap Relays.
@@ -33,6 +32,9 @@ pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default timeout (in seconds) for Nostr Connect (Bunker)
pub const BUNKER_TIMEOUT: u64 = 30;
/// Default timeout (in seconds) for fetching events
pub const QUERY_TIMEOUT: u64 = 3;
/// Total metadata requests will be grouped.
pub const METADATA_BATCH_LIMIT: usize = 100;

View File

@@ -1,7 +1,9 @@
use std::sync::OnceLock;
use nostr_sdk::prelude::*;
use whoami::{devicename, platform};
use crate::constants::CLIENT_NAME;
use crate::state::AppState;
pub mod constants;
@@ -9,6 +11,7 @@ pub mod paths;
pub mod state;
static APP_STATE: OnceLock<AppState> = OnceLock::new();
static APP_NAME: OnceLock<String> = OnceLock::new();
static NIP65_RELAYS: OnceLock<Vec<(RelayUrl, Option<RelayMetadata>)>> = OnceLock::new();
static NIP17_RELAYS: OnceLock<Vec<RelayUrl>> = OnceLock::new();
@@ -17,6 +20,15 @@ pub fn app_state() -> &'static AppState {
APP_STATE.get_or_init(AppState::new)
}
pub fn app_name() -> &'static String {
APP_NAME.get_or_init(|| {
let devicename = devicename();
let platform = platform();
format!("{CLIENT_NAME} on {platform} ({devicename})")
})
}
/// Default NIP-65 Relays. Used for new account
pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option<RelayMetadata>)> {
NIP65_RELAYS.get_or_init(|| {

View File

@@ -0,0 +1,25 @@
use std::sync::Arc;
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, Default)]
pub struct Device {
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
///
/// The client keys that used for communication between devices
pub client_keys: Option<Arc<dyn NostrSigner>>,
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
///
/// The encryption keys that used for encryption and decryption
pub encryption_keys: Option<Arc<dyn NostrSigner>>,
}
impl Device {
pub fn new() -> Self {
Self {
client_keys: None,
encryption_keys: None,
}
}
}

View File

@@ -0,0 +1,31 @@
use flume::{Receiver, Sender};
use nostr_sdk::prelude::*;
#[derive(Debug, Clone)]
pub struct Ingester {
rx: Receiver<PublicKey>,
tx: Sender<PublicKey>,
}
impl Default for Ingester {
fn default() -> Self {
Self::new()
}
}
impl Ingester {
pub fn new() -> Self {
let (tx, rx) = flume::bounded::<PublicKey>(1024);
Self { rx, tx }
}
pub fn receiver(&self) -> &Receiver<PublicKey> {
&self.rx
}
pub async fn send(&self, public_key: PublicKey) {
if let Err(e) = self.tx.send_async(public_key).await {
log::error!("Failed to send public key: {e}");
}
}
}

View File

@@ -1,169 +1,31 @@
use std::borrow::Cow;
use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
use std::collections::hash_map::DefaultHasher;
use std::collections::HashSet;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Error};
use flume::{Receiver, Sender};
use anyhow::{anyhow, Context, Error};
use nostr_lmdb::NostrLMDB;
use nostr_sdk::prelude::*;
use smol::lock::RwLock;
use crate::app_name;
use crate::constants::{
BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, METADATA_BATCH_TIMEOUT, SEARCH_RELAYS,
BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, METADATA_BATCH_TIMEOUT, QUERY_TIMEOUT, SEARCH_RELAYS,
};
use crate::paths::config_dir;
use crate::state::device::Device;
use crate::state::ingester::Ingester;
use crate::state::tracker::EventTracker;
const TIMEOUT: u64 = 5;
mod device;
mod ingester;
mod signal;
mod tracker;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AuthRequest {
pub url: RelayUrl,
pub challenge: String,
pub sending: bool,
}
impl AuthRequest {
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
Self {
challenge: challenge.into(),
sending: false,
url,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum UnwrappingStatus {
#[default]
Initialized,
Processing,
Complete,
}
/// Signals sent through the global event channel to notify UI
#[derive(Debug)]
pub enum SignalKind {
/// A signal to notify UI that the client's signer has been set
SignerSet(PublicKey),
/// A signal to notify UI that the client's signer has been unset
SignerUnset,
/// A signal to notify UI that the relay requires authentication
Auth(AuthRequest),
/// A signal to notify UI that a new profile has been received
NewProfile(Profile),
/// A signal to notify UI that a new gift wrap event has been received
NewMessage((EventId, UnsignedEvent)),
/// A signal to notify UI that no messaging relays for current user was found
MessagingRelaysNotFound,
/// A signal to notify UI that no gossip relays for current user was found
GossipRelaysNotFound,
/// A signal to notify UI that gift wrap status has changed
GiftWrapStatus(UnwrappingStatus),
}
#[derive(Debug, Clone)]
pub struct Signal {
rx: Receiver<SignalKind>,
tx: Sender<SignalKind>,
}
impl Default for Signal {
fn default() -> Self {
Self::new()
}
}
impl Signal {
pub fn new() -> Self {
let (tx, rx) = flume::bounded::<SignalKind>(2048);
Self { rx, tx }
}
pub fn receiver(&self) -> &Receiver<SignalKind> {
&self.rx
}
pub fn sender(&self) -> &Sender<SignalKind> {
&self.tx
}
pub async fn send(&self, kind: SignalKind) {
if let Err(e) = self.tx.send_async(kind).await {
log::error!("Failed to send signal: {e}");
}
}
}
#[derive(Debug, Clone)]
pub struct Ingester {
rx: Receiver<PublicKey>,
tx: Sender<PublicKey>,
}
impl Default for Ingester {
fn default() -> Self {
Self::new()
}
}
impl Ingester {
pub fn new() -> Self {
let (tx, rx) = flume::bounded::<PublicKey>(1024);
Self { rx, tx }
}
pub fn receiver(&self) -> &Receiver<PublicKey> {
&self.rx
}
pub async fn send(&self, public_key: PublicKey) {
if let Err(e) = self.tx.send_async(public_key).await {
log::error!("Failed to send public key: {e}");
}
}
}
#[derive(Debug, Clone, Default)]
pub struct EventTracker {
/// Tracking events that have been resent by Coop in the current session
pub resent_ids: Vec<Output<EventId>>,
/// Temporarily store events that need to be resent later
pub resend_queue: HashMap<EventId, RelayUrl>,
/// Tracking events sent by Coop in the current session
pub sent_ids: HashSet<EventId>,
/// Tracking events seen on which relays in the current session
pub seen_on_relays: HashMap<EventId, HashSet<RelayUrl>>,
}
impl EventTracker {
pub fn resent_ids(&self) -> &Vec<Output<EventId>> {
&self.resent_ids
}
pub fn resend_queue(&self) -> &HashMap<EventId, RelayUrl> {
&self.resend_queue
}
pub fn sent_ids(&self) -> &HashSet<EventId> {
&self.sent_ids
}
pub fn seen_on_relays(&self) -> &HashMap<EventId, HashSet<RelayUrl>> {
&self.seen_on_relays
}
}
pub use signal::*;
#[derive(Debug)]
pub struct AppState {
@@ -179,6 +41,9 @@ pub struct AppState {
/// Ingester channel for processing public keys
ingester: Ingester,
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub device: RwLock<Device>,
/// The timestamp when the application was initialized.
pub initialized_at: Timestamp,
@@ -213,6 +78,7 @@ impl AppState {
});
let client = ClientBuilder::default().database(lmdb).opts(opts).build();
let device = RwLock::new(Device::default());
let event_tracker = RwLock::new(EventTracker::default());
let signal = Signal::default();
@@ -220,6 +86,7 @@ impl AppState {
Self {
client,
device,
event_tracker,
signal,
ingester,
@@ -233,6 +100,11 @@ impl AppState {
&self.client
}
/// Returns a reference to the device
pub fn device(&'static self) -> &'static RwLock<Device> {
&self.device
}
/// Returns a reference to the event tracker
pub fn tracker(&'static self) -> &'static RwLock<EventTracker> {
&self.event_tracker
@@ -262,7 +134,10 @@ impl AppState {
// Get user's gossip relays
self.get_nip65(pk).await.ok();
// Exit the current loop
// Initialize client keys
self.init_client_keys().await.ok();
// Exit the loop
break;
}
}
@@ -355,6 +230,36 @@ impl AppState {
}
match event.kind {
// Encryption Keys announcement event
Kind::Custom(10044) => {
if let Ok(true) = self.is_self_authored(&event).await {
if let Ok(announcement) = self.extract_announcement(&event) {
self.signal
.send(SignalKind::EncryptionSet(announcement))
.await;
}
}
}
// Encryption Keys request event
Kind::Custom(4454) => {
if let Ok(true) = self.is_self_authored(&event).await {
if let Ok(announcement) = self.extract_announcement(&event) {
self.signal
.send(SignalKind::EncryptionRequest(announcement))
.await;
}
}
}
// Encryption Keys response event
Kind::Custom(4455) => {
if let Ok(true) = self.is_self_authored(&event).await {
if let Ok(response) = self.extract_response(&event) {
self.signal
.send(SignalKind::EncryptionResponse(response))
.await;
}
}
}
Kind::RelayList => {
// Get events if relay list belongs to current user
if let Ok(true) = self.is_self_authored(&event).await {
@@ -370,6 +275,11 @@ impl AppState {
log::error!("Failed to subscribe to contact list event: {e}");
}
// Fetch user's encryption announcement event
if let Err(e) = self.get_announcement(author).await {
log::error!("Failed to fetch encryption event: {e}");
}
// Fetch user's messaging relays event
if let Err(e) = self.get_nip17(author).await {
log::error!("Failed to fetch messaging relays event: {e}");
@@ -404,7 +314,9 @@ impl AppState {
self.signal.send(SignalKind::NewProfile(profile)).await;
}
Kind::GiftWrap => {
self.extract_rumor(&event).await;
if let Err(e) = self.extract_rumor(&event).await {
log::error!("Failed to extract rumor: {e}");
}
}
_ => {}
}
@@ -428,14 +340,14 @@ impl AppState {
event_id, message, ..
} => {
let msg = MachineReadablePrefix::parse(&message);
let mut event_tracker = self.event_tracker.write().await;
let mut tracker = self.event_tracker.write().await;
// Keep track of events sent by Coop
event_tracker.sent_ids.insert(event_id);
tracker.sent_ids.insert(event_id);
// Keep track of events that need to be resend after auth
if let Some(MachineReadablePrefix::AuthRequired) = msg {
event_tracker.resend_queue.insert(event_id, relay_url);
tracker.resend_queue.insert(event_id, relay_url);
}
}
_ => {}
@@ -504,6 +416,47 @@ impl AppState {
}
}
/// Encrypt and store a key in the local database.
pub async fn set_keys(&self, kind: impl Into<String>, value: String) -> Result<(), Error> {
let signer = self.client.signer().await?;
let public_key = signer.get_public_key().await?;
// Encrypt the value
let content = signer.nip44_encrypt(&public_key, value.as_ref()).await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(format!("coop:{}", kind.into())))
.build(public_key)
.sign(&Keys::generate())
.await?;
// Save the event to the database
self.client.database().save_event(&event).await?;
Ok(())
}
/// Get and decrypt a key from the local database.
pub async fn get_keys(&self, kind: impl Into<String>) -> Result<Keys, Error> {
let signer = self.client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(format!("coop:{}", kind.into()));
if let Some(event) = self.client.database().query(filter).await?.first() {
let content = signer.nip44_decrypt(&public_key, &event.content).await?;
let secret = SecretKey::parse(&content)?;
let keys = Keys::new(secret);
Ok(keys)
} else {
Err(anyhow!("Key not found"))
}
}
/// Check if event is published by current user
async fn is_self_authored(&self, event: &Event) -> Result<bool, Error> {
let signer = self.client.signer().await?;
@@ -552,7 +505,7 @@ impl AppState {
/// Get and verify NIP-65 relays for a given public key
pub async fn get_nip65(&self, public_key: PublicKey) -> Result<(), Error> {
let timeout = Duration::from_secs(TIMEOUT);
let timeout = Duration::from_secs(QUERY_TIMEOUT);
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
@@ -608,9 +561,269 @@ impl AppState {
Ok(())
}
/// Initialize the client keys to communicate between clients
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn init_client_keys(&self) -> Result<(), Error> {
// Get the keys from the database or generate new ones
let keys = self
.get_keys("client")
.await
.unwrap_or_else(|_| Keys::generate());
// Initialize the client keys
let mut device = self.device.write().await;
device.client_keys = Some(Arc::new(keys));
Ok(())
}
/// Get and verify encryption announcement for a given public key
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn get_announcement(&self, public_key: PublicKey) -> Result<(), Error> {
let timeout = Duration::from_secs(QUERY_TIMEOUT);
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(public_key)
.limit(1);
// Subscribe to events from user's nip65 relays
self.client.subscribe(filter.clone(), Some(opts)).await?;
let tx = self.signal.sender().clone();
let database = self.client.database().clone();
// Verify the received data after a timeout
smol::spawn(async move {
smol::Timer::after(timeout).await;
if database.count(filter).await.unwrap_or(0) < 1 {
tx.send_async(SignalKind::EncryptionNotSet).await.ok();
}
})
.detach();
Ok(())
}
/// Generate encryption keys and announce them
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn init_encryption_keys(&self) -> Result<(), Error> {
let signer = self.client.signer().await?;
let keys = Keys::generate();
let public_key = keys.public_key();
let secret = keys.secret_key().to_secret_hex();
// Initialize the encryption keys
let mut device = self.device.write().await;
device.encryption_keys = Some(Arc::new(keys));
// Store the encryption keys for future use
self.set_keys("encryption", secret).await?;
// Construct the announcement event
let event = EventBuilder::new(Kind::Custom(10044), "")
.tags(vec![
Tag::client(app_name()),
Tag::custom(TagKind::custom("n"), vec![public_key]),
])
.sign(&signer)
.await?;
// Send the announcement event to the relays
self.client.send_event(&event).await?;
Ok(())
}
/// User has previously set encryption keys, load them from storage
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn load_encryption_keys(&self, announcement: &Announcement) -> Result<(), Error> {
let keys = self.get_keys("encryption").await?;
// Check if the encryption keys match the announcement
if announcement.public_key() == keys.public_key() {
let mut device = self.device.write().await;
device.encryption_keys = Some(Arc::new(keys));
Ok(())
} else {
Err(anyhow!("Not found"))
}
}
/// Request encryption keys from other clients
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn request_encryption_keys(&self) -> Result<bool, Error> {
let mut wait_for_approval = false;
let device = self.device.read().await;
// Client Keys are always known at this point
let Some(client_keys) = device.client_keys.as_ref() else {
return Err(anyhow!("Client Keys is required"));
};
let signer = self.client.signer().await?;
let public_key = signer.get_public_key().await?;
let client_pubkey = client_keys.get_public_key().await?;
// Get the encryption keys response from the database first
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
.pubkey(client_pubkey)
.limit(1);
match self.client.database().query(filter).await?.first_owned() {
// Found encryption keys that shared by other clients
Some(event) => {
let root_device = event
.tags
.find(TagKind::custom("P"))
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let payload = event.content.as_str();
let decrypted = client_keys.nip44_decrypt(&root_device, payload).await?;
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
// No longer need to hold the reader for device
drop(device);
let mut device = self.device.write().await;
device.encryption_keys = Some(Arc::new(keys));
}
None => {
// Construct encryption keys request event
let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(vec![
Tag::client(app_name()),
Tag::custom(TagKind::custom("pubkey"), vec![client_pubkey]),
])
.sign(&signer)
.await?;
// Send a request for encryption keys from other devices
self.client.send_event(&event).await?;
// Create a unique ID to control the subscription later
let subscription_id = SubscriptionId::new("request");
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
.pubkey(client_pubkey)
.since(Timestamp::now());
// Subscribe to the approval response event
self.client
.subscribe_with_id(subscription_id, filter, None)
.await?;
wait_for_approval = true;
}
}
Ok(wait_for_approval)
}
/// Receive the encryption keys from other clients
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn receive_encryption_keys(&self, res: Response) -> Result<(), Error> {
let device = self.device.read().await;
// Client Keys are always known at this point
let Some(client_keys) = device.client_keys.as_ref() else {
return Err(anyhow!("Client Keys is required"));
};
let public_key = res.public_key();
let payload = res.payload();
// Decrypt the payload using the client keys
let decrypted = client_keys.nip44_decrypt(&public_key, payload).await?;
let secret = SecretKey::parse(&decrypted)?;
let keys = Keys::new(secret);
// No longer need to hold the reader for device
drop(device);
let mut device = self.device.write().await;
device.encryption_keys = Some(Arc::new(keys));
Ok(())
}
/// Response the encryption keys request from other clients
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn response_encryption_keys(&self, target: PublicKey) -> Result<(), Error> {
let device = self.device.read().await;
// Client Keys are always known at this point
let Some(client_keys) = device.client_keys.as_ref() else {
return Err(anyhow!("Client Keys is required"));
};
let encryption = self.get_keys("encryption").await?;
let client_pubkey = client_keys.get_public_key().await?;
// Encrypt the encryption keys with the client's signer
let payload = client_keys
.nip44_encrypt(&target, &encryption.secret_key().to_secret_hex())
.await?;
// Construct the response event
//
// P tag: the current client's public key
// p tag: the requester's public key
let event = EventBuilder::new(Kind::Custom(4455), payload)
.tags(vec![
Tag::custom(TagKind::custom("P"), vec![client_pubkey]),
Tag::public_key(target),
])
.sign(client_keys)
.await?;
// Get the current user's signer and public key
let signer = self.client.signer().await?;
let public_key = signer.get_public_key().await?;
// Get the current user's relay list
let urls: Vec<RelayUrl> = self
.client
.database()
.relay_list(public_key)
.await?
.into_iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == Some(RelayMetadata::Read) {
Some(url)
} else {
None
}
})
.collect();
// Send the response event to the user's relay list
self.client.send_event_to(urls, &event).await?;
Ok(())
}
/// Get and verify NIP-17 relays for a given public key
pub async fn get_nip17(&self, public_key: PublicKey) -> Result<(), Error> {
let timeout = Duration::from_secs(TIMEOUT);
let timeout = Duration::from_secs(QUERY_TIMEOUT);
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
@@ -685,33 +898,87 @@ impl AppState {
Ok(())
}
/// Gets messaging relays for public key
pub async fn messaging_relays(&self, public_key: PublicKey) -> Vec<RelayUrl> {
let mut relay_urls = vec![];
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Ok(events) = self.client.database().query(filter).await {
if let Some(event) = events.first_owned() {
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
// Connect to relays
for url in urls.iter() {
self.client.add_relay(url).await.ok();
self.client.connect_relay(url).await.ok();
}
relay_urls.extend(urls.into_iter().take(3));
}
}
relay_urls
}
/// Re-subscribes to gift wrap events
pub async fn resubscribe_messages(&self) -> Result<(), Error> {
let signer = self.client.signer().await?;
let public_key = signer.get_public_key().await?;
let urls = self.messaging_relays(public_key).await;
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let id = SubscriptionId::new("inbox");
// Unsubscribe the previous subscription
self.client.unsubscribe(&id).await;
// Subscribe to gift wrap events
self.client
.subscribe_with_id_to(urls, id, filter, None)
.await?;
Ok(())
}
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(&self, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let rumor_id = rumor
.id
.ok_or_else(|| anyhow!("Rumor is missing an event id"))?;
let author_hex = rumor.pubkey.to_hex();
let conversation = Self::conversation_id(rumor).to_string();
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
let author = rumor.pubkey;
let conversation = self.conversation_id(rumor).to_string();
let mut tags = rumor.tags.clone().to_vec();
// Add a unique identifier
tags.push(Tag::identifier(id));
// Add a reference to the rumor's author
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)),
[author_hex],
[author],
));
// Add a conversation id
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)),
[conversation],
));
// Add a reference to the rumor's id
tags.push(Tag::event(rumor_id));
// Add references to the rumor's participants
for receiver in rumor.tags.public_keys().copied() {
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
[receiver.to_hex()],
[receiver],
));
}
// Convert rumor to json
let content = rumor.as_json();
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
@@ -739,89 +1006,149 @@ impl AppState {
}
// Unwraps a gift-wrapped event and processes its contents.
async fn extract_rumor(&self, gift_wrap: &Event) {
let mut rumor: Option<UnsignedEvent> = None;
async fn extract_rumor(&self, gift_wrap: &Event) -> Result<(), Error> {
// Try to get cached rumor first
if let Ok(event) = self.get_rumor(gift_wrap.id).await {
rumor = Some(event);
} else if let Ok(unwrapped) = self.client.unwrap_gift_wrap(gift_wrap).await {
self.process_rumor(gift_wrap.id, event).await?;
return Ok(());
}
// Try to unwrap with the available signer
if let Ok(unwrapped) = self.try_unwrap_gift(gift_wrap).await {
let sender = unwrapped.sender;
let mut rumor_unsigned = unwrapped.rumor;
if !Self::verify_rumor_sender(sender, &rumor_unsigned) {
log::warn!(
"Ignoring gift wrap {}: seal pubkey {} mismatches rumor pubkey {}",
gift_wrap.id,
sender,
rumor_unsigned.pubkey
);
} else {
rumor_unsigned.ensure_id();
if !self.verify_rumor_sender(sender, &rumor_unsigned) {
return Err(anyhow!("Invalid rumor"));
};
if let Err(e) = self.set_rumor(gift_wrap.id, &rumor_unsigned).await {
log::warn!("Failed to cache unwrapped event: {e}")
} else {
rumor = Some(rumor_unsigned);
}
}
// Generate event id for the rumor if it doesn't have one
rumor_unsigned.ensure_id();
self.set_rumor(gift_wrap.id, &rumor_unsigned).await?;
self.process_rumor(gift_wrap.id, rumor_unsigned).await?;
return Ok(());
}
if let Some(event) = rumor {
// Send all pubkeys to the metadata batch to sync data
for public_key in event.tags.public_keys().copied() {
self.ingester.send(public_key).await;
}
match event.created_at >= self.initialized_at {
// New message: send a signal to notify the UI
true => {
self.signal
.send(SignalKind::NewMessage((gift_wrap.id, event)))
.await;
}
// Old message: Coop is probably processing the user's messages during initial load
false => {
self.gift_wrap_processing.store(true, Ordering::Release);
}
}
}
Ok(())
}
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
// Helper method to try unwrapping with different signers
async fn try_unwrap_gift(&self, gift_wrap: &Event) -> Result<UnwrappedGift, Error> {
// Try to unwrap with the device's encryption keys first
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
if let Some(signer) = self.device.read().await.encryption_keys.as_ref() {
if let Ok(unwrapped) = UnwrappedGift::from_gift_wrap(signer, gift_wrap).await {
return Ok(unwrapped);
}
}
// Try to unwrap with the user's signer
let signer = self.client.signer().await?;
if let Ok(unwrapped) = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await {
return Ok(unwrapped);
}
Err(anyhow!("No signer available"))
}
/// Process a rumor event.
async fn process_rumor(&self, id: EventId, event: UnsignedEvent) -> Result<(), Error> {
// Send all pubkeys to the metadata batch to sync data
for public_key in event.tags.public_keys().copied() {
self.ingester.send(public_key).await;
}
match event.created_at >= self.initialized_at {
// New message: send a signal to notify the UI
true => {
self.signal.send(SignalKind::NewMessage((id, event))).await;
}
// Old message: Coop is probably processing the user's messages during initial load
false => {
self.gift_wrap_processing.store(true, Ordering::Release);
}
}
Ok(())
}
/// Get the conversation ID for a given rumor (message).
fn conversation_id(&self, rumor: &UnsignedEvent) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().copied().collect();
pubkeys.push(rumor.pubkey);
pubkeys.sort();
pubkeys.dedup();
pubkeys.hash(&mut hasher);
hasher.finish()
}
fn verify_rumor_sender(sender: PublicKey, rumor: &UnsignedEvent) -> bool {
/// Verify that the sender of a rumor is the same as the sender of the event.
fn verify_rumor_sender(&self, sender: PublicKey, rumor: &UnsignedEvent) -> bool {
rumor.pubkey == sender
}
/// Extract an encryption keys announcement from an event.
fn extract_announcement(&self, event: &Event) -> Result<Announcement, Error> {
let public_key = event
.tags
.iter()
.find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "pubkey")
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())
.context("Cannot parse public key from the event's tags")?;
let client_name = event
.tags
.find(TagKind::Client)
.and_then(|tag| tag.content())
.map(|c| c.to_string())
.context("Cannot parse client name from the event's tags")?;
Ok(Announcement::new(event.id, client_name, public_key))
}
/// Extract an encryption keys response from an event.
fn extract_response(&self, event: &Event) -> Result<Response, Error> {
let payload = event.content.clone();
let root_device = event
.tags
.find(TagKind::custom("P"))
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())
.context("Cannot parse public key from the event's tags")?;
Ok(Response::new(payload, root_device))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_state;
#[test]
fn verify_rumor_sender_accepts_matching_sender() {
let state = app_state();
let keys = Keys::generate();
let public_key = keys.public_key();
let rumor = EventBuilder::text_note("hello").build(public_key);
assert!(AppState::verify_rumor_sender(public_key, &rumor));
assert!(state.verify_rumor_sender(public_key, &rumor));
}
#[test]
fn verify_rumor_sender_rejects_mismatched_sender() {
let state = app_state();
let sender_keys = Keys::generate();
let rumor_keys = Keys::generate();
let rumor = EventBuilder::text_note("spoof").build(rumor_keys.public_key());
assert!(!AppState::verify_rumor_sender(
sender_keys.public_key(),
&rumor
));
assert!(!state.verify_rumor_sender(sender_keys.public_key(), &rumor));
}
}

View File

@@ -0,0 +1,157 @@
use flume::{Receiver, Sender};
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AuthRequest {
pub url: RelayUrl,
pub challenge: String,
pub sending: bool,
}
impl AuthRequest {
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
Self {
challenge: challenge.into(),
sending: false,
url,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum UnwrappingStatus {
#[default]
Initialized,
Processing,
Complete,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Announcement {
id: EventId,
client: String,
public_key: PublicKey,
}
impl Announcement {
pub fn new(id: EventId, client_name: String, public_key: PublicKey) -> Self {
Self {
id,
client: client_name,
public_key,
}
}
pub fn id(&self) -> EventId {
self.id
}
pub fn public_key(&self) -> PublicKey {
self.public_key
}
pub fn client(&self) -> &str {
self.client.as_str()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Response {
payload: String,
public_key: PublicKey,
}
impl Response {
pub fn new(payload: String, public_key: PublicKey) -> Self {
Self {
payload,
public_key,
}
}
pub fn public_key(&self) -> PublicKey {
self.public_key
}
pub fn payload(&self) -> &str {
self.payload.as_str()
}
}
/// Signals sent through the global event channel to notify UI
#[derive(Debug)]
pub enum SignalKind {
/// NIP-4e
///
/// A signal to notify UI that the user has not set encryption keys yet
EncryptionNotSet,
/// NIP-4e
///
/// A signal to notify UI that the user has set encryption keys
EncryptionSet(Announcement),
/// NIP-4e
///
/// A signal to notify UI that the user has responded to an encryption request
EncryptionResponse(Response),
/// NIP-4e
///
/// A signal to notify UI that the user has requested encryption keys from other devices
EncryptionRequest(Announcement),
/// A signal to notify UI that the client's signer has been set
SignerSet(PublicKey),
/// A signal to notify UI that the relay requires authentication
Auth(AuthRequest),
/// A signal to notify UI that a new profile has been received
NewProfile(Profile),
/// A signal to notify UI that a new gift wrap event has been received
NewMessage((EventId, UnsignedEvent)),
/// A signal to notify UI that no messaging relays for current user was found
MessagingRelaysNotFound,
/// A signal to notify UI that no gossip relays for current user was found
GossipRelaysNotFound,
/// A signal to notify UI that gift wrap status has changed
GiftWrapStatus(UnwrappingStatus),
}
#[derive(Debug, Clone)]
pub struct Signal {
rx: Receiver<SignalKind>,
tx: Sender<SignalKind>,
}
impl Default for Signal {
fn default() -> Self {
Self::new()
}
}
impl Signal {
pub fn new() -> Self {
let (tx, rx) = flume::bounded::<SignalKind>(2048);
Self { rx, tx }
}
pub fn receiver(&self) -> &Receiver<SignalKind> {
&self.rx
}
pub fn sender(&self) -> &Sender<SignalKind> {
&self.tx
}
pub async fn send(&self, kind: SignalKind) {
if let Err(e) = self.tx.send_async(kind).await {
log::error!("Failed to send signal: {e}");
}
}
}

View File

@@ -0,0 +1,36 @@
use std::collections::{HashMap, HashSet};
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, Default)]
pub struct EventTracker {
/// Tracking events that have been resent by Coop in the current session
pub resent_ids: Vec<Output<EventId>>,
/// Temporarily store events that need to be resent later
pub resend_queue: HashMap<EventId, RelayUrl>,
/// Tracking events sent by Coop in the current session
pub sent_ids: HashSet<EventId>,
/// Tracking events seen on which relays in the current session
pub seen_on_relays: HashMap<EventId, HashSet<RelayUrl>>,
}
impl EventTracker {
pub fn resent_ids(&self) -> &Vec<Output<EventId>> {
&self.resent_ids
}
pub fn resend_queue(&self) -> &HashMap<EventId, RelayUrl> {
&self.resend_queue
}
pub fn sent_ids(&self) -> &HashSet<EventId> {
&self.sent_ids
}
pub fn seen_on_relays(&self) -> &HashMap<EventId, HashSet<RelayUrl>> {
&self.seen_on_relays
}
}

View File

@@ -76,6 +76,7 @@ pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement +
impl PopupMenuExt for Button {}
enum PopupMenuItem {
Title(SharedString),
Separator,
Item {
icon: Option<Icon>,
@@ -314,6 +315,20 @@ impl PopupMenu {
self
}
/// Add a title menu item
pub fn title(mut self, label: impl Into<SharedString>) -> Self {
if self.menu_items.is_empty() {
return self;
}
if let Some(PopupMenuItem::Title(_)) = self.menu_items.last() {
return self;
}
self.menu_items.push(PopupMenuItem::Title(label.into()));
self
}
/// Add a separator Menu Item
pub fn separator(mut self) -> Self {
if self.menu_items.is_empty() {
@@ -588,6 +603,15 @@ impl Render for PopupMenu {
}));
match item {
PopupMenuItem::Title(label) => {
this.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(label.clone())
)
},
PopupMenuItem::Separator => this.h_auto().p_0().disabled(true).child(
div()
.rounded_none()