diff --git a/assets/icons/reset.svg b/assets/icons/reset.svg
new file mode 100644
index 0000000..4bc319d
--- /dev/null
+++ b/assets/icons/reset.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/common/src/display.rs b/crates/common/src/display.rs
index 3f967ff..7b39ae0 100644
--- a/crates/common/src/display.rs
+++ b/crates/common/src/display.rs
@@ -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> 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..]
- )
-}
diff --git a/crates/coop/src/dialogs/screening.rs b/crates/coop/src/dialogs/screening.rs
index 5cfb8e8..d7ff40e 100644
--- a/crates/coop/src/dialogs/screening.rs
+++ b/crates/coop/src/dialogs/screening.rs
@@ -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;
diff --git a/crates/coop/src/panels/encryption_key.rs b/crates/coop/src/panels/encryption_key.rs
index 0f243ac..4584a63 100644
--- a/crates/coop/src/panels/encryption_key.rs
+++ b/crates/coop/src/panels/encryption_key.rs
@@ -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 {
- 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 {
+ 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>>,
}
impl EncryptionPanel {
- fn new(_window: &mut Window, cx: &mut Context) -> Self {
+ fn new(public_key: PublicKey, _window: &mut Window, cx: &mut Context) -> 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.loading = status;
+ cx.notify();
+ }
+
+ fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context) {
+ 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) -> Vec {
+ 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) -> impl IntoElement {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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)),
+ ),
+ )
+ })
}
}
diff --git a/crates/coop/src/panels/messaging_relays.rs b/crates/coop/src/panels/messaging_relays.rs
index 979ce17..4c0dd00 100644
--- a/crates/coop/src/panels/messaging_relays.rs
+++ b/crates/coop/src/panels/messaging_relays.rs
@@ -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);
})?;
}
diff --git a/crates/coop/src/panels/profile.rs b/crates/coop/src/panels/profile.rs
index b0817e7..5a13b98 100644
--- a/crates/coop/src/panels/profile.rs
+++ b/crates/coop/src/panels/profile.rs
@@ -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};
diff --git a/crates/coop/src/panels/relay_list.rs b/crates/coop/src/panels/relay_list.rs
index 3207dad..55d576d 100644
--- a/crates/coop/src/panels/relay_list.rs
+++ b/crates/coop/src/panels/relay_list.rs
@@ -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);
})?;
}
diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs
index 6554137..1cf1830 100644
--- a/crates/coop/src/workspace.rs
+++ b/crates/coop/src/workspace.rs
@@ -182,14 +182,19 @@ impl Workspace {
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) {
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| {
diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs
index f711950..6ea6d2f 100644
--- a/crates/device/src/lib.rs
+++ b/crates/device/src/lib.rs
@@ -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,
+
/// Device state
state: DeviceState,
- /// Device requests
- requests: Entity>,
-
/// Async tasks
tasks: Vec>>,
@@ -52,8 +52,6 @@ impl DeviceRegistry {
/// Create a new device registry instance
fn new(window: &mut Window, cx: &mut Context) -> 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::(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.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.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.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) {
+ fn announce(&mut self, cx: &mut Context) {
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) {
+ fn new_signer(&mut self, event: &Event, cx: &mut Context) {
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) {
+ fn listen_request(&mut self, cx: &mut Context) {
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) {
+ fn listen_approval(&mut self, cx: &mut Context) {
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> = 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) {
+ fn request(&mut self, cx: &mut Context) {
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) {
+ pub fn approve(&self, event: &Event, cx: &App) -> Task> {
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> = 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();
+ })
}
}
diff --git a/crates/state/src/constants.rs b/crates/state/src/constants.rs
index 13585e1..cb34e2b 100644
--- a/crates/state/src/constants.rs
+++ b/crates/state/src/constants.rs
@@ -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";
diff --git a/crates/state/src/device.rs b/crates/state/src/device.rs
index 93dfd38..682e8b2 100644
--- a/crates/state/src/device.rs
+++ b/crates/state/src/device.rs
@@ -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 {
diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs
index fa79d0b..c596bd0 100644
--- a/crates/ui/src/icon.rs
+++ b/crates/ui/src/icon.rs
@@ -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",