update encryption panel
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m58s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m39s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Rust / build (macos-latest, stable) (pull_request) Has been cancelled
Rust / build (windows-latest, stable) (pull_request) Has been cancelled

This commit is contained in:
2026-02-24 15:48:58 +07:00
parent e44ce7e3f6
commit 10ded51d2f
12 changed files with 352 additions and 128 deletions

3
assets/icons/reset.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M13 17.25C16.4518 17.25 19.25 14.4518 19.25 11V10.5C19.25 6.77208 16.2279 3.75 12.5 3.75C8.77208 3.75 5.75 6.77208 5.75 10.5V20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2 16.5L5.75 20.25L9.5 16.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

View File

@@ -13,38 +13,6 @@ const MINUTES_IN_HOUR: i64 = 60;
const HOURS_IN_DAY: i64 = 24;
const DAYS_IN_MONTH: i64 = 30;
pub trait RenderedProfile {
fn avatar(&self) -> SharedString;
fn display_name(&self) -> SharedString;
}
impl RenderedProfile for Profile {
fn avatar(&self) -> SharedString {
self.metadata()
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| picture.into())
.unwrap_or_else(|| "brand/avatar.png".into())
}
fn display_name(&self) -> SharedString {
if let Some(display_name) = self.metadata().display_name.as_ref() {
if !display_name.is_empty() {
return SharedString::from(display_name);
}
}
if let Some(name) = self.metadata().name.as_ref() {
if !name.is_empty() {
return SharedString::from(name);
}
}
SharedString::from(shorten_pubkey(self.public_key(), 4))
}
}
pub trait RenderedTimestamp {
fn to_human_time(&self) -> SharedString;
fn to_ago(&self) -> SharedString;
@@ -126,13 +94,3 @@ impl<T: AsRef<str>> TextUtils for T {
)))
}
}
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let Ok(pubkey) = public_key.to_bech32();
format!(
"{}:{}",
&pubkey[0..(len + 1)],
&pubkey[pubkey.len() - len..]
)
}

View File

@@ -2,14 +2,14 @@ use std::collections::HashMap;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error};
use common::{shorten_pubkey, RenderedTimestamp};
use common::RenderedTimestamp;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
};
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use person::{shorten_pubkey, Person, PersonRegistry};
use smallvec::{smallvec, SmallVec};
use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
use theme::ActiveTheme;

View File

@@ -1,27 +1,151 @@
use anyhow::Error;
use device::DeviceRegistry;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, Render, SharedString, Styled, Window,
div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
};
use nostr_sdk::prelude::*;
use person::{shorten_pubkey, PersonRegistry};
use state::Announcement;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::v_flex;
use ui::notification::Notification;
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<EncryptionPanel> {
cx.new(|cx| EncryptionPanel::new(window, cx))
const MSG: &str =
"Encryption Key is a special key that used to encrypt and decrypt your messages. \
Your identity is completely decoupled from all encryption processes to protect your privacy.";
const NOTICE: &str = "By resetting your encryption key, you will lose access to \
all your encrypted messages before. This action cannot be undone.";
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<EncryptionPanel> {
cx.new(|cx| EncryptionPanel::new(public_key, window, cx))
}
#[derive(Debug)]
pub struct EncryptionPanel {
name: SharedString,
focus_handle: FocusHandle,
/// User's public key
public_key: PublicKey,
/// Whether the panel is loading
loading: bool,
/// Tasks
tasks: Vec<Task<Result<(), Error>>>,
}
impl EncryptionPanel {
fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(public_key: PublicKey, _window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
name: "Encryption".into(),
focus_handle: cx.focus_handle(),
public_key,
loading: false,
tasks: vec![],
}
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
let device = DeviceRegistry::global(cx);
let task = device.read(cx).approve(event, cx);
let id = event.id;
// Update loading status
self.set_loading(true, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
this.update_in(cx, |this, window, cx| {
// Reset loading status
this.set_loading(false, cx);
// Remove request
device.update(cx, |this, cx| {
this.remove_request(&id, cx);
});
window.push_notification("Approved", cx);
})?;
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_loading(false, cx);
window.push_notification(Notification::error(e.to_string()), cx);
})?;
}
}
Ok(())
}));
}
fn render_requests(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
const TITLE: &str = "You've requested for the Encryption Key from:";
let device = DeviceRegistry::global(cx);
let requests = device.read(cx).requests.clone();
let mut items = Vec::new();
for event in requests.into_iter() {
let request = Announcement::from(&event);
let client_name = request.client_name();
let target = request.public_key();
items.push(
v_flex()
.gap_2()
.text_sm()
.child(SharedString::from(TITLE))
.child(
v_flex()
.h_12()
.items_center()
.justify_center()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().warning_background)
.text_color(cx.theme().warning_foreground)
.child(client_name.clone()),
)
.child(
h_flex()
.h_7()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(SharedString::from(target.to_hex())),
)
.child(
h_flex().justify_end().gap_2().child(
Button::new("approve")
.label("Approve")
.ghost()
.small()
.disabled(self.loading)
.loading(self.loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.approve(&event, window, cx);
})),
),
),
)
}
items
}
}
impl Panel for EncryptionPanel {
@@ -43,12 +167,128 @@ impl Focusable for EncryptionPanel {
}
impl Render for EncryptionPanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let device = DeviceRegistry::global(cx);
let state = device.read(cx).state();
let has_requests = device.read(cx).has_requests();
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&self.public_key, cx);
let Some(announcement) = profile.announcement() else {
return div();
};
let pubkey = SharedString::from(shorten_pubkey(announcement.public_key(), 16));
let client_name = announcement.client_name();
v_flex()
.size_full()
.items_center()
.justify_center()
.p_2()
.gap_10()
.p_3()
.gap_3()
.w_full()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(MSG)),
)
.child(divider(cx))
.child(
v_flex()
.gap_3()
.text_sm()
.child(
v_flex()
.gap_1p5()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Device Name:")),
)
.child(
h_flex()
.h_12()
.items_center()
.justify_center()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(client_name.clone()),
),
)
.child(
v_flex()
.gap_1p5()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Encryption Public Key:")),
)
.child(
h_flex()
.h_7()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(pubkey),
),
),
)
.when(has_requests, |this| {
this.child(divider(cx)).child(
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Requests:")),
)
.child(
v_flex()
.gap_2()
.flex_1()
.w_full()
.children(self.render_requests(cx)),
),
)
})
.child(divider(cx))
.when(state.requesting(), |this| {
this.child(
h_flex()
.h_8()
.justify_center()
.text_xs()
.text_center()
.text_color(cx.theme().text_accent)
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius)
.child(SharedString::from(
"Please open other device and approve the request",
)),
)
})
.when(state.set(), |this| {
this.child(
v_flex()
.gap_1()
.child(
Button::new("reset")
.icon(IconName::Reset)
.label("Reset")
.warning()
.small()
.font_semibold(),
)
.child(
div()
.italic()
.text_size(px(10.))
.text_color(cx.theme().text_muted)
.child(SharedString::from(NOTICE)),
),
)
})
}
}

View File

@@ -214,6 +214,7 @@ impl MessagingRelayPanel {
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_updating(false, cx);
this.set_error(e.to_string(), window, cx);
})?;
}

View File

@@ -2,7 +2,6 @@ use std::str::FromStr;
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::shorten_pubkey;
use gpui::{
div, rems, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
@@ -10,7 +9,7 @@ use gpui::{
};
use gpui_tokio::Tokio;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use person::{shorten_pubkey, Person, PersonRegistry};
use settings::AppSettings;
use smol::fs;
use state::{nostr_upload, NostrRegistry};

View File

@@ -234,6 +234,7 @@ impl RelayListPanel {
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_updating(false, cx);
this.set_error(e.to_string(), window, cx);
})?;
}

View File

@@ -182,14 +182,19 @@ impl Workspace {
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::OpenEncryptionPanel => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(encryption_key::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
if let Some(public_key) = signer.public_key() {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(encryption_key::init(public_key, window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
}
Command::OpenInboxPanel => {
self.dock.update(cx, |this, cx| {

View File

@@ -25,12 +25,12 @@ impl Global for GlobalDeviceRegistry {}
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
#[derive(Debug)]
pub struct DeviceRegistry {
/// Request for encryption key from other devices
pub requests: Vec<Event>,
/// Device state
state: DeviceState,
/// Device requests
requests: Entity<HashSet<Event>>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
@@ -52,8 +52,6 @@ impl DeviceRegistry {
/// Create a new device registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let requests = cx.new(|_| HashSet::default());
let mut subscriptions = smallvec![];
subscriptions.push(
@@ -77,7 +75,7 @@ impl DeviceRegistry {
});
Self {
requests,
requests: vec![],
state: DeviceState::default(),
tasks: vec![],
_subscriptions: subscriptions,
@@ -89,7 +87,7 @@ impl DeviceRegistry {
let client = nostr.read(cx).client();
let (tx, rx) = flume::bounded::<Event>(100);
cx.background_spawn(async move {
self.tasks.push(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
@@ -107,20 +105,21 @@ impl DeviceRegistry {
match event.kind {
Kind::Custom(4454) => {
if verify_author(&client, event.as_ref()).await {
tx.send_async(event.into_owned()).await.ok();
tx.send_async(event.into_owned()).await?;
}
}
Kind::Custom(4455) => {
if verify_author(&client, event.as_ref()).await {
tx.send_async(event.into_owned()).await.ok();
tx.send_async(event.into_owned()).await?;
}
}
_ => {}
}
}
}
})
.detach();
Ok(())
}));
self.tasks.push(
// Update GPUI states
@@ -147,8 +146,8 @@ impl DeviceRegistry {
}
/// Get the device state
pub fn state(&self) -> &DeviceState {
&self.state
pub fn state(&self) -> DeviceState {
self.state.clone()
}
/// Set the device state
@@ -181,19 +180,25 @@ impl DeviceRegistry {
/// Reset the device state
fn reset(&mut self, cx: &mut Context<Self>) {
self.state = DeviceState::Idle;
self.requests.update(cx, |this, cx| {
this.clear();
cx.notify();
});
self.requests.clear();
cx.notify();
}
/// Add a request for device keys
fn add_request(&mut self, request: Event, cx: &mut Context<Self>) {
self.requests.update(cx, |this, cx| {
this.insert(request);
cx.notify();
});
self.requests.push(request);
cx.notify();
}
/// Remove a request for device keys
pub fn remove_request(&mut self, id: &EventId, cx: &mut Context<Self>) {
self.requests.retain(|r| r.id != *id);
cx.notify();
}
/// Check if there are any pending requests
pub fn has_requests(&self) -> bool {
!self.requests.is_empty()
}
/// Get all messages for encryption keys
@@ -290,12 +295,12 @@ impl DeviceRegistry {
match task.await {
Ok(event) => {
this.update(cx, |this, cx| {
this.init_device_signer(&event, cx);
this.new_signer(&event, cx);
})?;
}
Err(_) => {
this.update(cx, |this, cx| {
this.announce_device(cx);
this.announce(cx);
})?;
}
}
@@ -305,13 +310,15 @@ impl DeviceRegistry {
}
/// Create a new device signer and announce it
fn announce_device(&mut self, cx: &mut Context<Self>) {
fn announce(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get current user
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let keys = Keys::generate();
@@ -338,20 +345,20 @@ impl DeviceRegistry {
Ok(())
});
cx.spawn(async move |this, cx| {
self.tasks.push(cx.spawn(async move |this, cx| {
if task.await.is_ok() {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.listen_device_request(cx);
})
.ok();
this.listen_request(cx);
})?;
}
})
.detach();
Ok(())
}));
}
/// Initialize device signer (decoupled encryption key) for the current user
fn init_device_signer(&mut self, event: &Event, cx: &mut Context<Self>) {
fn new_signer(&mut self, event: &Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -375,14 +382,14 @@ impl DeviceRegistry {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.listen_device_request(cx);
this.listen_request(cx);
})
.ok();
}
Err(e) => {
this.update(cx, |this, cx| {
this.request_device_keys(cx);
this.listen_device_approval(cx);
this.request(cx);
this.listen_approval(cx);
})
.ok();
@@ -394,7 +401,7 @@ impl DeviceRegistry {
}
/// Listen for device key requests on user's write relays
fn listen_device_request(&mut self, cx: &mut Context<Self>) {
fn listen_request(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -426,7 +433,7 @@ impl DeviceRegistry {
}
/// Listen for device key approvals on user's write relays
fn listen_device_approval(&mut self, cx: &mut Context<Self>) {
fn listen_approval(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -435,7 +442,7 @@ impl DeviceRegistry {
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
self.tasks.push(cx.background_spawn(async move {
let urls = write_relays.await;
// Construct a filter for device key requests
@@ -452,13 +459,11 @@ impl DeviceRegistry {
client.subscribe(target).await?;
Ok(())
});
task.detach();
}));
}
/// Request encryption keys from other device
fn request_device_keys(&mut self, cx: &mut Context<Self>) {
fn request(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -559,34 +564,32 @@ impl DeviceRegistry {
Ok(keys)
});
cx.spawn(async move |this, cx| {
match task.await {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})
.ok();
}
Err(e) => {
log::error!("Error: {e}")
}
};
})
.detach();
self.tasks.push(cx.spawn(async move |this, cx| {
let keys = task.await?;
// Update signer
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})?;
Ok(())
}));
}
/// Approve requests for device keys from other devices
#[allow(dead_code)]
fn approve(&mut self, event: Event, cx: &mut Context<Self>) {
pub fn approve(&self, event: &Event, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get current user
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let event = event.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
cx.background_spawn(async move {
let urls = write_relays.await;
// Get device keys
@@ -619,9 +622,7 @@ impl DeviceRegistry {
client.send_event(&event).to(urls).await?;
Ok(())
});
task.detach();
})
}
}

View File

@@ -4,7 +4,7 @@ use std::sync::OnceLock;
pub const CLIENT_NAME: &str = "Coop";
/// COOP's public key
pub const COOP_PUBKEY: &str = "npub126kl5fruqan90py77gf6pvfvygefl2mu2ukew6xdx5pc5uqscwgsnkgarv";
pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x";
/// App ID
pub const APP_ID: &str = "su.reya.coop";

View File

@@ -9,6 +9,20 @@ pub enum DeviceState {
Set,
}
impl DeviceState {
pub fn idle(&self) -> bool {
matches!(self, DeviceState::Idle)
}
pub fn requesting(&self) -> bool {
matches!(self, DeviceState::Requesting)
}
pub fn set(&self) -> bool {
matches!(self, DeviceState::Set)
}
}
/// Announcement
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Announcement {

View File

@@ -47,6 +47,7 @@ pub enum IconName {
Plus,
PlusCircle,
Profile,
Reset,
Relay,
Reply,
Refresh,
@@ -112,6 +113,7 @@ impl IconNamed for IconName {
Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg",
Self::Profile => "icons/profile.svg",
Self::Reset => "icons/reset.svg",
Self::Relay => "icons/relay.svg",
Self::Reply => "icons/reply.svg",
Self::Refresh => "icons/refresh.svg",