23 Commits

Author SHA1 Message Date
b5d6d91851 chore: config auto update and fix ci 2026-03-05 08:46:56 +07:00
d475d03d0c chore: fix ci 2026-03-05 08:12:45 +07:00
0f00fed122 chore: add env-filter for tracing 2026-03-05 08:03:03 +07:00
ef73b3c629 chore: fix ci build for macos intel 2026-03-04 18:04:24 +07:00
bbf31baee5 chore: fix missing dep import for windows 2026-03-04 15:59:38 +07:00
80227b3ed3 chore: fix build on windows 2026-03-04 15:46:55 +07:00
d00c5a1982 chore: fix ci 2026-03-04 15:32:54 +07:00
c054017d7e chore: prepare
Some checks failed
Rust / build (ghcr.io/catthehacker/ubuntu:rust-latest, stable) (push) Has been cancelled
2026-03-04 15:23:06 +07:00
d065e70cd1 chore: some improvements (#16)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m55s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Reviewed-on: #16
2026-03-04 07:49:42 +00:00
7a6b6feacc feat: refactor the text parser (#15)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m44s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Fix: https://jumble.social/notes/nevent1qvzqqqqqqypzqwlsccluhy6xxsr6l9a9uhhxf75g85g8a709tprjcn4e42h053vaqyvhwumn8ghj7un9d3shjtnjv4ukztnnw5hkjmnzdauqzrnhwden5te0dehhxtnvdakz7qpqpj4awhj4ul6tztlne0v7efvqhthygt0myrlxslpsjh7t6x4esapq3lf5c0
Reviewed-on: #15
2026-03-03 08:55:36 +00:00
55c5ebbf17 feat: multi-account switcher (#14)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m56s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Reviewed-on: #14
2026-03-02 08:08:04 +00:00
3fecda175b feat: refactor encryption panel (#13)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m52s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Reviewed-on: #13
2026-02-28 11:25:02 +00:00
2423cdca19 Merge pull request 'fix: build on macos' (#12) from fix-build-macos into master
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m50s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Reviewed-on: #12
2026-02-28 06:28:13 +00:00
4b021bef01 fix core-text-version
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m28s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 4m44s
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
2026-02-28 13:11:16 +07:00
dcf28e2b60 chore: better dropdown menu (#11)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m44s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Reviewed-on: #11
2026-02-28 06:05:44 +00:00
624140c061 feat: add contact list panel (#10)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m41s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Reviewed-on: #10
2026-02-28 01:50:33 +00:00
fcb2b671e7 chore: update deps
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m59s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
2026-02-27 18:39:07 +07:00
a86219dcb0 chore: bump version
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 5m41s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
2026-02-27 15:51:24 +07:00
c22a7291c7 .
Some checks failed
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m40s
Rust / build (ubuntu-latest, stable) (push) Failing after 1m41s
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
2026-02-27 15:39:33 +07:00
d7996bf32e .
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 2m56s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m43s
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
2026-02-27 15:17:18 +07:00
2dcf825105 .
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m50s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m40s
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
2026-02-27 08:11:40 +07:00
3debfa81d7 Revert "wip"
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m50s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m50s
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 reverts commit e152154c3b.
2026-02-27 05:46:41 +07:00
4ba2049756 Revert "."
This reverts commit b7ffdc8431.
2026-02-27 05:46:40 +07:00
63 changed files with 3671 additions and 2240 deletions

View File

@@ -21,7 +21,7 @@ jobs:
os: windows-11-arm
target: aarch64-pc-windows-msvc
- platform: macos-x64
os: macos-13
os: macos-15-intel
target: x86_64-apple-darwin
- platform: macos-arm64
os: macos-latest
@@ -130,7 +130,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Make get-crate-version executable
run: chmod +x script/get-crate-version
@@ -163,8 +163,6 @@ jobs:
generate_release_notes: true
files: |
artifacts/**/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Output release info
run: |

616
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,15 +4,14 @@ members = ["crates/*"]
default-members = ["crates/coop"]
[workspace.package]
version = "0.3.0"
version = "1.0.0-beta"
edition = "2021"
publish = false
[workspace.dependencies]
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "screen-capture", "x11", "wayland", "runtime_shaders"] }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland", "runtime_shaders"] }
gpui_linux = { git = "https://github.com/zed-industries/zed" }
gpui_windows = { git = "https://github.com/zed-industries/zed" }
gpui_macos = { git = "https://github.com/zed-industries/zed" }
@@ -23,7 +22,6 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" }
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-sqlite = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }

3
assets/icons/book.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="M4.75 20V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H19.25V18.75H6C5.30964 18.75 4.75 19.3096 4.75 20ZM4.75 20C4.75 20.6904 5.30964 21.25 6 21.25H19.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 12.25C9.75 12.25 9.75 13.75 9.75 13.75H14.25C14.25 13.75 14.25 12.25 12 12.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 9.25C13 9.80228 12.5523 10.25 12 10.25C11.4477 10.25 11 9.80228 11 9.25C11 8.69772 11.4477 8.25 12 8.25C12.5523 8.25 13 8.69772 13 9.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.5 9.25H12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 866 B

3
assets/icons/device.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="M14.25 10.75C14.25 9.64543 15.1454 8.75 16.25 8.75H20.25C21.3546 8.75 22.25 9.64543 22.25 10.75V19.25C22.25 20.3546 21.3546 21.25 20.25 21.25H16.25C15.1454 21.25 14.25 20.3546 14.25 19.25V10.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.25 18.25H19.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.25 8.75V5.75C20.25 4.64543 19.3546 3.75 18.25 3.75H5.75C4.64543 3.75 3.75 4.64543 3.75 5.75V14.75C3.75 15.8546 2.85457 16.75 1.75 16.75V18.25C1.75 19.3546 2.64543 20.25 3.75 20.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.75 16.75H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 898 B

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8.75" r="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="4" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="20" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.25 16.625V16.5C7.25 13.8766 9.37665 11.75 12 11.75C14.6234 11.75 16.75 13.8766 16.75 16.5V16.625C16.75 17.5225 16.0225 18.25 15.125 18.25H8.875C7.97754 18.25 7.25 17.5225 7.25 16.625Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.25 17.2602H2.75C1.64543 17.2602 0.706551 16.3538 0.919944 15.2701C1.25877 13.5493 2.15049 12.3257 4 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.75 17.2601H21.25C22.3546 17.2601 23.2935 16.3538 23.08 15.27C22.7412 13.5493 21.8495 12.3257 20 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

3
assets/icons/scan.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="M7.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V9.25M16.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V9.25M21.25 14.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.75M7.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V14.75M7.75 9.75V14.25M16.25 9.75V14.25M12 9.75V12.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 468 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M13 19.25H5.95C4.82989 19.25 4.26984 19.25 3.84202 19.032C3.46569 18.8403 3.15973 18.5343 2.96799 18.158C2.75 17.7302 2.75 17.1701 2.75 16.05V7.95C2.75 6.82989 2.75 6.26984 2.96799 5.84202C3.15973 5.46569 3.46569 5.15973 3.84202 4.96799C4.26984 4.75 4.8299 4.75 5.95 4.75H18.05C19.1701 4.75 19.7302 4.75 20.158 4.96799C20.5343 5.15973 20.8403 5.46569 21.032 5.84202C21.25 6.26984 21.25 6.8299 21.25 7.95V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3 5.63635L10.9761 10.3898C11.6069 10.7657 12.3931 10.7657 13.0239 10.3898L21 5.63635" stroke="currentColor" stroke-width="1.5"/><path d="M21.8148 15.375L21.1669 15.7491M16.1856 18.625L16.8335 18.2509M21.8147 18.625L21.1669 18.251M16.1855 15.375L16.8335 15.7491M19.0002 20.25L19.0002 19.4375M19.0002 13.75V14.5625M21.1669 17C21.1669 16.6053 21.0613 16.2352 20.8769 15.9165C20.5022 15.269 19.8021 14.8333 19.0002 14.8333C18.1983 14.8333 17.4981 15.269 17.1235 15.9165C16.9391 16.2352 16.8335 16.6053 16.8335 17C16.8335 17.3947 16.9391 17.7648 17.1235 18.0835C17.4982 18.731 18.1983 19.1667 19.0002 19.1667C19.8021 19.1667 20.5022 18.731 20.8769 18.0835C21.0613 17.7648 21.1669 17.3947 21.1669 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::http_client::{AsyncBody, HttpClient};
use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
@@ -11,7 +11,7 @@ use gpui::{
};
use semver::Version;
use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
use smallvec::{SmallVec, smallvec};
use smol::fs::File;
use smol::io::AsyncReadExt;
use smol::process::Command;
@@ -20,11 +20,11 @@ const GITHUB_API_URL: &str = "https://api.github.com";
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
fn get_github_repo_owner() -> String {
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "your-username".to_string())
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
}
fn get_github_repo_name() -> String {
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "your-repo".to_string())
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
}
fn is_flatpak_installation() -> bool {

View File

@@ -1,20 +1,20 @@
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use anyhow::{Context as AnyhowContext, Error, anyhow};
use common::EventUtils;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP};
use smallvec::{SmallVec, smallvec};
use state::{DEVICE_GIFTWRAP, NostrRegistry, RelayState, TIMEOUT, USER_GIFTWRAP};
mod message;
mod room;
@@ -113,15 +113,19 @@ impl ChatRegistry {
subscriptions.push(
// Observe the nip65 state and load chat rooms on every state change
cx.observe(&nostr, |this, state, cx| {
match state.read(cx).relay_list_state() {
match state.read(cx).relay_list_state {
RelayState::Idle => {
this.reset(cx);
}
RelayState::Configured => {
this.get_contact_list(cx);
this.ensure_messaging_relays(cx);
}
_ => {}
}
// Load rooms on every state change
this.get_rooms(cx);
}),
);
@@ -137,13 +141,8 @@ impl ChatRegistry {
// Run at the end of the current cycle
cx.defer_in(window, |this, _window, cx| {
// Load chat rooms
this.get_rooms(cx);
// Handle nostr notifications
this.handle_notifications(cx);
// Track unwrap gift wrap progress
this.tracking(cx);
});
@@ -248,7 +247,7 @@ impl ChatRegistry {
let status = self.tracking_flag.clone();
self.tasks.push(cx.background_spawn(async move {
let loop_duration = Duration::from_secs(10);
let loop_duration = Duration::from_secs(15);
loop {
if status.load(Ordering::Acquire) {
@@ -259,6 +258,46 @@ impl ChatRegistry {
}));
}
/// Get contact list from relays
pub fn get_contact_list(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let Some(public_key) = signer.public_key() else {
return;
};
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let id = SubscriptionId::new("contact-list");
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
// Get user's write relays
let urls = write_relays.await;
// Construct filter for inbox relays
let filter = Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1);
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> =
urls.iter().map(|relay| (relay, filter.clone())).collect();
// Subscribe
client.subscribe(target).close_on(opts).with_id(id).await?;
Ok(())
});
self.tasks.push(task);
}
/// Ensure messaging relays are set up for the current user.
pub fn ensure_messaging_relays(&mut self, cx: &mut Context<Self>) {
let task = self.verify_relays(cx);
@@ -282,20 +321,30 @@ impl ChatRegistry {
fn verify_relays(&mut self, cx: &mut Context<Self>) -> Task<Result<InboxState, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let Some(public_key) = signer.public_key() else {
return Task::ready(Err(anyhow!("User not found")));
};
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
cx.background_spawn(async move {
let urls = write_relays.await;
// Construct filter for inbox relays
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> =
urls.iter().map(|relay| (relay, filter.clone())).collect();
// Stream events from user's write relays
let mut stream = client
.stream_events(filter)
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await?;
@@ -642,14 +691,12 @@ impl ChatRegistry {
}
/// Trigger a refresh of the opened chat rooms by their IDs
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
if let Some(ids) = ids {
for room in self.rooms.iter() {
if ids.contains(&room.read(cx).id) {
room.update(cx, |this, cx| {
this.emit_refresh(cx);
});
}
pub fn refresh_rooms(&mut self, ids: &[u64], cx: &mut Context<Self>) {
for room in self.rooms.iter() {
if ids.contains(&room.read(cx).id) {
room.update(cx, |this, cx| {
this.emit_refresh(cx);
});
}
}
}
@@ -696,7 +743,7 @@ async fn try_unwrap(
// Try with the user's signer
let user_signer = client.signer().context("Signer not found")?;
let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?;
let unwrapped = try_unwrap_with(gift_wrap, user_signer).await?;
Ok(unwrapped)
}

View File

@@ -1,6 +1,7 @@
use std::hash::Hash;
use std::ops::Range;
use common::EventUtils;
use common::{EventUtils, NostrParser};
use nostr_sdk::prelude::*;
/// New message.
@@ -91,6 +92,18 @@ impl PartialOrd for Message {
}
}
#[derive(Debug, Clone)]
pub struct Mention {
pub public_key: PublicKey,
pub range: Range<usize>,
}
impl Mention {
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
Self { public_key, range }
}
}
/// Rendered message.
#[derive(Debug, Clone)]
pub struct RenderedMessage {
@@ -102,7 +115,7 @@ pub struct RenderedMessage {
/// Message created time as unix timestamp
pub created_at: Timestamp,
/// List of mentioned public keys in the message
pub mentions: Vec<PublicKey>,
pub mentions: Vec<Mention>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
}
@@ -184,20 +197,17 @@ impl Hash for RenderedMessage {
}
/// Extracts all mentions (public keys) from a content string.
fn extract_mentions(content: &str) -> Vec<PublicKey> {
fn extract_mentions(content: &str) -> Vec<Mention> {
let parser = NostrParser::new();
let tokens = parser.parse(content);
tokens
.filter_map(|token| match token {
Token::Nostr(nip21) => match nip21 {
Nip21::Pubkey(pubkey) => Some(pubkey),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
.filter_map(|token| match token.value {
Nip21::Pubkey(public_key) => Some(Mention::new(public_key, token.range)),
Nip21::Profile(profile) => Some(Mention::new(profile.public_key, token.range)),
_ => None,
})
.collect::<Vec<_>>()
.collect()
}
/// Extracts all reply (ids) from the event tags.

View File

@@ -10,7 +10,7 @@ use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT};
use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
use crate::NewMessage;
@@ -153,7 +153,7 @@ impl From<&UnsignedEvent> for Room {
subject,
members,
kind: RoomKind::default(),
config: RoomConfig::default(),
config: RoomConfig::new(),
}
}
}
@@ -232,6 +232,12 @@ impl Room {
cx.notify();
}
/// Updates the backup config for the room
pub fn set_backup(&mut self, cx: &mut Context<Self>) {
self.config.toggle_backup();
cx.notify();
}
/// Returns the config of the room
pub fn config(&self) -> &RoomConfig {
&self.config
@@ -319,70 +325,53 @@ impl Room {
cx.emit(RoomEvent::Reload);
}
#[allow(clippy::type_complexity)]
/// Get gossip relays for each member
pub fn connect(&self, cx: &App) -> HashMap<PublicKey, Task<Result<(bool, bool), Error>>> {
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let sender = signer.public_key();
let members = self.members();
let mut tasks = HashMap::new();
// Get room's id
let id = self.id;
for member in members.into_iter() {
let client = nostr.read(cx).client();
// Get all members, excluding the sender
let members: Vec<PublicKey> = self
.members
.iter()
.filter(|public_key| Some(**public_key) != sender)
.copied()
.collect();
// Skip if member is the current user
if member == public_key {
continue;
}
cx.background_spawn(async move {
let id = SubscriptionId::new(format!("room-{id}"));
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
tasks.insert(
member,
cx.background_spawn(async move {
let mut has_inbox = false;
let mut has_announcement = false;
// Construct filters for each member
let filters: Vec<Filter> = members
.into_iter()
.map(|public_key| {
Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1)
})
.collect();
// Construct filters for inbox
let inbox = Filter::new()
.kind(Kind::InboxRelays)
.author(member)
.limit(1);
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, filters.clone()))
.collect();
// Construct filters for announcement
let announcement = Filter::new()
.kind(Kind::Custom(10044))
.author(member)
.limit(1);
// Subscribe to the target
client.subscribe(target).close_on(opts).with_id(id).await?;
// Stream events from user's write relays
let mut stream = client
.stream_events(vec![inbox.clone(), announcement.clone()])
.timeout(Duration::from_secs(TIMEOUT))
.await?;
while let Some((_url, res)) = stream.next().await {
let event = res?;
match event.kind {
Kind::InboxRelays => has_inbox = true,
Kind::Custom(10044) => has_announcement = true,
_ => {}
}
// Early exit if both flags are found
if has_inbox && has_announcement {
break;
}
}
Ok((has_inbox, has_announcement))
}),
);
}
tasks
Ok(())
})
}
/// Get all messages belonging to the room
@@ -425,7 +414,7 @@ impl Room {
// Get current user's public key
let sender = nostr.read(cx).signer().public_key()?;
// Get all members
// Get all members, excluding the sender
let members: Vec<Person> = self
.members
.iter()
@@ -552,7 +541,9 @@ impl Room {
reports.push(report);
sents += 1;
}
Err(report) => reports.push(report),
Err(report) => {
reports.push(report);
}
}
}

View File

@@ -28,3 +28,5 @@ serde_json.workspace = true
once_cell = "1.19.0"
regex = "1"
linkify = "0.10.0"
pulldown-cmark = "0.13.1"

View File

@@ -9,6 +9,7 @@ pub enum Command {
Insert(&'static str),
ChangeSubject(&'static str),
ChangeSigner(SignerKind),
ToggleBackup,
}
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]

View File

@@ -1,5 +1,6 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc;
use std::time::Duration;
pub use actions::*;
use anyhow::{Context as AnyhowContext, Error};
@@ -7,19 +8,19 @@ use chat::{Message, RenderedMessage, Room, RoomEvent, SendReport};
use common::RenderedTimestamp;
use gpui::prelude::FluentBuilder;
use gpui::{
deferred, div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext,
ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement,
PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage,
Subscription, Task, WeakEntity, Window,
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement,
Styled, StyledImage, Subscription, Task, WeakEntity, Window, deferred, div, img, list, px, red,
relative, svg, white,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use settings::{AppSettings, SignerKind};
use smallvec::{smallvec, SmallVec};
use smallvec::{SmallVec, smallvec};
use smol::lock::RwLock;
use state::{upload, NostrRegistry};
use state::{NostrRegistry, upload};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
@@ -30,8 +31,8 @@ use ui::menu::{ContextMenuExt, DropdownMenu};
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::{
h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
WindowExtension,
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
h_flex, v_flex,
};
use crate::text::RenderedText;
@@ -39,10 +40,13 @@ use crate::text::RenderedText;
mod actions;
mod text;
const ANNOUNCEMENT: &str =
"This conversation is private. Only members can see each other's messages.";
const NO_INBOX: &str = "has not set up messaging relays. \
They will not receive your messages.";
They will not receive messages you send.";
const NO_ANNOUNCEMENT: &str = "has not set up an encryption key. \
You cannot send messages encrypted with an encryption key to them yet.";
You cannot send messages encrypted with an encryption key to them yet. \
Coop automatically uses your identity to encrypt messages.";
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
cx.new(|cx| ChatPanel::new(room, window, cx))
@@ -134,7 +138,6 @@ impl ChatPanel {
cx.defer_in(window, |this, window, cx| {
this.connect(window, cx);
this.handle_notifications(cx);
this.subscribe_room_events(window, cx);
this.get_messages(window, cx);
});
@@ -157,6 +160,49 @@ impl ChatPanel {
}
}
/// Get all necessary data for each member
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok((members, connect)) = self
.room
.read_with(cx, |this, cx| (this.members(), this.connect(cx)))
else {
return;
};
// Run the connect task in background
self.tasks.push(connect);
// Spawn another task to verify after 3 seconds
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
// Verify the connection
this.update_in(cx, |this, _window, cx| {
let persons = PersonRegistry::global(cx);
for member in members.into_iter() {
let profile = persons.read(cx).get(&member, cx);
if profile.announcement().is_none() {
let content = format!("{} {}", profile.name(), NO_ANNOUNCEMENT);
let message = Message::warning(content);
this.insert_message(message, true, cx);
}
if profile.messaging_relays().is_empty() {
let content = format!("{} {}", profile.name(), NO_INBOX);
let message = Message::warning(content);
this.insert_message(message, true, cx);
}
}
})?;
Ok(())
}));
}
/// Handle nostr notifications
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -227,46 +273,6 @@ impl ChatPanel {
);
}
/// Get all necessary data for each member
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(tasks) = self.room.read_with(cx, |this, cx| this.connect(cx)) else {
return;
};
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
for (member, task) in tasks.into_iter() {
match task.await {
Ok((has_inbox, has_announcement)) => {
this.update(cx, |this, cx| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&member, cx);
if !has_inbox {
let content = format!("{} {}", profile.name(), NO_INBOX);
let message = Message::warning(content);
this.insert_message(message, true, cx);
}
if !has_announcement {
let content = format!("{} {}", profile.name(), NO_ANNOUNCEMENT);
let message = Message::warning(content);
this.insert_message(message, true, cx);
}
})?;
}
Err(e) => {
this.update(cx, |this, cx| {
this.insert_message(Message::warning(e.to_string()), true, cx);
})?;
}
};
}
Ok(())
}));
}
/// Load all messages belonging to this room
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else {
@@ -363,10 +369,12 @@ impl ChatPanel {
// This can't fail, because we already ensured that the ID is set
let id = rumor.id.unwrap();
// Upgrade room reference
let Some(room) = self.room.upgrade() else {
return;
};
// Get the send message task
let Some(task) = room.read(cx).send(rumor, cx) else {
window.push_notification("Failed to send message", cx);
return;
@@ -612,13 +620,21 @@ impl ChatPanel {
window.push_notification(Notification::error("Failed to change signer"), cx);
}
}
Command::ToggleBackup => {
if self
.room
.update(cx, |this, cx| {
this.set_backup(cx);
})
.is_err()
{
window.push_notification(Notification::error("Failed to toggle backup"), cx);
}
}
}
}
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
const MSG: &str =
"This conversation is private. Only members can see each other's messages.";
v_flex()
.id(ix)
.h_40()
@@ -637,19 +653,19 @@ impl ChatPanel {
.size_12()
.text_color(cx.theme().ghost_element_active),
)
.child(SharedString::from(MSG))
.child(SharedString::from(ANNOUNCEMENT))
.into_any_element()
}
fn render_warning(&self, ix: usize, content: SharedString, cx: &Context<Self>) -> AnyElement {
div()
.id(ix)
.relative()
.w_full()
.py_2()
.px_3()
.child(
h_flex()
.w_full()
.gap_3()
.text_sm()
.child(
@@ -662,16 +678,14 @@ impl ChatPanel {
.text_color(cx.theme().warning_foreground)
.child(Icon::new(IconName::Warning).small()),
)
.child(content),
)
.child(
div()
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().warning_active),
.child(
div()
.flex_1()
.w_full()
.flex_initial()
.overflow_hidden()
.child(content),
),
)
.into_any_element()
}
@@ -685,10 +699,13 @@ impl ChatPanel {
if let Some(message) = self.messages.iter().nth(ix) {
match message {
Message::User(rendered) => {
let persons = PersonRegistry::global(cx);
let text = self
.rendered_texts_by_id
.entry(rendered.id)
.or_insert_with(|| RenderedText::new(&rendered.content, cx))
.or_insert_with(|| {
RenderedText::new(&rendered.content, &rendered.mentions, &persons, cx)
})
.element(ix.into(), window, cx);
self.render_text_message(ix, rendered, text, cx)
@@ -744,7 +761,7 @@ impl ChatPanel {
this.child(
div()
.id(SharedString::from(format!("{ix}-avatar")))
.child(Avatar::new(author.avatar()).size(rems(2.)))
.child(Avatar::new(author.avatar()))
.context_menu(move |this, _window, _cx| {
let view = Box::new(OpenPublicKey(public_key));
let copy = Box::new(CopyPublicKey(public_key));
@@ -862,7 +879,7 @@ impl ChatPanel {
window.open_modal(cx, move |this, _window, cx| {
this.show_close(true)
.title(SharedString::from("Sent Reports"))
.child(v_flex().pb_4().gap_4().children({
.child(v_flex().pb_2().gap_4().children({
let mut items = Vec::with_capacity(reports.len());
for report in reports.iter() {
@@ -880,7 +897,7 @@ impl ChatPanel {
h_flex()
.id(SharedString::from(id.to_hex()))
.gap_0p5()
.text_color(cx.theme().danger_foreground)
.text_color(cx.theme().danger_active)
.text_xs()
.italic()
.child(Icon::new(IconName::Info).xsmall())
@@ -926,7 +943,7 @@ impl ChatPanel {
h_flex()
.gap_1()
.font_semibold()
.child(Avatar::new(avatar).size(rems(1.25)))
.child(Avatar::new(avatar).small())
.child(name.clone()),
),
)
@@ -935,13 +952,13 @@ impl ChatPanel {
h_flex()
.flex_wrap()
.justify_center()
.p_2()
.h_20()
.p_1()
.h_16()
.w_full()
.text_sm()
.rounded(cx.theme().radius)
.bg(cx.theme().danger_background)
.text_color(cx.theme().danger_foreground)
.bg(cx.theme().warning_background)
.text_color(cx.theme().warning_foreground)
.child(div().flex_1().w_full().text_center().child(error)),
)
})
@@ -957,11 +974,10 @@ impl ChatPanel {
items.push(
v_flex()
.gap_0p5()
.py_1()
.px_2()
.p_1()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.bg(cx.theme().danger_background)
.child(
div()
.text_xs()
@@ -971,7 +987,7 @@ impl ChatPanel {
)
.child(
div()
.text_sm()
.text_xs()
.text_color(cx.theme().danger_foreground)
.line_height(relative(1.25))
.child(SharedString::from(msg.to_string())),
@@ -988,8 +1004,7 @@ impl ChatPanel {
items.push(
v_flex()
.gap_0p5()
.py_1()
.px_2()
.p_1()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
@@ -1002,8 +1017,7 @@ impl ChatPanel {
)
.child(
div()
.text_sm()
.text_color(cx.theme().secondary_foreground)
.text_xs()
.line_height(relative(1.25))
.child(SharedString::from("Successfully")),
),
@@ -1196,15 +1210,18 @@ impl ChatPanel {
items
}
fn render_encryption_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let signer_kind = self
fn render_config_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let (backup, signer_kind) = self
.room
.read_with(cx, |this, _cx| this.config().signer_kind().clone())
.read_with(cx, |this, _cx| {
(this.config().backup(), this.config().signer_kind().clone())
})
.ok()
.unwrap_or_default();
.unwrap_or((true, SignerKind::default()));
Button::new("encryption")
.icon(IconName::UserKey)
.icon(IconName::Settings2)
.tooltip("Configuration")
.ghost()
.large()
.dropdown_menu(move |this, _window, _cx| {
@@ -1212,24 +1229,28 @@ impl ChatPanel {
let encryption = matches!(signer_kind, SignerKind::Encryption);
let user = matches!(signer_kind, SignerKind::User);
this.menu_with_check_and_disabled(
"Auto",
auto,
Box::new(Command::ChangeSigner(SignerKind::Auto)),
auto,
)
.menu_with_check_and_disabled(
"Decoupled Encryption Key",
encryption,
Box::new(Command::ChangeSigner(SignerKind::Encryption)),
encryption,
)
.menu_with_check_and_disabled(
"User Identity",
user,
Box::new(Command::ChangeSigner(SignerKind::User)),
user,
)
this.label("Signer")
.menu_with_check_and_disabled(
"Auto",
auto,
Box::new(Command::ChangeSigner(SignerKind::Auto)),
auto,
)
.menu_with_check_and_disabled(
"Decoupled Encryption Key",
encryption,
Box::new(Command::ChangeSigner(SignerKind::Encryption)),
encryption,
)
.menu_with_check_and_disabled(
"User Identity",
user,
Box::new(Command::ChangeSigner(SignerKind::User)),
user,
)
.separator()
.label("Backup")
.menu_with_check("Backup messages", backup, Box::new(Command::ToggleBackup))
})
}
@@ -1265,7 +1286,7 @@ impl Panel for ChatPanel {
h_flex()
.gap_1p5()
.child(Avatar::new(url).size(rems(1.25)))
.child(Avatar::new(url).small())
.child(label)
.into_any_element()
})
@@ -1287,9 +1308,9 @@ impl Render for ChatPanel {
.on_action(cx.listener(Self::on_command))
.size_full()
.child(
div()
v_flex()
.flex_1()
.size_full()
.relative()
.child(
list(
self.list_state.clone(),
@@ -1327,15 +1348,15 @@ impl Render for ChatPanel {
.child(
TextInput::new(&self.input)
.appearance(false)
.flex_1()
.text_sm(),
.text_sm()
.flex_1(),
)
.child(
h_flex()
.pl_1()
.gap_1()
.child(self.render_emoji_menu(window, cx))
.child(self.render_encryption_menu(window, cx))
.child(self.render_config_menu(window, cx))
.child(
Button::new("send")
.icon(IconName::PaperPlaneFill)

View File

@@ -1,29 +1,29 @@
use std::ops::Range;
use std::sync::Arc;
use chat::Mention;
use common::RangeExt;
use gpui::{
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
StyledText, UnderlineStyle, Window,
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
};
use nostr_sdk::prelude::*;
use once_cell::sync::Lazy;
use person::PersonRegistry;
use regex::Regex;
use theme::ActiveTheme;
use crate::actions::OpenPublicKey;
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?:^|\s)(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?(?:\s|$)").unwrap()
});
static NOSTR_URI_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
#[allow(clippy::enum_variant_names)]
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Link,
Nostr,
Code,
InlineCode(bool),
Highlight(HighlightStyle),
Mention,
}
impl From<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Highlight(style)
}
}
#[derive(Default)]
@@ -35,7 +35,12 @@ pub struct RenderedText {
}
impl RenderedText {
pub fn new(content: &str, cx: &App) -> Self {
pub fn new(
content: &str,
mentions: &[Mention],
persons: &Entity<PersonRegistry>,
cx: &App,
) -> Self {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
@@ -43,10 +48,12 @@ impl RenderedText {
render_plain_text_mut(
content,
mentions,
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
persons,
cx,
);
@@ -61,7 +68,7 @@ impl RenderedText {
}
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let link_color = cx.theme().text_accent;
let code_background = cx.theme().elevated_surface_background;
InteractiveText::new(
id,
@@ -71,15 +78,35 @@ impl RenderedText {
(
range.clone(),
match highlight {
Highlight::Link => HighlightStyle {
color: Some(link_color),
underline: Some(UnderlineStyle::default()),
Highlight::Code => HighlightStyle {
background_color: Some(code_background),
..Default::default()
},
Highlight::Nostr => HighlightStyle {
color: Some(link_color),
Highlight::InlineCode(link) => {
if *link {
HighlightStyle {
background_color: Some(code_background),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}
} else {
HighlightStyle {
background_color: Some(code_background),
..Default::default()
}
}
}
Highlight::Mention => HighlightStyle {
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
},
Highlight::Highlight(highlight) => *highlight,
},
)
}),
@@ -87,22 +114,10 @@ impl RenderedText {
)
.on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone();
move |ix, window, cx| {
let token = link_urls[ix].as_str();
if let Some(clean_url) = token.strip_prefix("nostr:") {
if let Ok(public_key) = PublicKey::parse(clean_url) {
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
}
} else if is_url(token) {
let url = if token.starts_with("http") {
token.to_string()
} else {
format!("https://{token}")
};
cx.open_url(&url);
} else {
log::warn!("Unrecognized token {token}")
move |ix, _, cx| {
let url = &link_urls[ix];
if url.starts_with("http") {
cx.open_url(url);
}
}
})
@@ -110,214 +125,273 @@ impl RenderedText {
}
}
#[allow(clippy::too_many_arguments)]
fn render_plain_text_mut(
content: &str,
block: &str,
mut mentions: &[Mention],
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
persons: &Entity<PersonRegistry>,
cx: &App,
) {
// Copy the content directly
text.push_str(content);
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
// Collect all URLs
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
let mut bold_depth = 0;
let mut italic_depth = 0;
let mut strikethrough_depth = 0;
let mut link_url = None;
let mut list_stack = Vec::new();
for link in URL_REGEX.find_iter(content) {
let range = link.start()..link.end();
let url = link.as_str().to_string();
let mut options = Options::all();
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
url_matches.push((range, url));
}
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
let prev_len = text.len();
// Collect all nostr entities with nostr: prefix
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
match event {
Event::Text(t) => {
// Process text with mention replacements
let t_str = t.as_ref();
let mut last_processed = 0;
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
let range = nostr_match.start()..nostr_match.end();
let nostr_uri = nostr_match.as_str().to_string();
while let Some(mention) = mentions.first() {
if !source_range.contains_inclusive(&mention.range) {
break;
}
// Check if this nostr URI overlaps with any already processed URL
if !url_matches
.iter()
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end)
{
nostr_matches.push((range, nostr_uri));
}
}
// Calculate positions within the current text
let mention_start_in_text = mention.range.start - source_range.start;
let mention_end_in_text = mention.range.end - source_range.start;
// Combine all matches for processing from end to start
let mut all_matches = Vec::new();
all_matches.extend(url_matches);
all_matches.extend(nostr_matches);
// Add text before this mention
if mention_start_in_text > last_processed {
let before_mention = &t_str[last_processed..mention_start_in_text];
process_text_segment(
before_mention,
prev_len + last_processed,
bold_depth,
italic_depth,
strikethrough_depth,
link_url.clone(),
text,
highlights,
link_ranges,
link_urls,
);
}
// Sort by position (end to start) to avoid changing positions when replacing text
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
// Process the mention replacement
let profile = persons.read(cx).get(&mention.public_key, cx);
let replacement_text = format!("@{}", profile.name());
// Process all matches
for (range, entity) in all_matches {
// Handle URL token
if is_url(&entity) {
highlights.push((range.clone(), Highlight::Link));
link_ranges.push(range);
link_urls.push(entity);
continue;
};
let replacement_start = text.len();
text.push_str(&replacement_text);
let replacement_end = text.len();
if let Ok(nip21) = Nip21::parse(&entity) {
match nip21 {
Nip21::Pubkey(public_key) => {
render_pubkey(
public_key,
text,
&range,
highlights,
link_ranges,
link_urls,
cx,
);
highlights.push((replacement_start..replacement_end, Highlight::Mention));
last_processed = mention_end_in_text;
mentions = &mentions[1..];
}
Nip21::Profile(nip19_profile) => {
render_pubkey(
nip19_profile.public_key,
// Add any remaining text after the last mention
if last_processed < t_str.len() {
let remaining_text = &t_str[last_processed..];
process_text_segment(
remaining_text,
prev_len + last_processed,
bold_depth,
italic_depth,
strikethrough_depth,
link_url.clone(),
text,
&range,
highlights,
link_ranges,
link_urls,
cx,
);
}
Nip21::EventId(event_id) => {
render_bech32(
event_id.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Event(nip19_event) => {
render_bech32(
nip19_event.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Coordinate(nip19_coordinate) => {
render_bech32(
nip19_coordinate.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
}
Event::Code(t) => {
text.push_str(t.as_ref());
let is_link = link_url.is_some();
if let Some(link_url) = link_url.clone() {
link_ranges.push(prev_len..text.len());
link_urls.push(link_url);
}
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
}
Event::Start(tag) => match tag {
Tag::Paragraph => new_paragraph(text, &mut list_stack),
Tag::Heading { .. } => {
new_paragraph(text, &mut list_stack);
bold_depth += 1;
}
Tag::CodeBlock(_kind) => {
new_paragraph(text, &mut list_stack);
}
Tag::Emphasis => italic_depth += 1,
Tag::Strong => bold_depth += 1,
Tag::Strikethrough => strikethrough_depth += 1,
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
Tag::List(number) => {
list_stack.push((number, false));
}
Tag::Item => {
let len = list_stack.len();
if let Some((list_number, has_content)) = list_stack.last_mut() {
*has_content = false;
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
for _ in 0..len - 1 {
text.push_str(" ");
}
if let Some(number) = list_number {
text.push_str(&format!("{}. ", number));
*number += 1;
*has_content = false;
} else {
text.push_str("- ");
}
}
}
_ => {}
},
Event::End(tag) => match tag {
TagEnd::Heading(_) => bold_depth -= 1,
TagEnd::Emphasis => italic_depth -= 1,
TagEnd::Strong => bold_depth -= 1,
TagEnd::Strikethrough => strikethrough_depth -= 1,
TagEnd::Link => link_url = None,
TagEnd::List(_) => drop(list_stack.pop()),
_ => {}
},
Event::HardBreak => text.push('\n'),
Event::SoftBreak => text.push('\n'),
_ => {}
}
}
}
/// Check if a string is a URL
fn is_url(s: &str) -> bool {
URL_REGEX.is_match(s)
}
#[allow(clippy::too_many_arguments)]
fn process_text_segment(
segment: &str,
segment_start: usize,
bold_depth: i32,
italic_depth: i32,
strikethrough_depth: i32,
link_url: Option<String>,
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
// Build the style for this segment
let mut style = HighlightStyle::default();
if bold_depth > 0 {
style.font_weight = Some(FontWeight::BOLD);
}
if italic_depth > 0 {
style.font_style = Some(FontStyle::Italic);
}
if strikethrough_depth > 0 {
style.strikethrough = Some(StrikethroughStyle {
thickness: 1.0.into(),
..Default::default()
});
}
/// Format a bech32 entity with ellipsis and last 4 characters
fn format_shortened_entity(entity: &str) -> String {
let prefix_end = entity.find('1').unwrap_or(0);
// Add the text
text.push_str(segment);
let text_end = text.len();
if prefix_end > 0 && entity.len() > prefix_end + 5 {
let prefix = &entity[0..=prefix_end]; // Include the '1'
let suffix = &entity[entity.len() - 4..]; // Last 4 chars
if let Some(link_url) = link_url {
// Handle as a markdown link
link_ranges.push(segment_start..text_end);
link_urls.push(link_url);
style.underline = Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
});
format!("{prefix}...{suffix}")
// Add highlight for the entire linked segment
if style != HighlightStyle::default() {
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
}
} else {
entity.to_string()
}
}
// Handle link detection within the segment
let mut finder = linkify::LinkFinder::new();
finder.kinds(&[linkify::LinkKind::Url]);
let mut last_link_pos = 0;
fn render_pubkey(
public_key: PublicKey,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
let display_name = format!("@{}", profile.name());
for link in finder.links(segment) {
let start = link.start();
let end = link.end();
text.replace_range(range.clone(), &display_name);
// Add non-link text before this link
if start > last_link_pos {
let non_link_start = segment_start + last_link_pos;
let non_link_end = segment_start + start;
let new_length = display_name.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
let new_range = range.start..(range.start + new_length);
if style != HighlightStyle::default() {
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
}
}
highlights.push((new_range.clone(), Highlight::Nostr));
link_ranges.push(new_range);
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
// Add the link
let range = (segment_start + start)..(segment_start + end);
link_ranges.push(range.clone());
link_urls.push(link.as_str().to_string());
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
// Apply link styling (underline + existing style)
let mut link_style = style;
link_style.underline = Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
});
fn render_bech32(
bech32: String,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
let njump_url = format!("https://njump.me/{bech32}");
let shortened_entity = format_shortened_entity(&bech32);
let display_text = format!("https://njump.me/{shortened_entity}");
highlights.push((range, Highlight::Highlight(link_style)));
text.replace_range(range.clone(), &display_text);
let new_length = display_text.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
let new_range = range.start..(range.start + new_length);
highlights.push((new_range.clone(), Highlight::Link));
link_ranges.push(new_range);
link_urls.push(njump_url);
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
// Helper function to adjust ranges when text length changes
fn adjust_ranges(
highlights: &mut [(Range<usize>, Highlight)],
link_ranges: &mut [Range<usize>],
position: usize,
length_diff: isize,
) {
// Adjust highlight ranges
for (range, _) in highlights.iter_mut() {
if range.start > position {
range.start = (range.start as isize + length_diff) as usize;
range.end = (range.end as isize + length_diff) as usize;
last_link_pos = end;
}
}
// Adjust link ranges
for range in link_ranges.iter_mut() {
if range.start > position {
range.start = (range.start as isize + length_diff) as usize;
range.end = (range.end as isize + length_diff) as usize;
// Add any remaining text after the last link
if last_link_pos < segment.len() {
let remaining_start = segment_start + last_link_pos;
let remaining_end = segment_start + segment.len();
if style != HighlightStyle::default() {
highlights.push((remaining_start..remaining_end, Highlight::Highlight(style)));
}
}
}
}
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
let mut is_subsequent_paragraph_of_list = false;
if let Some((_, has_content)) = list_stack.last_mut() {
if *has_content {
is_subsequent_paragraph_of_list = true;
} else {
*has_content = true;
return;
}
}
if !text.is_empty() {
if !text.ends_with('\n') {
text.push('\n');
}
text.push('\n');
}
for _ in 0..list_stack.len().saturating_sub(1) {
text.push_str(" ");
}
if is_subsequent_paragraph_of_list {
text.push_str(" ");
}
}

View File

@@ -19,3 +19,4 @@ log.workspace = true
dirs = "5.0"
qrcode = "0.14.1"
bech32 = "0.11.1"

View File

@@ -1,9 +1,13 @@
pub use debounced_delay::*;
pub use display::*;
pub use event::*;
pub use parser::*;
pub use paths::*;
pub use range::*;
mod debounced_delay;
mod display;
mod event;
mod parser;
mod paths;
mod range;

210
crates/common/src/parser.rs Normal file
View File

@@ -0,0 +1,210 @@
use std::ops::Range;
use nostr::prelude::*;
const BECH32_SEPARATOR: u8 = b'1';
const SCHEME_WITH_COLON: &str = "nostr:";
/// Nostr parsed token with its range in the original text
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
/// The parsed NIP-21 URI
///
/// <https://github.com/nostr-protocol/nips/blob/master/21.md>
pub value: Nip21,
/// The range of this token in the original text
pub range: Range<usize>,
}
#[derive(Debug, Clone, Copy)]
struct Match {
start: usize,
end: usize,
}
/// Nostr parser
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NostrParser;
impl Default for NostrParser {
fn default() -> Self {
Self::new()
}
}
impl NostrParser {
/// Create new parser
pub const fn new() -> Self {
Self
}
/// Parse text
pub fn parse<'a>(&self, text: &'a str) -> NostrParserIter<'a> {
NostrParserIter::new(text)
}
}
struct FindMatches<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> FindMatches<'a> {
fn new(text: &'a str) -> Self {
Self {
bytes: text.as_bytes(),
pos: 0,
}
}
fn try_parse_nostr_uri(&mut self) -> Option<Match> {
let start = self.pos;
let bytes = self.bytes;
let len = bytes.len();
// Check if we have "nostr:" prefix
if len - start < SCHEME_WITH_COLON.len() {
return None;
}
// Check for "nostr:" prefix (case-insensitive)
let scheme_prefix = &bytes[start..start + SCHEME_WITH_COLON.len()];
if !scheme_prefix.eq_ignore_ascii_case(SCHEME_WITH_COLON.as_bytes()) {
return None;
}
// Skip the scheme
let pos = start + SCHEME_WITH_COLON.len();
// Parse bech32 entity
let mut has_separator = false;
let mut end = pos;
while end < len {
let byte = bytes[end];
// Check for bech32 separator
if byte == BECH32_SEPARATOR && !has_separator {
has_separator = true;
end += 1;
continue;
}
// Check if character is valid for bech32
if !byte.is_ascii_alphanumeric() {
break;
}
end += 1;
}
// Must have at least one character after separator
if !has_separator || end <= pos + 1 {
return None;
}
// Update position
self.pos = end;
Some(Match { start, end })
}
}
impl Iterator for FindMatches<'_> {
type Item = Match;
fn next(&mut self) -> Option<Self::Item> {
while self.pos < self.bytes.len() {
// Try to parse nostr URI
if let Some(mat) = self.try_parse_nostr_uri() {
return Some(mat);
}
// Skip one character if no match found
self.pos += 1;
}
None
}
}
enum HandleMatch {
Token(Token),
Recursion,
}
pub struct NostrParserIter<'a> {
/// The original text
text: &'a str,
/// Matches found
matches: FindMatches<'a>,
/// A pending match
pending_match: Option<Match>,
/// Last match end index
last_match_end: usize,
}
impl<'a> NostrParserIter<'a> {
fn new(text: &'a str) -> Self {
Self {
text,
matches: FindMatches::new(text),
pending_match: None,
last_match_end: 0,
}
}
fn handle_match(&mut self, mat: Match) -> HandleMatch {
// Update last match end
self.last_match_end = mat.end;
// Extract the matched string
let data: &str = &self.text[mat.start..mat.end];
// Parse NIP-21 URI
match Nip21::parse(data) {
Ok(uri) => HandleMatch::Token(Token {
value: uri,
range: mat.start..mat.end,
}),
// If the nostr URI parsing is invalid, skip it
Err(_) => HandleMatch::Recursion,
}
}
}
impl<'a> Iterator for NostrParserIter<'a> {
type Item = Token;
fn next(&mut self) -> Option<Self::Item> {
// Handle a pending match
if let Some(pending_match) = self.pending_match.take() {
return match self.handle_match(pending_match) {
HandleMatch::Token(token) => Some(token),
HandleMatch::Recursion => self.next(),
};
}
match self.matches.next() {
Some(mat) => {
// Skip any text before this match
if mat.start > self.last_match_end {
// Update pending match
// This will be handled at next iteration, in `handle_match` method.
self.pending_match = Some(mat);
// Skip the text before the match
self.last_match_end = mat.start;
return self.next();
}
// Handle match
match self.handle_match(mat) {
HandleMatch::Token(token) => Some(token),
HandleMatch::Recursion => self.next(),
}
}
None => None,
}
}
}

View File

@@ -0,0 +1,45 @@
use std::cmp::{self};
use std::ops::{Range, RangeInclusive};
pub trait RangeExt<T> {
fn sorted(&self) -> Self;
fn to_inclusive(&self) -> RangeInclusive<T>;
fn overlaps(&self, other: &Range<T>) -> bool;
fn contains_inclusive(&self, other: &Range<T>) -> bool;
}
impl<T: Ord + Clone> RangeExt<T> for Range<T> {
fn sorted(&self) -> Self {
cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone()
}
fn to_inclusive(&self) -> RangeInclusive<T> {
self.start.clone()..=self.end.clone()
}
fn overlaps(&self, other: &Range<T>) -> bool {
self.start < other.end && other.start < self.end
}
fn contains_inclusive(&self, other: &Range<T>) -> bool {
self.start <= other.start && other.end <= self.end
}
}
impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
fn sorted(&self) -> Self {
cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone()
}
fn to_inclusive(&self) -> RangeInclusive<T> {
self.clone()
}
fn overlaps(&self, other: &Range<T>) -> bool {
self.start() < &other.end && &other.start <= self.end()
}
fn contains_inclusive(&self, other: &Range<T>) -> bool {
self.start() <= &other.start && &other.end <= self.end()
}
}

View File

@@ -14,7 +14,7 @@ product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "0.3.0"
version = "1.0.0-beta"
out-dir = "../../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
@@ -64,4 +64,8 @@ oneshot.workspace = true
webbrowser.workspace = true
indexset = "0.12.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Temporary workaround https://github.com/zed-industries/zed/issues/47168
core-text = "=21.0.0"

View File

@@ -0,0 +1,256 @@
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use state::{NostrRegistry, SignerEvent};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, WindowExtension};
use crate::dialogs::connect::ConnectSigner;
use crate::dialogs::import::ImportKey;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AccountSelector> {
cx.new(|cx| AccountSelector::new(window, cx))
}
/// Account selector
pub struct AccountSelector {
/// Public key currently being chosen for login
logging_in: Entity<Option<PublicKey>>,
/// The error message displayed when an error occurs.
error: Entity<Option<SharedString>>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Subscription to the signer events
_subscription: Option<Subscription>,
}
impl AccountSelector {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let logging_in = cx.new(|_| None);
let error = cx.new(|_| None);
// Subscribe to the signer events
let nostr = NostrRegistry::global(cx);
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| {
match event {
SignerEvent::Set => {
window.close_all_modals(cx);
window.refresh();
}
SignerEvent::Error(e) => {
this.set_error(e.to_string(), cx);
}
};
});
Self {
logging_in,
error,
tasks: vec![],
_subscription: Some(subscription),
}
}
fn logging_in(&self, public_key: &PublicKey, cx: &App) -> bool {
self.logging_in.read(cx) == &Some(*public_key)
}
fn set_logging_in(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
self.logging_in.update(cx, |this, cx| {
*this = Some(public_key);
cx.notify();
});
}
fn set_error<T>(&mut self, error: T, cx: &mut Context<Self>)
where
T: Into<SharedString>,
{
self.error.update(cx, |this, cx| {
*this = Some(error.into());
cx.notify();
});
self.logging_in.update(cx, |this, cx| {
*this = None;
cx.notify();
})
}
fn login(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).get_signer(&public_key, cx);
// Mark the public key as being logged in
self.set_logging_in(public_key, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(signer) => {
nostr.update(cx, |this, cx| {
this.set_signer(signer, cx);
});
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})?;
}
};
Ok(())
}));
}
fn remove(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.remove_signer(&public_key, cx);
});
}
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let import = cx.new(|cx| ImportKey::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Import a Secret Key or Bunker Connection")
.show_close(true)
.pb_2()
.child(import.clone())
});
}
fn open_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let connect = cx.new(|cx| ConnectSigner::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Scan QR Code to Connect")
.show_close(true)
.pb_2()
.child(connect.clone())
});
}
}
impl Render for AccountSelector {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let npubs = nostr.read(cx).npubs();
let loading = self.logging_in.read(cx).is_some();
v_flex()
.size_full()
.gap_2()
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_center()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
.children({
let mut items = vec![];
for (ix, public_key) in npubs.read(cx).iter().enumerate() {
let profile = persons.read(cx).get(public_key, cx);
let logging_in = self.logging_in(public_key, cx);
items.push(
h_flex()
.id(ix)
.group("")
.px_2()
.h_10()
.justify_between()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().ghost_element_background)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(
h_flex()
.gap_2()
.child(Avatar::new(profile.avatar()).small())
.child(div().text_sm().child(profile.name())),
)
.when(logging_in, |this| this.child(Indicator::new().small()))
.when(!logging_in, |this| {
this.child(
h_flex()
.gap_1()
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new(format!("del-{ix}"))
.icon(IconName::Close)
.ghost()
.small()
.disabled(logging_in)
.on_click(cx.listener({
let public_key = *public_key;
move |this, _ev, _window, cx| {
cx.stop_propagation();
this.remove(public_key, cx);
}
})),
),
)
})
.when(!logging_in, |this| {
let public_key = *public_key;
this.on_click(cx.listener(move |this, _ev, window, cx| {
this.login(public_key, window, cx);
}))
}),
);
}
items
})
.child(div().w_full().h_px().bg(cx.theme().border_variant))
.child(
h_flex()
.gap_1()
.justify_end()
.w_full()
.child(
Button::new("input")
.icon(Icon::new(IconName::Usb))
.label("Import")
.ghost()
.small()
.disabled(loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_import(window, cx);
})),
)
.child(
Button::new("qr")
.icon(Icon::new(IconName::Scan))
.label("Scan QR to connect")
.ghost()
.small()
.disabled(loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_connect(window, cx);
})),
),
)
}
}

View File

@@ -0,0 +1,115 @@
use std::sync::Arc;
use std::time::Duration;
use common::TextUtils;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, AppContext, Context, Entity, Image, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Window,
};
use nostr_connect::prelude::*;
use state::{
CoopAuthUrlHandler, NostrRegistry, SignerEvent, CLIENT_NAME, NOSTR_CONNECT_RELAY,
NOSTR_CONNECT_TIMEOUT,
};
use theme::ActiveTheme;
use ui::v_flex;
pub struct ConnectSigner {
/// QR Code
qr_code: Option<Arc<Image>>,
/// Error message
error: Entity<Option<SharedString>>,
/// Subscription to the signer event
_subscription: Option<Subscription>,
}
impl ConnectSigner {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let error = cx.new(|_| None);
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys.clone();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
// Generate the nostr connect uri
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
// Generate the nostr connect
let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
// Handle the auth request
signer.auth_url_handler(CoopAuthUrlHandler);
// Generate a QR code for quick connection
let qr_code = uri.to_string().to_qr();
// Set signer in the background
nostr.update(cx, |this, cx| {
this.add_nip46_signer(&signer, cx);
});
// Subscribe to the signer event
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
if let SignerEvent::Error(e) = event {
this.set_error(e, cx);
}
});
Self {
qr_code,
error,
_subscription: Some(subscription),
}
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
}
}
impl Render for ConnectSigner {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const MSG: &str = "Scan with any Nostr Connect-compatible app to connect";
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded(cx.theme().radius_lg)
.border_1()
.border_color(cx.theme().border),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(MSG)),
)
}
}

View File

@@ -0,0 +1,301 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use gpui::prelude::FluentBuilder;
use gpui::{
div, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window,
};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{CoopAuthUrlHandler, NostrRegistry, SignerEvent};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::{v_flex, Disableable};
#[derive(Debug)]
pub struct ImportKey {
/// Secret key input
key_input: Entity<InputState>,
/// Password input (if required)
pass_input: Entity<InputState>,
/// Error message
error: Entity<Option<SharedString>>,
/// Countdown timer for nostr connect
countdown: Entity<Option<u64>>,
/// Whether the user is currently loading
loading: bool,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl ImportKey {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to key input events and process login when the user presses enter
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
};
}),
);
subscriptions.push(
// Subscribe to the nostr signer event
cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
if let SignerEvent::Error(e) = event {
this.set_error(e, cx);
}
}),
);
Self {
key_input,
pass_input,
error,
countdown,
loading: false,
tasks: vec![],
_subscriptions: subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.loading {
return;
};
// Prevent duplicate login requests
self.set_loading(true, cx);
let value = self.key_input.read(cx).value();
let password = self.pass_input.read(cx).value();
if value.starts_with("bunker://") {
self.bunker(&value, window, cx);
return;
}
if value.starts_with("ncryptsec1") {
self.ncryptsec(value, password, window, cx);
return;
}
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx);
// Update the signer
nostr.update(cx, |this, cx| {
this.add_key_signer(&keys, cx);
});
} else {
self.set_error("Invalid key", cx);
}
}
fn bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectUri::parse(content) else {
self.set_error("Bunker is not valid", cx);
return;
};
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys.clone();
let timeout = Duration::from_secs(30);
// Construct the nostr connect signer
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Set signer in the background
nostr.update(cx, |this, cx| {
this.add_nip46_signer(&signer, cx);
});
// Start countdown
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
for i in (0..=30).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})?;
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})?;
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
Ok(())
}));
}
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
where
S: Into<String>,
{
let nostr = NostrRegistry::global(cx);
let content: String = content.into();
let password: String = pwd.into();
if password.is_empty() {
self.set_error("Password is required", cx);
return;
}
let Ok(enc) = EncryptedSecretKey::from_bech32(&content) else {
self.set_error("Secret Key is invalid", cx);
return;
};
// Decrypt in the background to ensure it doesn't block the UI
let task = cx.background_spawn(async move {
if let Ok(content) = enc.decrypt(&password) {
Ok(Keys::new(content))
} else {
Err(anyhow!("Invalid password"))
}
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(keys) => {
nostr.update(cx, |this, cx| {
this.add_key_signer(&keys, cx);
});
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})?;
}
}
Ok(())
}));
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
// Reset the log in state
self.set_loading(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Clear the error message after 3 secs
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
this.update(cx, |this, cx| {
this.error.update(cx, |this, cx| {
*this = None;
cx.notify();
});
})?;
Ok(())
}));
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Render for ImportKey {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.p_4()
.gap_2()
.text_sm()
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("nsec or bunker://")
.child(TextInput::new(&self.key_input)),
)
.when(
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|this| {
this.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Password:")
.child(TextInput::new(&self.pass_input)),
)
},
)
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::from(format!(
"Approve connection request from your signer in {} seconds",
i
))),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
}
}

View File

@@ -1,2 +1,6 @@
pub mod accounts;
pub mod screening;
pub mod settings;
mod connect;
mod import;

View File

@@ -5,8 +5,8 @@ use anyhow::{Context as AnyhowContext, Error};
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,
div, px, relative, uniform_list, App, AppContext, Context, Div, Entity, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window,
};
use nostr_sdk::prelude::*;
use person::{shorten_pubkey, Person, PersonRegistry};
@@ -41,10 +41,20 @@ pub struct Screening {
/// Async tasks
tasks: SmallVec<[Task<()>; 3]>,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
this.tasks.clear();
window.close_all_modals(cx);
}));
cx.defer_in(window, move |this, _window, cx| {
this.check_contact(cx);
this.check_wot(cx);
@@ -59,6 +69,7 @@ impl Screening {
last_active: None,
mutual_contacts: vec![],
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
@@ -137,10 +148,10 @@ impl Screening {
let mut activity: Option<Timestamp> = None;
// Construct target for subscription
let target = BOOTSTRAP_RELAYS
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect::<HashMap<_, _>>();
.collect();
if let Ok(mut stream) = client
.stream_events(target)
@@ -243,7 +254,7 @@ impl Screening {
let total = contacts.len();
this.title(SharedString::from("Mutual contacts")).child(
v_flex().gap_1().pb_4().child(
v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(total);
@@ -263,7 +274,7 @@ impl Screening {
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.child(Avatar::new(profile.avatar()).size(rems(1.75)))
.child(Avatar::new(profile.avatar()).small())
.child(profile.name()),
);
}
@@ -279,11 +290,21 @@ impl Screening {
impl Render for Screening {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const CONTACT: &str = "This person is one of your contacts.";
const NOT_CONTACT: &str = "This person is not one of your contacts.";
const NO_ACTIVITY: &str = "This person hasn't had any activity.";
const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate.";
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
const NIP05_MATCH: &str = "The address matches the user's public key.";
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
const NO_NIP05: &str = "This person has not set up their friendly address";
let profile = self.profile(cx);
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
let total_mutuals = self.mutual_contacts.len();
let last_active = self.last_active.map(|_| true);
let mutuals = self.mutual_contacts.len();
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
v_flex()
.gap_4()
@@ -293,7 +314,7 @@ impl Render for Screening {
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar()).size(rems(4.)))
.child(Avatar::new(profile.avatar()).large())
.child(
div()
.font_semibold()
@@ -335,8 +356,9 @@ impl Render for Screening {
.child(
Button::new("report")
.tooltip("Report as a scam or impostor")
.icon(IconName::Boom)
.danger()
.icon(IconName::Warning)
.small()
.warning()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.report(window, cx);
@@ -363,9 +385,9 @@ impl Render for Screening {
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from("This person is one of your contacts.")
SharedString::from(CONTACT)
} else {
SharedString::from("This person is not one of your contacts.")
SharedString::from(NOT_CONTACT)
}
}),
),
@@ -390,7 +412,7 @@ impl Render for Screening {
.xsmall()
.ghost()
.rounded()
.tooltip("This may be inaccurate if the user only publishes to their private relays."),
.tooltip(RELAY_INFO),
),
)
.child(
@@ -399,13 +421,13 @@ impl Render for Screening {
.line_clamp(1)
.text_color(cx.theme().text_muted)
.map(|this| {
if let Some(date) = self.last_active {
if let Some(t) = self.last_active {
this.child(SharedString::from(format!(
"Last active: {}.",
date.to_human_time()
t.to_human_time()
)))
} else {
this.child(SharedString::from("This person hasn't had any activity."))
this.child(SharedString::from(NO_ACTIVITY))
}
}),
),
@@ -423,7 +445,9 @@ impl Render for Screening {
if let Some(addr) = self.address(cx) {
SharedString::from(format!("{} validation", addr))
} else {
SharedString::from("Friendly Address (NIP-05) validation")
SharedString::from(
"Friendly Address (NIP-05) validation",
)
}
})
.child(
@@ -433,12 +457,12 @@ impl Render for Screening {
.child({
if self.address(cx).is_some() {
if self.verified {
SharedString::from("The address matches the user's public key.")
SharedString::from(NIP05_MATCH)
} else {
SharedString::from("The address does not match the user's public key.")
SharedString::from(NIP05_NOT_MATCH)
}
} else {
SharedString::from("This person has not set up their friendly address")
SharedString::from(NO_NIP05)
}
}),
),
@@ -448,7 +472,7 @@ impl Render for Screening {
h_flex()
.items_start()
.gap_2()
.child(status_badge(Some(total_mutuals > 0), cx))
.child(status_badge(Some(mutuals > 0), cx))
.child(
v_flex()
.text_sm()
@@ -474,13 +498,10 @@ impl Render for Screening {
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if total_mutuals > 0 {
SharedString::from(format!(
"You have {} mutual contacts with this person.",
total_mutuals
))
if mutuals > 0 {
SharedString::from(mutuals_str)
} else {
SharedString::from("You don't have any mutual contacts with this person.")
SharedString::from(NO_MUTUAL)
}
}),
),

View File

@@ -79,16 +79,14 @@ fn main() {
// Initialize theme registry
theme::init(cx);
// Initialize settings
settings::init(window, cx);
// Initialize the nostr client
state::init(window, cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(window, cx);
// Initialize settings
settings::init(window, cx);
// Initialize person registry
person::init(cx);
// Initialize relay auth registry
relay_auth::init(window, cx);
@@ -96,8 +94,10 @@ fn main() {
// Initialize app registry
chat::init(window, cx);
// Initialize person registry
person::init(cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(window, cx);
// Initialize auto update
auto_update::init(window, cx);

View File

@@ -1,127 +0,0 @@
use std::sync::Arc;
use common::TextUtils;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString, Styled, Task,
Window,
};
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::dock_area::ClosePanel;
use ui::notification::Notification;
use ui::{v_flex, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ConnectPanel> {
cx.new(|cx| ConnectPanel::new(window, cx))
}
pub struct ConnectPanel {
name: SharedString,
focus_handle: FocusHandle,
/// QR Code
qr_code: Option<Arc<Image>>,
/// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl ConnectPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let weak_state = nostr.downgrade();
let (signer, uri) = nostr.read(cx).client_connect(None);
// Generate a QR code for quick connection
let qr_code = uri.to_string().to_qr();
let mut tasks = smallvec![];
tasks.push(
// Wait for nostr connect
cx.spawn_in(window, async move |_this, cx| {
let result = signer.bunker_uri().await;
weak_state
.update_in(cx, |this, window, cx| {
match result {
Ok(uri) => {
this.persist_bunker(uri, cx);
this.set_signer(signer, true, cx);
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
}),
);
Self {
name: "Nostr Connect".into(),
focus_handle: cx.focus_handle(),
qr_code,
_tasks: tasks,
}
}
}
impl Panel for ConnectPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for ConnectPanel {}
impl Focusable for ConnectPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ConnectPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.p_2()
.gap_10()
.child(
v_flex()
.justify_center()
.items_center()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from("Continue with Nostr Connect")),
)
.child(div().text_sm().text_color(cx.theme().text_muted).child(
SharedString::from("Use Nostr Connect apps to scan the code"),
)),
)
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded(cx.theme().radius_lg)
.border_1()
.border_color(cx.theme().border),
)
})
}
}

View File

@@ -0,0 +1,369 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error};
use gpui::prelude::FluentBuilder;
use gpui::{
div, rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::{h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
cx.new(|cx| ContactListPanel::new(window, cx))
}
#[derive(Debug)]
pub struct ContactListPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Npub input
input: Entity<InputState>,
/// Whether the panel is updating
updating: bool,
/// Error message
error: Option<SharedString>,
/// All contacts
contacts: HashSet<PublicKey>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
}
impl ContactListPanel {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1..."));
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to user's input events
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
}),
);
// Run at the end of current cycle
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self {
name: "Contact List".into(),
focus_handle: cx.focus_handle(),
input,
updating: false,
contacts: HashSet::new(),
error: None,
_subscriptions: subscriptions,
tasks: vec![],
}
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let contact_list = client.database().contacts_public_keys(public_key).await?;
Ok(contact_list)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let public_keys = task.await?;
// Update state
this.update(cx, |this, cx| {
this.contacts.extend(public_keys);
cx.notify();
})?;
Ok(())
}));
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.input.read(cx).value().to_string();
if let Ok(public_key) = PublicKey::parse(&value) {
if self.contacts.insert(public_key) {
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
cx.notify();
}
} else {
self.set_error("Public Key is invalid", window, cx);
}
}
fn remove(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
self.contacts.remove(public_key);
cx.notify();
}
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
where
E: Into<SharedString>,
{
self.error = Some(error.into());
cx.notify();
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Clear the error message after a delay
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})?;
Ok(())
}));
}
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) {
self.updating = updating;
cx.notify();
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.contacts.is_empty() {
self.set_error("You need to add at least 1 contact", window, cx);
return;
};
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let Some(public_key) = signer.public_key() else {
window.push_notification("Public Key not found", cx);
return;
};
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
// Get contacts
let contacts: Vec<Contact> = self
.contacts
.iter()
.map(|public_key| Contact::new(*public_key))
.collect();
// Set updating state
self.set_updating(true, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
// Construct contact list event builder
let builder = EventBuilder::contact_list(contacts);
let event = client.sign_event_builder(builder).await?;
// Set contact list
client.send_event(&event).to(urls).await?;
Ok(())
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
this.update_in(cx, |this, window, cx| {
this.set_updating(false, cx);
this.load(window, cx);
window.push_notification("Update successful", cx);
})?;
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_updating(false, cx);
this.set_error(e.to_string(), window, cx);
})?;
}
};
Ok(())
}));
}
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
let mut items = Vec::new();
for (ix, public_key) in self.contacts.iter().enumerate() {
let profile = persons.read(cx).get(public_key, cx);
items.push(
h_flex()
.id(ix)
.group("")
.flex_1()
.w_full()
.h_8()
.px_2()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
.child(
h_flex()
.gap_2()
.text_sm()
.child(Avatar::new(profile.avatar()).small())
.child(profile.name()),
)
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let public_key = public_key.to_owned();
cx.listener(move |this, _ev, _window, cx| {
this.remove(&public_key, cx);
})
}),
),
)
}
items
}
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.h_20()
.justify_center()
.border_2()
.border_dashed()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg)
.text_sm()
.text_align(TextAlign::Center)
.child(SharedString::from("Please add some relays."))
}
}
impl Panel for ContactListPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for ContactListPanel {}
impl Focusable for ContactListPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ContactListPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex().p_3().gap_3().w_full().child(
v_flex()
.gap_2()
.flex_1()
.w_full()
.text_sm()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("New contact:")),
)
.child(
v_flex()
.gap_1()
.child(
h_flex()
.gap_1()
.w_full()
.child(
TextInput::new(&self.input)
.small()
.bordered(false)
.cleanable(),
)
.child(
Button::new("add")
.icon(IconName::Plus)
.tooltip("Add contact")
.ghost()
.size(rems(2.))
.on_click(cx.listener(move |this, _, window, cx| {
this.add(window, cx);
})),
),
)
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
}),
)
.map(|this| {
if self.contacts.is_empty() {
this.child(self.render_empty(window, cx))
} else {
this.child(
v_flex()
.gap_1()
.flex_1()
.w_full()
.children(self.render_list_items(cx)),
)
}
})
.child(
Button::new("submit")
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.update(window, cx);
})),
),
)
}
}

View File

@@ -1,335 +0,0 @@
use anyhow::Error;
use device::DeviceRegistry;
use gpui::prelude::FluentBuilder;
use gpui::{
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::notification::Notification;
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
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,
/// Whether the encryption is resetting
resetting: bool,
/// Tasks
tasks: Vec<Task<Result<(), Error>>>,
}
impl EncryptionPanel {
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,
resetting: 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 set_resetting(&mut self, status: bool, cx: &mut Context<Self>) {
self.resetting = status;
cx.notify();
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let device = DeviceRegistry::global(cx);
let task = device.read(cx).create_encryption(cx);
// Update the reset status
self.set_resetting(true, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(keys) => {
this.update_in(cx, |this, _window, cx| {
this.set_resetting(false, cx);
device.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.listen_request(cx);
});
})?;
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_resetting(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 {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for EncryptionPanel {}
impl Focusable for EncryptionPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for EncryptionPanel {
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()
.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",
)),
)
})
.child(
v_flex()
.gap_1()
.child(
Button::new("reset")
.icon(IconName::Reset)
.label("Reset")
.warning()
.small()
.font_semibold()
.on_click(
cx.listener(move |this, _ev, window, cx| this.reset(window, cx)),
),
)
.child(
div()
.italic()
.text_size(px(10.))
.text_color(cx.theme().text_muted)
.child(SharedString::from(NOTICE)),
),
)
}
}

View File

@@ -11,7 +11,7 @@ use ui::dock_area::dock::DockPlacement;
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
use crate::panels::{connect, import, messaging_relays, profile, relay_list};
use crate::panels::{messaging_relays, profile, relay_list};
use crate::workspace::Workspace;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
@@ -86,10 +86,7 @@ impl Render for GreeterPanel {
let nip17 = chat.read(cx).state(cx);
let nostr = NostrRegistry::global(cx);
let nip65 = nostr.read(cx).relay_list_state();
let signer = nostr.read(cx).signer();
let owned = signer.owned();
let nip65 = nostr.read(cx).relay_list_state.clone();
let required_actions =
nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable;
@@ -191,60 +188,6 @@ impl Render for GreeterPanel {
),
)
})
.when(!owned, |this| {
this.child(
v_flex()
.gap_2()
.w_full()
.child(
h_flex()
.gap_2()
.w_full()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Use your own identity"))
.child(div().flex_1().h_px().bg(cx.theme().border)),
)
.child(
v_flex()
.gap_2()
.w_full()
.child(
Button::new("connect")
.icon(Icon::new(IconName::Door))
.label("Connect account via Nostr Connect")
.ghost()
.small()
.justify_start()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
connect::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
)
.child(
Button::new("import")
.icon(Icon::new(IconName::Usb))
.label("Import a secret key or bunker")
.ghost()
.small()
.justify_start()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
import::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
),
),
)
})
.child(
v_flex()
.gap_2()

View File

@@ -1,371 +0,0 @@
use std::time::Duration;
use anyhow::anyhow;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{CoopAuthUrlHandler, NostrRegistry};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::dock_area::ClosePanel;
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ImportPanel> {
cx.new(|cx| ImportPanel::new(window, cx))
}
#[derive(Debug)]
pub struct ImportPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Secret key input
key_input: Entity<InputState>,
/// Password input (if required)
pass_input: Entity<InputState>,
/// Error message
error: Entity<Option<SharedString>>,
/// Countdown timer for nostr connect
countdown: Entity<Option<u64>>,
/// Whether the user is currently logging in
logging_in: bool,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl ImportPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to key input events and process login when the user presses enter
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
};
}),
);
Self {
key_input,
pass_input,
error,
countdown,
name: "Import".into(),
focus_handle: cx.focus_handle(),
logging_in: false,
_subscriptions: subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.logging_in {
return;
};
// Prevent duplicate login requests
self.set_logging_in(true, cx);
let value = self.key_input.read(cx).value();
let password = self.pass_input.read(cx).value();
if value.starts_with("bunker://") {
self.login_with_bunker(&value, window, cx);
return;
}
if value.starts_with("ncryptsec1") {
self.login_with_password(&value, &password, window, cx);
return;
}
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx);
// Update the signer
nostr.update(cx, |this, cx| {
this.set_signer(keys, true, cx);
});
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), cx);
} else {
self.set_error("Invalid", cx);
}
}
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectUri::parse(content) else {
self.set_error("Bunker is not valid", cx);
return;
};
let nostr = NostrRegistry::global(cx);
let weak_state = nostr.downgrade();
let app_keys = nostr.read(cx).app_keys();
let timeout = Duration::from_secs(30);
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Start countdown
cx.spawn_in(window, async move |this, cx| {
for i in (0..=30).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})
.ok();
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
})
.detach();
// Handle connection
cx.spawn_in(window, async move |_this, cx| {
let result = signer.bunker_uri().await;
weak_state
.update_in(cx, |this, window, cx| {
match result {
Ok(uri) => {
this.persist_bunker(uri, cx);
this.set_signer(signer, true, cx);
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
})
.detach();
}
pub fn login_with_password(
&mut self,
content: &str,
pwd: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
if pwd.is_empty() {
self.set_error("Password is required", cx);
return;
}
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
self.set_error("Secret Key is invalid", cx);
return;
};
let password = pwd.to_owned();
// Decrypt in the background to ensure it doesn't block the UI
let task = cx.background_spawn(async move {
if let Ok(content) = enc.decrypt(&password) {
Ok(Keys::new(content))
} else {
Err(anyhow!("Invalid password"))
}
});
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(keys) => {
let nostr = NostrRegistry::global(cx);
// Update the signer
nostr.update(cx, |this, cx| {
this.set_signer(keys, true, cx);
});
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), cx);
}
Err(e) => {
this.set_error(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
// Reset the log in state
self.set_logging_in(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Clear the error message after 3 secs
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
this.update(cx, |this, cx| {
this.error.update(cx, |this, cx| {
*this = None;
cx.notify();
});
})
.ok();
})
.detach();
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.logging_in = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Panel for ImportPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for ImportPanel {}
impl Focusable for ImportPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ImportPanel {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
const SECRET_WARN: &str = "* Coop doesn't store your secret key. \
It will be cleared when you close the app. \
To persist your identity, please connect via Nostr Connect.";
v_flex()
.size_full()
.items_center()
.justify_center()
.p_2()
.gap_10()
.child(
div()
.text_center()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from("Import a Secret Key or Bunker")),
)
.child(
v_flex()
.w_112()
.gap_2()
.text_sm()
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("nsec or bunker://")
.child(TextInput::new(&self.key_input)),
)
.when(
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|this| {
this.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Password:")
.child(TextInput::new(&self.pass_input)),
)
},
)
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.logging_in)
.disabled(self.logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::from(format!(
"Approve connection request from your signer in {} seconds",
i
))),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
})
.child(
div()
.mt_2()
.italic()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(SECRET_WARN)),
),
)
}
}

View File

@@ -10,7 +10,7 @@ use gpui::{
};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, TIMEOUT};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
@@ -170,6 +170,15 @@ impl MessagingRelayPanel {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let Some(public_key) = signer.public_key() else {
window.push_notification("Public Key not found", cx);
return;
};
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
// Construct event tags
let tags: Vec<Tag> = self
@@ -182,16 +191,14 @@ impl MessagingRelayPanel {
self.set_updating(true, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
// Construct nip17 event builder
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
let event = client.sign_event_builder(builder).await?;
// Set messaging relays
client
.send_event(&event)
.to_nip65()
.ok_timeout(Duration::from_secs(TIMEOUT))
.await?;
client.send_event(&event).to(urls).await?;
Ok(())
});
@@ -342,7 +349,7 @@ impl Render for MessagingRelayPanel {
div()
.italic()
.text_xs()
.text_color(cx.theme().danger_foreground)
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
}),

View File

@@ -1,8 +1,6 @@
pub mod backup;
pub mod connect;
pub mod encryption_key;
pub mod contact_list;
pub mod greeter;
pub mod import;
pub mod messaging_relays;
pub mod profile;
pub mod relay_list;

View File

@@ -3,9 +3,9 @@ use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error};
use gpui::{
div, rems, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
Styled, Task, Window,
div, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
Window,
};
use nostr_sdk::prelude::*;
use person::{shorten_pubkey, Person, PersonRegistry};
@@ -322,7 +322,7 @@ impl Render for ProfilePanel {
.items_center()
.justify_center()
.gap_4()
.child(Avatar::new(avatar).size(rems(4.25)))
.child(Avatar::new(avatar).large())
.child(
Button::new("upload")
.icon(IconName::PlusCircle)

View File

@@ -408,7 +408,7 @@ impl Render for RelayListPanel {
div()
.italic()
.text_xs()
.text_color(cx.theme().danger_foreground)
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
}),

View File

@@ -3,7 +3,7 @@ use std::rc::Rc;
use chat::RoomKind;
use gpui::prelude::FluentBuilder;
use gpui::{
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
div, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window,
};
use nostr_sdk::prelude::*;
@@ -106,14 +106,7 @@ impl RenderOnce for RoomEntry {
.rounded(cx.theme().radius)
.when(!hide_avatar, |this| {
this.when_some(self.avatar, |this, avatar| {
this.child(
div()
.flex_shrink_0()
.size_6()
.rounded_full()
.overflow_hidden()
.child(Avatar::new(avatar).size(rems(1.5))),
)
this.child(Avatar::new(avatar).small().flex_shrink_0())
})
})
.child(

View File

@@ -8,22 +8,22 @@ use common::{DebouncedDelay, RenderedTimestamp};
use entry::RoomEntry;
use gpui::prelude::FluentBuilder;
use gpui::{
div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription,
Task, UniformListScrollHandle, Window,
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription, Task,
UniformListScrollHandle, Window, div, uniform_list,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, FIND_DELAY};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry};
use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::{h_flex, v_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension};
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
mod entry;
@@ -585,10 +585,11 @@ impl Render for Sidebar {
)
.when(!show_find_panel && !loading && total_rooms == 0, |this| {
this.child(
div().px_2().child(
div().w(SIDEBAR_WIDTH).px_2().child(
v_flex()
.p_3()
.h_24()
.w_full()
.border_2()
.border_dashed()
.border_color(cx.theme().border_variant)
@@ -612,11 +613,9 @@ impl Render for Sidebar {
})
.child(
v_flex()
.h_full()
.px_1p5()
.gap_1()
.size_full()
.flex_1()
.overflow_y_hidden()
.gap_1()
.when(show_find_panel, |this| {
this.gap_3()
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
@@ -687,7 +686,8 @@ impl Render for Sidebar {
)
.track_scroll(&self.scroll_handle)
.flex_1()
.h_full(),
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(&self.scroll_handle))
}),

View File

@@ -5,27 +5,34 @@ use chat::{ChatEvent, ChatRegistry, InboxState};
use device::DeviceRegistry;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, rems, Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, Window,
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Subscription, Window, div, px,
};
use person::PersonRegistry;
use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, RelayState};
use theme::{ActiveTheme, Theme, ThemeRegistry, SIDEBAR_WIDTH};
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, RelayState, SignerEvent};
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
use title_bar::TitleBar;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::dock::DockPlacement;
use ui::dock_area::panel::PanelView;
use ui::dock_area::{ClosePanel, DockArea, DockItem};
use ui::menu::DropdownMenu;
use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::Notification;
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
use crate::dialogs::settings;
use crate::panels::{backup, encryption_key, greeter, messaging_relays, profile, relay_list};
use crate::dialogs::{accounts, settings};
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list};
use crate::sidebar;
const ENC_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 ENC_WARN: &str = "By resetting your encryption key, you will lose access to \
all your encrypted messages before. This action cannot be undone.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
cx.new(|cx| Workspace::new(window, cx))
}
@@ -34,17 +41,19 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
#[action(namespace = workspace, no_json)]
enum Command {
ToggleTheme,
ToggleAccount,
RefreshEncryption,
RefreshRelayList,
RefreshMessagingRelays,
RefreshEncryption,
ResetEncryption,
ShowRelayList,
ShowMessaging,
ShowEncryption,
ShowProfile,
ShowSettings,
ShowBackup,
ShowContactList,
}
pub struct Workspace {
@@ -55,11 +64,13 @@ pub struct Workspace {
dock: Entity<DockArea>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 3]>,
_subscriptions: SmallVec<[Subscription; 4]>,
}
impl Workspace {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let npubs = nostr.read(cx).npubs();
let chat = ChatRegistry::global(cx);
let titlebar = cx.new(|_| TitleBar::new());
let dock = cx.new(|cx| DockArea::new(window, cx));
@@ -73,6 +84,24 @@ impl Workspace {
}),
);
subscriptions.push(
// Observe the npubs entity
cx.observe_in(&npubs, window, move |this, npubs, window, cx| {
if !npubs.read(cx).is_empty() {
this.account_selector(window, cx);
}
}),
);
subscriptions.push(
// Subscribe to the signer events
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
if let SignerEvent::Set = event {
this.set_center_layout(window, cx);
}
}),
);
subscriptions.push(
// Observe all events emitted by the chat registry
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
@@ -112,12 +141,12 @@ impl Workspace {
let ids = this.panel_ids(cx);
chat.update(cx, |this, cx| {
this.refresh_rooms(ids, cx);
this.refresh_rooms(&ids, cx);
});
}),
);
// Set the default layout for app's dock
// Set the layout at the end of cycle
cx.defer_in(window, |this, window, cx| {
this.set_layout(window, cx);
});
@@ -146,49 +175,40 @@ impl Workspace {
}
/// Get all panel ids
fn panel_ids(&self, cx: &App) -> Option<Vec<u64>> {
let ids: Vec<u64> = self
.dock
fn panel_ids(&self, cx: &App) -> Vec<u64> {
self.dock
.read(cx)
.items
.panel_ids(cx)
.into_iter()
.filter_map(|panel| panel.parse::<u64>().ok())
.collect();
Some(ids)
.collect()
}
/// Set the dock layout
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let weak_dock = self.dock.downgrade();
// Sidebar
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
// Main workspace
let center = DockItem::split_with_sizes(
Axis::Vertical,
vec![DockItem::tabs(
vec![Arc::new(greeter::init(window, cx))],
None,
&weak_dock,
window,
cx,
)],
vec![None],
&weak_dock,
window,
cx,
);
// Update the dock layout
// Update the dock layout with sidebar on the left
self.dock.update(cx, |this, cx| {
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
});
}
/// Set the center dock layout
fn set_center_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let dock = self.dock.downgrade();
let greeeter = Arc::new(greeter::init(window, cx));
let tabs = DockItem::tabs(vec![greeeter], None, &dock, window, cx);
let center = DockItem::split(Axis::Vertical, vec![tabs], &dock, window, cx);
// Update the layout with center dock
self.dock.update(cx, |this, cx| {
this.set_center(center, window, cx);
});
}
/// Handle command events
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::ShowSettings => {
@@ -197,7 +217,7 @@ impl Workspace {
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(520.))
.show_close(true)
.pb_4()
.pb_2()
.title("Preferences")
.child(view.clone())
});
@@ -217,6 +237,16 @@ impl Workspace {
});
}
}
Command::ShowContactList => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(contact_list::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
}
Command::ShowBackup => {
self.dock.update(cx, |this, cx| {
this.add_panel(
@@ -227,21 +257,6 @@ impl Workspace {
);
});
}
Command::ShowEncryption => {
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::ShowMessaging => {
self.dock.update(cx, |this, cx| {
this.add_panel(
@@ -274,6 +289,9 @@ impl Workspace {
this.ensure_relay_list(cx);
});
}
Command::ResetEncryption => {
self.confirm_reset_encryption(window, cx);
}
Command::RefreshMessagingRelays => {
let chat = ChatRegistry::global(cx);
chat.update(cx, |this, cx| {
@@ -283,9 +301,74 @@ impl Workspace {
Command::ToggleTheme => {
self.theme_selector(window, cx);
}
Command::ToggleAccount => {
self.account_selector(window, cx);
}
}
}
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, |this, _window, cx| {
this.confirm()
.show_close(true)
.title("Reset Encryption Keys")
.child(
v_flex()
.gap_1()
.text_sm()
.child(SharedString::from(ENC_MSG))
.child(
div()
.italic()
.text_color(cx.theme().warning_active)
.child(SharedString::from(ENC_WARN)),
),
)
.on_ok(move |_ev, window, cx| {
let device = DeviceRegistry::global(cx);
let task = device.read(cx).create_encryption(cx);
window
.spawn(cx, async move |cx| {
let result = task.await;
cx.update(|window, cx| match result {
Ok(keys) => {
device.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.listen_request(cx);
});
window.close_modal(cx);
}
Err(e) => {
window
.push_notification(Notification::error(e.to_string()), cx);
}
})
.ok();
})
.detach();
// false to keep modal open
false
})
});
}
fn account_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let accounts = accounts::init(window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(520.))
.title("Continue with")
.show_close(false)
.keyboard(false)
.overlay_closable(false)
.pb_2()
.child(accounts.clone())
});
}
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, move |this, _window, cx| {
let registry = ThemeRegistry::global(cx);
@@ -294,20 +377,22 @@ impl Workspace {
this.width(px(520.))
.show_close(true)
.title("Select theme")
.pb_4()
.pb_2()
.child(v_flex().gap_2().w_full().children({
let mut items = vec![];
for (ix, (path, theme)) in themes.iter().enumerate() {
items.push(
h_flex()
.id(ix)
.group("")
.px_2()
.h_8()
.w_full()
.justify_between()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.bg(cx.theme().ghost_element_background)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(
h_flex()
.gap_1p5()
@@ -372,28 +457,52 @@ impl Workspace {
h_flex()
.flex_shrink_0()
.justify_between()
.gap_2()
.when_none(&current_user, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Choose an account to continue...")),
)
})
.when_some(current_user.as_ref(), |this, public_key| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(public_key, cx);
let avatar = profile.avatar();
let name = profile.name();
this.child(
Button::new("current-user")
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
.child(Avatar::new(avatar.clone()).xsmall())
.small()
.caret()
.compact()
.transparent()
.dropdown_menu(move |this, _window, _cx| {
let avatar = avatar.clone();
let name = name.clone();
this.min_w(px(256.))
.label(profile.name())
.item(PopupMenuItem::element(move |_window, cx| {
h_flex()
.gap_1p5()
.text_xs()
.text_color(cx.theme().text_muted)
.child(Avatar::new(avatar.clone()).xsmall())
.child(name.clone())
}))
.separator()
.menu_with_icon(
"Profile",
IconName::Profile,
Box::new(Command::ShowProfile),
)
.menu_with_icon(
"Contact List",
IconName::Book,
Box::new(Command::ShowContactList),
)
.menu_with_icon(
"Backup",
IconName::UserKey,
@@ -404,6 +513,12 @@ impl Workspace {
IconName::Sun,
Box::new(Command::ToggleTheme),
)
.separator()
.menu_with_icon(
"Accounts",
IconName::Group,
Box::new(Command::ToggleAccount),
)
.menu_with_icon(
"Settings",
IconName::Settings,
@@ -412,25 +527,11 @@ impl Workspace {
}),
)
})
.when(nostr.read(cx).creating(), |this| {
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
SharedString::from("Coop is creating a new identity for you..."),
))
})
.when(!nostr.read(cx).connected(), |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Connecting...")),
)
})
}
fn titlebar_right(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let relay_list = nostr.read(cx).relay_list_state();
let chat = ChatRegistry::global(cx);
let inbox_state = chat.read(cx).state(cx);
@@ -448,18 +549,38 @@ impl Workspace {
.tooltip("Decoupled encryption key")
.small()
.ghost()
.dropdown_menu(|this, _window, _cx| {
.dropdown_menu(move |this, _window, cx| {
let device = DeviceRegistry::global(cx);
let state = device.read(cx).state();
this.min_w(px(260.))
.label("Encryption")
.item(PopupMenuItem::element(move |_window, _cx| {
h_flex()
.px_1()
.w_full()
.gap_2()
.text_sm()
.child(
div()
.size_1p5()
.rounded_full()
.when(state.set(), |this| this.bg(gpui::green()))
.when(state.requesting(), |this| {
this.bg(gpui::yellow())
}),
)
.child(SharedString::from(state.to_string()))
}))
.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshEncryption),
)
.menu_with_icon(
"View encryption",
IconName::Settings,
Box::new(Command::ShowEncryption),
"Reset",
IconName::Warning,
Box::new(Command::ResetEncryption),
)
}),
)
@@ -491,54 +612,35 @@ impl Workspace {
.small()
.ghost()
.when(inbox_state.subscribing(), |this| this.indicator())
.dropdown_menu(move |this, _window, _cx| {
this.min_w(px(260.))
.label("Messaging Relays")
.menu_element_with_disabled(
Box::new(Command::ShowRelayList),
true,
move |_window, cx| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&pkey, cx);
let urls = profile.messaging_relays();
.dropdown_menu(move |this, _window, cx| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&pkey, cx);
let urls: Vec<SharedString> = profile
.messaging_relays()
.iter()
.map(|url| SharedString::from(url.to_string()))
.collect();
v_flex()
.gap_1()
.w_full()
.items_start()
.justify_start()
.children({
let mut items = vec![];
// Header
let menu = this.min_w(px(260.)).label("Messaging Relays");
for url in urls.iter() {
items.push(
h_flex()
.h_6()
.w_full()
.gap_2()
.px_2()
.text_xs()
.bg(cx
.theme()
.elevated_surface_background)
.rounded(cx.theme().radius)
.child(
div()
.size_1()
.rounded_full()
.bg(gpui::green()),
)
.child(SharedString::from(
url.to_string(),
)),
);
}
// Content
let menu = urls.into_iter().fold(menu, |this, url| {
this.item(PopupMenuItem::element(move |_window, _cx| {
h_flex()
.px_1()
.w_full()
.gap_2()
.text_sm()
.child(
div().size_1p5().rounded_full().bg(gpui::green()),
)
.child(url.clone())
}))
});
items
})
},
)
.separator()
// Footer
menu.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
@@ -559,7 +661,7 @@ impl Workspace {
div()
.text_xs()
.text_color(cx.theme().text_muted)
.map(|this| match relay_list {
.map(|this| match nostr.read(cx).relay_list_state {
RelayState::Checking => this
.child(div().child(SharedString::from(
"Fetching user's relay list...",
@@ -578,11 +680,33 @@ impl Workspace {
.tooltip("User's relay list")
.small()
.ghost()
.when(relay_list.configured(), |this| this.indicator())
.dropdown_menu(move |this, _window, _cx| {
this.min_w(px(260.))
.label("Relays")
.separator()
.when(nostr.read(cx).relay_list_state.configured(), |this| {
this.indicator()
})
.dropdown_menu(move |this, _window, cx| {
let nostr = NostrRegistry::global(cx);
let urls = nostr.read(cx).read_only_relays(&pkey, cx);
// Header
let menu = this.min_w(px(260.)).label("Relays");
// Content
let menu = urls.into_iter().fold(menu, |this, url| {
this.item(PopupMenuItem::element(move |_window, _cx| {
h_flex()
.px_1()
.w_full()
.gap_2()
.text_sm()
.child(
div().size_1p5().rounded_full().bg(gpui::green()),
)
.child(url.clone())
}))
});
// Footer
menu.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,

View File

@@ -8,6 +8,8 @@ publish.workspace = true
common = { path = "../common" }
state = { path = "../state" }
person = { path = "../person" }
ui = { path = "../ui" }
theme = { path = "../theme" }
gpui.workspace = true
nostr-sdk.workspace = true

View File

@@ -1,16 +1,28 @@
use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
use gpui::{
div, App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString,
Styled, Subscription, Task, Window,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::{
app_name, Announcement, DeviceState, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT,
};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::notification::Notification;
use ui::{h_flex, v_flex, Disableable, IconName, Sizable, WindowExtension};
const IDENTIFIER: &str = "coop:device";
const MSG: &str = "You've requested an encryption key from another device. \
Approve to allow Coop to share with it.";
pub fn init(window: &mut Window, cx: &mut App) {
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
@@ -25,9 +37,6 @@ 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,
@@ -57,32 +66,25 @@ impl DeviceRegistry {
subscriptions.push(
// Observe the NIP-65 state
cx.observe(&nostr, |this, state, cx| {
match state.read(cx).relay_list_state() {
RelayState::Idle => {
this.reset(cx);
}
RelayState::Configured => {
this.get_announcement(cx);
}
_ => {}
if state.read(cx).relay_list_state == RelayState::Configured {
this.get_announcement(cx);
};
}),
);
// Run at the end of current cycle
cx.defer_in(window, |this, _window, cx| {
this.handle_notifications(cx);
cx.defer_in(window, |this, window, cx| {
this.handle_notifications(window, cx);
});
Self {
requests: vec![],
state: DeviceState::default(),
tasks: vec![],
_subscriptions: subscriptions,
}
}
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let (tx, rx) = flume::bounded::<Event>(100);
@@ -123,17 +125,19 @@ impl DeviceRegistry {
self.tasks.push(
// Update GPUI states
cx.spawn(async move |this, cx| {
cx.spawn_in(window, async move |this, cx| {
while let Ok(event) = rx.recv_async().await {
match event.kind {
// New request event
Kind::Custom(4454) => {
this.update(cx, |this, cx| {
this.add_request(event, cx);
this.update_in(cx, |this, window, cx| {
this.ask_for_approval(event, window, cx);
})?;
}
// New response event
Kind::Custom(4455) => {
this.update(cx, |this, cx| {
this.parse_response(event, cx);
this.extract_encryption(event, cx);
})?;
}
_ => {}
@@ -180,27 +184,9 @@ impl DeviceRegistry {
/// Reset the device state
fn reset(&mut self, cx: &mut Context<Self>) {
self.state = DeviceState::Idle;
self.requests.clear();
cx.notify();
}
/// Add a request for device keys
fn add_request(&mut self, request: Event, cx: &mut Context<Self>) {
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
fn get_messages(&mut self, cx: &mut Context<Self>) {
let task = self.subscribe_to_giftwrap_events(cx);
@@ -218,9 +204,11 @@ impl DeviceRegistry {
fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let Some(public_key) = signer.public_key() else {
return Task::ready(Err(anyhow!("User not found")));
};
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
@@ -251,20 +239,34 @@ impl DeviceRegistry {
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let Some(public_key) = signer.public_key() else {
return;
};
// Reset state before fetching announcement
self.reset(cx);
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
// Construct the filter for the device announcement event
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(public_key)
.limit(1);
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> =
urls.iter().map(|relay| (relay, filter.clone())).collect();
// Stream events from user's write relays
let mut stream = client
.stream_events(filter)
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await?;
@@ -301,16 +303,26 @@ impl DeviceRegistry {
}));
}
/// Create new encryption keys
pub fn create_encryption(&self, cx: &App) -> Task<Result<Keys, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let Some(public_key) = signer.public_key() else {
return Task::ready(Err(anyhow!("User not found")));
};
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
// Generate encryption keys
let keys = Keys::generate();
let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key();
cx.background_spawn(async move {
let urls = write_relays.await;
// Construct an announcement event
let event = client
.sign_event_builder(EventBuilder::new(Kind::Custom(10044), "").tags(vec![
@@ -320,11 +332,7 @@ impl DeviceRegistry {
.await?;
// Publish announcement
client
.send_event(&event)
.to_nip65()
.ok_timeout(Duration::from_secs(TIMEOUT))
.await?;
client.send_event(&event).to(urls).await?;
// Save device keys to the database
set_keys(&client, &secret).await?;
@@ -338,23 +346,20 @@ impl DeviceRegistry {
let task = self.create_encryption(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.listen_request(cx);
})?;
}
Err(e) => {
log::error!("Failed to create encryption key: {}", e);
}
}
let keys = task.await?;
// Update signer
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.listen_request(cx);
})?;
Ok(())
}));
}
/// Initialize device signer (decoupled encryption key) for the current user
fn new_signer(&mut self, event: &Event, cx: &mut Context<Self>) {
pub fn new_signer(&mut self, event: &Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -373,46 +378,54 @@ impl DeviceRegistry {
}
});
cx.spawn(async move |this, cx| {
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.listen_request(cx);
})
.ok();
})?;
}
Err(e) => {
log::warn!("Failed to initialize device signer: {e}");
this.update(cx, |this, cx| {
this.request(cx);
this.listen_approval(cx);
})
.ok();
log::warn!("Failed to initialize device signer: {e}");
})?;
}
};
})
.detach();
Ok(())
}));
}
/// Listen for device key requests on user's write relays
pub fn listen_request(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let Some(public_key) = signer.public_key() else {
return;
};
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
// Construct a filter for device key requests
let filter = Filter::new()
.kind(Kind::Custom(4454))
.author(public_key)
.since(Timestamp::now());
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> =
urls.iter().map(|relay| (relay, filter.clone())).collect();
// Subscribe to the device key requests on user's write relays
client.subscribe(filter).await?;
client.subscribe(target).await?;
Ok(())
});
@@ -424,19 +437,29 @@ impl DeviceRegistry {
fn listen_approval(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = signer.public_key().unwrap();
let Some(public_key) = signer.public_key() else {
return;
};
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
self.tasks.push(cx.background_spawn(async move {
let urls = write_relays.await;
// Construct a filter for device key requests
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
.since(Timestamp::now());
// Construct target for subscription
let target: HashMap<&RelayUrl, Filter> =
urls.iter().map(|relay| (relay, filter.clone())).collect();
// Subscribe to the device key requests on user's write relays
client.subscribe(filter).await?;
client.subscribe(target).await?;
Ok(())
}));
@@ -448,7 +471,13 @@ impl DeviceRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let app_keys = nostr.read(cx).app_keys().clone();
let Some(public_key) = signer.public_key() else {
return;
};
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let app_keys = nostr.read(cx).app_keys.clone();
let app_pubkey = app_keys.public_key();
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
@@ -478,52 +507,49 @@ impl DeviceRegistry {
Ok(Some(keys))
}
None => {
let urls = write_relays.await;
// Construct an event for device key request
let event = client
.sign_event_builder(EventBuilder::new(Kind::Custom(4454), "").tags(vec![
Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
Tag::client(app_name()),
Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
]))
.await?;
// Send the event to write relays
client
.send_event(&event)
.to_nip65()
.ok_timeout(Duration::from_secs(TIMEOUT))
.await?;
client.send_event(&event).to(urls).await?;
Ok(None)
}
}
});
cx.spawn(async move |this, cx| {
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(Some(keys)) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})
.ok();
})?;
}
Ok(None) => {
this.update(cx, |this, cx| {
this.set_state(DeviceState::Requesting, cx);
})
.ok();
})?;
}
Err(e) => {
log::error!("Failed to request the encryption key: {e}");
}
};
})
.detach();
Ok(())
}));
}
/// Parse the response event for device keys from other devices
fn parse_response(&mut self, event: Event, cx: &mut Context<Self>) {
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys().clone();
let app_keys = nostr.read(cx).app_keys.clone();
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let root_device = event
@@ -555,15 +581,23 @@ impl DeviceRegistry {
}
/// Approve requests for device keys from other devices
pub fn approve(&self, event: &Event, cx: &App) -> Task<Result<(), Error>> {
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Get user's write relays
let event = event.clone();
let Some(public_key) = signer.public_key() else {
return;
};
// Get user's write relays
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let event = event.clone();
let id: SharedString = event.id.to_hex().into();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let urls = write_relays.await;
cx.background_spawn(async move {
// Get device keys
let keys = get_keys(&client).await?;
let secret = keys.secret_key().to_secret_hex();
@@ -583,22 +617,145 @@ impl DeviceRegistry {
//
// P tag: the current device's public key
// p tag: the requester's public key
let event = client
.sign_event_builder(EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
Tag::public_key(target),
]))
.await?;
let builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
Tag::public_key(target),
]);
// Sign the builder
let event = client.sign_event_builder(builder).await?;
// Send the response event to the user's relay list
client
.send_event(&event)
.to_nip65()
.ok_timeout(Duration::from_secs(TIMEOUT))
.await?;
client.send_event(&event).to(urls).await?;
Ok(())
});
cx.spawn_in(window, async move |_this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
window.clear_notification(id, cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
};
})
.detach();
}
/// Handle encryption request
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
let notification = self.notification(event, cx);
cx.spawn_in(window, async move |_this, cx| {
cx.update(|window, cx| {
window.push_notification(notification, cx);
})
.ok();
})
.detach();
}
/// Build a notification for the encryption request.
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
let request = Announcement::from(&event);
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&request.public_key(), cx);
let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false));
Notification::new()
.custom_id(SharedString::from(event.id.to_hex()))
.autohide(false)
.icon(IconName::UserKey)
.title(SharedString::from("New request"))
.content(move |_window, cx| {
v_flex()
.gap_2()
.text_sm()
.child(SharedString::from(MSG))
.child(
v_flex()
.gap_2()
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Requester:")),
)
.child(
div()
.h_7()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(
h_flex()
.gap_2()
.child(Avatar::new(profile.avatar()).xsmall())
.child(profile.name()),
),
),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Client:")),
)
.child(
div()
.h_7()
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(request.client_name()),
),
),
)
.into_any_element()
})
.action(move |_window, _cx| {
let view = entity.clone();
let event = event.clone();
Button::new("approve")
.label("Approve")
.small()
.primary()
.loading(loading.get())
.disabled(loading.get())
.on_click({
let loading = Rc::clone(&loading);
move |_ev, window, cx| {
// Set loading state to true
loading.set(true);
// Process to approve the request
view.update(cx, |this, cx| {
this.approve(&event, window, cx);
})
.ok();
}
})
})
}
}
@@ -640,15 +797,14 @@ async fn get_keys(client: &Client) -> Result<Keys, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER);
.identifier(IDENTIFIER)
.author(public_key);
if let Some(event) = 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);
log::info!("Encryption keys retrieved successfully");
Ok(keys)
} else {
Err(anyhow!("Key not found"))

View File

@@ -198,7 +198,7 @@ impl PersonRegistry {
loop {
match flume::Selector::new()
.recv(rx, |result| result.ok())
.wait_timeout(Duration::from_secs(TIMEOUT))
.wait_timeout(Duration::from_secs(2))
{
Ok(Some(public_key)) => {
batch.insert(public_key);
@@ -253,7 +253,7 @@ impl PersonRegistry {
match self.persons.get(&public_key) {
Some(this) => {
this.update(cx, |this, cx| {
*this = person;
this.set_metadata(person.metadata());
cx.notify();
});
}
@@ -307,21 +307,15 @@ where
.timeout(Some(Duration::from_secs(TIMEOUT)));
// Construct the filter for metadata
let metadata = Filter::new()
let filter = Filter::new()
.kind(Kind::Metadata)
.authors(authors.clone())
.limit(limit);
// Construct the filter for relay list
let gossip = Filter::new()
.kind(Kind::RelayList)
.authors(authors)
.limit(limit);
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![metadata.clone(), gossip.clone()]))
.map(|relay| (relay, vec![filter.clone()]))
.collect();
client.subscribe(target).close_on(opts).await?;

View File

@@ -75,6 +75,11 @@ impl Person {
self.metadata.clone()
}
/// Set profile metadata
pub fn set_metadata(&mut self, metadata: Metadata) {
self.metadata = metadata;
}
/// Get profile encryption keys announcement
pub fn announcement(&self) -> Option<Announcement> {
self.announcement.clone()
@@ -83,7 +88,6 @@ impl Person {
/// Set profile encryption keys announcement
pub fn set_announcement(&mut self, announcement: Announcement) {
self.announcement = Some(announcement);
log::info!("Updated announcement for: {}", self.public_key());
}
/// Get profile messaging relays
@@ -102,7 +106,6 @@ impl Person {
I: IntoIterator<Item = RelayUrl>,
{
self.messaging_relays = relays.into_iter().collect();
log::info!("Updated messaging relays for: {}", self.public_key());
}
/// Get profile avatar

View File

@@ -67,7 +67,7 @@ pub struct RelayAuth {
pending_events: HashSet<(EventId, RelayUrl)>,
/// Tasks for asynchronous operations
tasks: SmallVec<[Task<()>; 2]>,
_tasks: SmallVec<[Task<()>; 2]>,
}
impl RelayAuth {
@@ -83,26 +83,15 @@ impl RelayAuth {
/// Create a new relay auth instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
cx.defer_in(window, |this, window, cx| {
this.handle_notifications(window, cx);
});
Self {
pending_events: HashSet::default(),
tasks: smallvec![],
}
}
/// Handle nostr notifications
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let mut tasks = smallvec![];
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Signal>(256);
self.tasks.push(cx.background_spawn(async move {
log::info!("Started handling nostr notifications");
tasks.push(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
@@ -117,6 +106,22 @@ impl RelayAuth {
tx.send_async(signal).await.ok();
}
}
RelayMessage::Closed {
subscription_id,
message,
} => {
let msg = MachineReadablePrefix::parse(&message);
if let Some(MachineReadablePrefix::AuthRequired) = msg {
if let Ok(Some(relay)) = client.relay(&relay_url).await {
// Send close message to relay
relay
.send_msg(ClientMessage::Close(subscription_id))
.await
.ok();
}
}
}
RelayMessage::Ok {
event_id, message, ..
} => {
@@ -134,7 +139,7 @@ impl RelayAuth {
}
}));
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
tasks.push(cx.spawn_in(window, async move |this, cx| {
while let Ok(signal) = rx.recv_async().await {
match signal {
Signal::Auth(req) => {
@@ -152,6 +157,11 @@ impl RelayAuth {
}
}
}));
Self {
pending_events: HashSet::default(),
_tasks: tasks,
}
}
/// Insert a pending event waiting for resend after authentication
@@ -162,15 +172,12 @@ impl RelayAuth {
/// Get all pending events for a specific relay,
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
let pending_events: Vec<EventId> = self
.pending_events
self.pending_events
.iter()
.filter(|(_, pending_relay)| pending_relay == relay)
.map(|(id, _relay)| id)
.cloned()
.collect();
pending_events
.collect()
}
/// Clear all pending events for a specific relay,
@@ -282,10 +289,12 @@ impl RelayAuth {
Ok(_) => {
// Clear pending events for the authenticated relay
this.clear_pending_events(url, cx);
// Save the authenticated relay to automatically authenticate future requests
settings.update(cx, |this, cx| {
this.add_trusted_relay(url, cx);
});
window.push_notification(format!("{} has been authenticated", url), cx);
}
Err(e) => {
@@ -334,8 +343,8 @@ impl RelayAuth {
.px_1p5()
.rounded_sm()
.text_xs()
.bg(cx.theme().warning_background)
.text_color(cx.theme().warning_foreground)
.bg(cx.theme().elevated_surface_background)
.text_color(cx.theme().text_accent)
.child(url.clone()),
)
.into_any_element()
@@ -352,11 +361,9 @@ impl RelayAuth {
.disabled(loading.get())
.on_click({
let loading = Rc::clone(&loading);
move |_ev, window, cx| {
// Set loading state to true
loading.set(true);
// Process to approve the request
view.update(cx, |this, cx| {
this.response(&req, window, cx);

View File

@@ -2,12 +2,12 @@ use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::rc::Rc;
use anyhow::{anyhow, Error};
use anyhow::{Error, anyhow};
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{smallvec, SmallVec};
use smallvec::{SmallVec, smallvec};
use theme::{Theme, ThemeFamily, ThemeMode};
pub fn init(window: &mut Window, cx: &mut App) {
@@ -94,21 +94,28 @@ pub struct RoomConfig {
}
impl RoomConfig {
pub fn new() -> Self {
Self {
backup: true,
signer_kind: SignerKind::Auto,
}
}
/// Get backup config
pub fn backup(&self) -> bool {
self.backup
}
/// Set backup config
pub fn toggle_backup(&mut self) {
self.backup = !self.backup;
}
/// Get signer kind config
pub fn signer_kind(&self) -> &SignerKind {
&self.signer_kind
}
/// Set backup config
pub fn set_backup(&mut self, backup: bool) {
self.backup = backup;
}
/// Set signer kind config
pub fn set_signer_kind(&mut self, kind: &SignerKind) {
self.signer_kind = kind.to_owned();
@@ -284,6 +291,8 @@ impl AppSettings {
/// Reset theme
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.values.theme = None;
cx.notify();
self.apply_theme(window, cx);
}

View File

@@ -11,7 +11,6 @@ nostr.workspace = true
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
nostr-connect.workspace = true
nostr-gossip-sqlite.workspace = true
nostr-blossom.workspace = true
gpui.workspace = true

View File

@@ -12,7 +12,7 @@ pub const APP_ID: &str = "su.reya.coop";
/// Keyring name
pub const KEYRING: &str = "Coop Safe Storage";
/// Default timeout in second for subscription
/// Default timeout for subscription
pub const TIMEOUT: u64 = 2;
/// Default delay for searching
@@ -21,28 +21,28 @@ pub const FIND_DELAY: u64 = 600;
/// Default limit for searching
pub const FIND_LIMIT: usize = 20;
/// Default timeout for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default Nostr Connect relay
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default subscription id for device gift wrap events
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
/// Default subscription id for user gift wrap events
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
/// Default timeout for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
/// Default Nostr Connect relay
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
/// Default vertex relays
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
/// Default search relays
pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"];
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://indexer.coracle.social",
"wss://user.kindpag.es",
];

View File

@@ -1,3 +1,5 @@
use std::fmt::Display;
use gpui::SharedString;
use nostr_sdk::prelude::*;
@@ -9,6 +11,16 @@ pub enum DeviceState {
Set,
}
impl Display for DeviceState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeviceState::Idle => write!(f, "Idle"),
DeviceState::Requesting => write!(f, "Wait for approval"),
DeviceState::Set => write!(f, "Encryption Key is ready"),
}
}
}
impl DeviceState {
pub fn idle(&self) -> bool {
matches!(self, DeviceState::Idle)

View File

@@ -0,0 +1,83 @@
use std::collections::{HashMap, HashSet};
use gpui::SharedString;
use nostr_sdk::prelude::*;
/// Gossip
#[derive(Debug, Clone, Default)]
pub struct Gossip {
relays: HashMap<PublicKey, HashSet<(RelayUrl, Option<RelayMetadata>)>>,
}
impl Gossip {
pub fn read_only_relays(&self, public_key: &PublicKey) -> Vec<SharedString> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.map(|(url, _)| url.to_string().into())
.collect()
})
.unwrap_or_default()
}
/// Get read relays for a given public key
pub fn read_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Read) {
Some(url.to_owned())
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}
/// Get write relays for a given public key
pub fn write_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
self.relays
.get(public_key)
.map(|relays| {
relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url.to_owned())
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}
/// Insert gossip relays for a public key
pub fn insert_relays(&mut self, event: &Event) {
self.relays.entry(event.pubkey).or_default().extend(
event
.tags
.iter()
.filter_map(|tag| {
if let Some(TagStandard::RelayMetadata {
relay_url,
metadata,
}) = tag.clone().to_standardized()
{
Some((relay_url, metadata))
} else {
None
}
})
.take(3),
);
}
}

View File

@@ -1,25 +1,25 @@
use std::collections::HashMap;
use std::os::unix::fs::PermissionsExt;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use anyhow::{Context as AnyhowContext, Error, anyhow};
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Task, Window};
use nostr_connect::prelude::*;
use nostr_gossip_sqlite::prelude::*;
use nostr_lmdb::prelude::*;
use nostr_sdk::prelude::*;
mod blossom;
mod constants;
mod device;
mod gossip;
mod nip05;
mod signer;
pub use blossom::*;
pub use constants::*;
pub use device::*;
pub use gossip::*;
pub use nip05::*;
pub use signer::*;
@@ -50,19 +50,19 @@ pub struct NostrRegistry {
/// Nostr signer
signer: Arc<CoopSigner>,
/// Local public keys
npubs: Entity<Vec<PublicKey>>,
/// Custom gossip implementation
gossip: Entity<Gossip>,
/// App keys
///
/// Used for Nostr Connect and NIP-4e operations
app_keys: Keys,
pub app_keys: Keys,
/// Relay list state
relay_list_state: RelayState,
/// Whether Coop is connected to all bootstrap relays
connected: bool,
/// Whether Coop is creating a new signer
creating: bool,
pub relay_list_state: RelayState,
/// Tasks for asynchronous operations
tasks: Vec<Task<Result<(), Error>>>,
@@ -85,6 +85,12 @@ impl NostrRegistry {
let app_keys = get_or_init_app_keys().unwrap_or(Keys::generate());
let signer = Arc::new(CoopSigner::new(app_keys.clone()));
// Construct the nostr npubs entity
let npubs = cx.new(|_| vec![]);
// Construct the gossip entity
let gossip = cx.new(|_| Gossip::default());
// Construct the nostr lmdb instance
let lmdb = cx.foreground_executor().block_on(async move {
NostrLmdb::open(config_dir().join("nostr"))
@@ -92,21 +98,13 @@ impl NostrRegistry {
.expect("Failed to initialize database")
});
// Construct the nostr gossip instance
let gossip = cx.foreground_executor().block_on(async move {
NostrGossipSqlite::open(config_dir().join("gossip"))
.await
.expect("Failed to initialize gossip database")
});
// Construct the nostr client
let client = ClientBuilder::default()
.signer(signer.clone())
.database(lmdb)
.gossip(gossip)
.automatic_authentication(false)
.verify_subscriptions(false)
.connect_timeout(Duration::from_secs(5))
.connect_timeout(Duration::from_secs(TIMEOUT))
.sleep_when_idle(SleepWhenIdle::Enabled {
timeout: Duration::from_secs(600),
})
@@ -115,19 +113,36 @@ impl NostrRegistry {
// Run at the end of current cycle
cx.defer_in(window, |this, _window, cx| {
this.connect(cx);
this.handle_notifications(cx);
});
Self {
client,
signer,
npubs,
app_keys,
gossip,
relay_list_state: RelayState::Idle,
connected: false,
creating: false,
tasks: vec![],
}
}
/// Get the nostr client
pub fn client(&self) -> Client {
self.client.clone()
}
/// Get the nostr signer
pub fn signer(&self) -> Arc<CoopSigner> {
self.signer.clone()
}
/// Get the npubs entity
pub fn npubs(&self) -> Entity<Vec<PublicKey>> {
self.npubs.clone()
}
/// Connect to the bootstrapping relays
fn connect(&mut self, cx: &mut Context<Self>) {
let client = self.client();
@@ -145,73 +160,65 @@ impl NostrRegistry {
}
// Connect to all added relays
client.connect().and_wait(Duration::from_secs(5)).await;
client.connect().and_wait(Duration::from_secs(2)).await;
})
.await;
// Update the state
this.update(cx, |this, cx| {
this.set_connected(cx);
this.get_signer(cx);
this.get_npubs(cx);
})?;
Ok(())
}));
}
/// Get the nostr client
pub fn client(&self) -> Client {
self.client.clone()
}
/// Handle nostr notifications
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
let client = self.client();
let gossip = self.gossip.downgrade();
/// Get the nostr signer
pub fn signer(&self) -> Arc<CoopSigner> {
self.signer.clone()
}
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Event>(2048);
/// Get the app keys
pub fn app_keys(&self) -> &Keys {
&self.app_keys
}
self.tasks.push(cx.background_spawn(async move {
// Handle nostr notifications
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
/// Get the connected status of the client
pub fn connected(&self) -> bool {
self.connected
}
while let Some(notification) = notifications.next().await {
if let ClientNotification::Message {
message:
RelayMessage::Event {
event,
subscription_id,
},
..
} = notification
{
if !processed_events.insert(event.id) {
// Skip if the event has already been processed
continue;
}
/// Get the creating status
pub fn creating(&self) -> bool {
self.creating
}
/// Get the relay list state
pub fn relay_list_state(&self) -> RelayState {
self.relay_list_state.clone()
}
/// Set the connected status of the client
fn set_connected(&mut self, cx: &mut Context<Self>) {
self.connected = true;
cx.notify();
}
/// Get local stored signer
fn get_signer(&mut self, cx: &mut Context<Self>) {
let read_credential = cx.read_credentials(KEYRING);
self.tasks.push(cx.spawn(async move |this, cx| {
match read_credential.await {
Ok(Some((_user, secret))) => {
let secret = SecretKey::from_slice(&secret)?;
let keys = Keys::new(secret);
this.update(cx, |this, cx| {
this.set_signer(keys, false, cx);
})?;
if let Kind::RelayList = event.kind {
if subscription_id.as_str().contains("room-") {
get_events_for_room(&client, &event).await.ok();
}
tx.send_async(event.into_owned()).await?;
}
}
_ => {
this.update(cx, |this, cx| {
this.get_bunker(cx);
}
Ok(())
}));
self.tasks.push(cx.spawn(async move |_this, cx| {
while let Ok(event) = rx.recv_async().await {
if let Kind::RelayList = event.kind {
gossip.update(cx, |this, cx| {
this.insert_relays(&event);
cx.notify();
})?;
}
}
@@ -220,45 +227,63 @@ impl NostrRegistry {
}));
}
/// Get local stored bunker connection
fn get_bunker(&mut self, cx: &mut Context<Self>) {
let client = self.client();
let app_keys = self.app_keys().clone();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
/// Get all used npubs
fn get_npubs(&mut self, cx: &mut Context<Self>) {
let npubs = self.npubs.downgrade();
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
let dir = config_dir().join("keys");
// Ensure keys directory exists
smol::fs::create_dir_all(&dir).await?;
let task: Task<Result<NostrConnect, Error>> = cx.background_spawn(async move {
log::info!("Getting bunker connection");
let mut files = smol::fs::read_dir(&dir).await?;
let mut entries = Vec::new();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier("coop:account")
.limit(1);
while let Some(Ok(entry)) = files.next().await {
let metadata = entry.metadata().await?;
let modified_time = metadata.modified()?;
let name = entry
.file_name()
.into_string()
.unwrap()
.replace(".npub", "");
if let Some(event) = client.database().query(filter).await?.first_owned() {
let uri = NostrConnectUri::parse(event.content)?;
let signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None)?;
Ok(signer)
} else {
Err(anyhow!("No account found"))
entries.push((modified_time, name));
}
// Sort by modification time (most recent first)
entries.sort_by(|a, b| b.0.cmp(&a.0));
let mut npubs = Vec::new();
for (_, name) in entries {
let public_key = PublicKey::parse(&name)?;
npubs.push(public_key);
}
Ok(npubs)
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(signer) => {
this.update(cx, |this, cx| {
this.set_signer(signer, true, cx);
})
.ok();
}
Ok(public_keys) => match public_keys.is_empty() {
true => {
this.update(cx, |this, cx| {
this.create_identity(cx);
})?;
}
false => {
// TODO: auto login
npubs.update(cx, |this, cx| {
this.extend(public_keys);
cx.notify();
})?;
}
},
Err(e) => {
log::warn!("Failed to get bunker: {e}");
// Create a new identity if no stored bunker exists
log::error!("Failed to get npubs: {e}");
this.update(cx, |this, cx| {
this.set_default_signer(cx);
})
.ok();
this.create_identity(cx);
})?;
}
}
@@ -266,58 +291,17 @@ impl NostrRegistry {
}));
}
/// Set the signer for the nostr client and verify the public key
pub fn set_signer<T>(&mut self, new: T, owned: bool, cx: &mut Context<Self>)
where
T: NostrSigner + 'static,
{
let client = self.client();
let signer = self.signer();
// Create a task to update the signer and verify the public key
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Update signer
signer.switch(new, owned).await;
// Unsubscribe from all subscriptions
client.unsubscribe_all().await?;
// Verify signer
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
log::info!("Signer's public key: {}", public_key);
Ok(())
});
self.tasks.push(cx.spawn(async move |this, cx| {
// set signer
task.await?;
// Update states
this.update(cx, |this, cx| {
this.ensure_relay_list(cx);
})?;
Ok(())
}));
}
/// Create a new identity
fn set_default_signer(&mut self, cx: &mut Context<Self>) {
fn create_identity(&mut self, cx: &mut Context<Self>) {
let client = self.client();
let keys = Keys::generate();
let async_keys = keys.clone();
// Create a write credential task
let write_credential = cx.write_credentials(
KEYRING,
&keys.public_key().to_hex(),
&keys.secret_key().to_secret_bytes(),
);
let username = keys.public_key().to_bech32().unwrap();
let secret = keys.secret_key().to_secret_bytes();
// Set the creating signer status
self.set_creating_signer(true, cx);
// Create a write credential task
let write_credential = cx.write_credentials(&username, &username, &secret);
// Run async tasks in background
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
@@ -326,14 +310,34 @@ impl NostrRegistry {
// Get default relay list
let relay_list = default_relay_list();
// Extract write relays
let write_urls: Vec<RelayUrl> = relay_list
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url)
} else {
None
}
})
.cloned()
.collect();
// Ensure connected to all relays
for (url, _metadata) in relay_list.iter() {
client.add_relay(url).and_connect().await?;
}
// Publish relay list event
let event = EventBuilder::relay_list(relay_list).sign(&signer).await?;
client
let output = client
.send_event(&event)
.broadcast()
.to(BOOTSTRAP_RELAYS)
.ok_timeout(Duration::from_secs(TIMEOUT))
.await?;
log::info!("Sent gossip relay list: {output:?}");
// Construct the default metadata
let name = petname::petname(2, "-").unwrap_or("Cooper".to_string());
let avatar = Url::parse(&format!("https://avatar.vercel.sh/{name}")).unwrap();
@@ -343,7 +347,7 @@ impl NostrRegistry {
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
client
.send_event(&event)
.ok_timeout(Duration::from_secs(TIMEOUT))
.to(&write_urls)
.ack_policy(AckPolicy::none())
.await?;
@@ -354,19 +358,23 @@ impl NostrRegistry {
let event = EventBuilder::contact_list(contacts).sign(&signer).await?;
client
.send_event(&event)
.broadcast()
.ok_timeout(Duration::from_secs(TIMEOUT))
.to(&write_urls)
.ack_policy(AckPolicy::none())
.await?;
// Construct the default messaging relay list
let relays = default_messaging_relays();
// Ensure connected to all relays
for url in relays.iter() {
client.add_relay(url).and_connect().await?;
}
// Publish messaging relay list event
let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?;
client
.send_event(&event)
.ok_timeout(Duration::from_secs(TIMEOUT))
.to(&write_urls)
.ack_policy(AckPolicy::none())
.await?;
@@ -380,23 +388,212 @@ impl NostrRegistry {
// Wait for the task to complete
task.await?;
// Set signer
this.update(cx, |this, cx| {
this.set_creating_signer(false, cx);
this.set_signer(keys, false, cx);
this.set_signer(keys, cx);
})?;
Ok(())
}));
}
/// Set whether Coop is creating a new signer
fn set_creating_signer(&mut self, creating: bool, cx: &mut Context<Self>) {
self.creating = creating;
cx.notify();
/// Get the signer in keyring by username
pub fn get_signer(
&self,
public_key: &PublicKey,
cx: &App,
) -> Task<Result<Arc<dyn NostrSigner>, Error>> {
let username = public_key.to_bech32().unwrap();
let app_keys = self.app_keys.clone();
let read_credential = cx.read_credentials(&username);
cx.spawn(async move |_cx| {
let (_, secret) = read_credential
.await
.map_err(|_| anyhow!("Failed to get signer. Please re-import the secret key"))?
.ok_or_else(|| anyhow!("Failed to get signer. Please re-import the secret key"))?;
// Try to parse as a direct secret key first
if let Ok(secret_key) = SecretKey::from_slice(&secret) {
return Ok(Keys::new(secret_key).into_nostr_signer());
}
// Convert the secret into string
let sec = String::from_utf8(secret)
.map_err(|_| anyhow!("Failed to parse secret as UTF-8"))?;
// Try to parse as a NIP-46 URI
let uri =
NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?;
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let mut nip46 = NostrConnect::new(uri, app_keys, timeout, None)?;
// Set the auth URL handler
nip46.auth_url_handler(CoopAuthUrlHandler);
Ok(nip46.into_nostr_signer())
})
}
/// Set relay list state
fn set_relay_list_state(&mut self, state: RelayState, cx: &mut Context<Self>) {
/// Set the signer for the nostr client and verify the public key
pub fn set_signer<T>(&mut self, new: T, cx: &mut Context<Self>)
where
T: NostrSigner + 'static,
{
let client = self.client();
let signer = self.signer();
// Create a task to update the signer and verify the public key
let task: Task<Result<PublicKey, Error>> = cx.background_spawn(async move {
// Update signer and unsubscribe
signer.switch(new).await;
client.unsubscribe_all().await?;
// Verify and get public key
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let npub = public_key.to_bech32().unwrap();
let keys_dir = config_dir().join("keys");
// Ensure keys directory exists
smol::fs::create_dir_all(&keys_dir).await?;
let key_path = keys_dir.join(format!("{}.npub", npub));
smol::fs::write(key_path, "").await?;
log::info!("Signer's public key: {}", public_key);
Ok(public_key)
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(public_key) => {
// Update states
this.update(cx, |this, cx| {
// Add public key to npubs if not already present
this.npubs.update(cx, |this, cx| {
if !this.contains(&public_key) {
this.push(public_key);
cx.notify();
}
});
// Ensure relay list for the user
this.ensure_relay_list(cx);
// Emit signer changed event
cx.emit(SignerEvent::Set);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
}
}
Ok(())
}));
}
/// Remove a signer from the keyring
pub fn remove_signer(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
let public_key = public_key.to_owned();
let npub = public_key.to_bech32().unwrap();
let keys_dir = config_dir().join("keys");
self.tasks.push(cx.spawn(async move |this, cx| {
let key_path = keys_dir.join(format!("{}.npub", npub));
smol::fs::remove_file(key_path).await?;
this.update(cx, |this, cx| {
this.npubs().update(cx, |this, cx| {
this.retain(|k| k != &public_key);
cx.notify();
});
})?;
Ok(())
}));
}
/// Add a key signer to keyring
pub fn add_key_signer(&mut self, keys: &Keys, cx: &mut Context<Self>) {
let keys = keys.clone();
let username = keys.public_key().to_bech32().unwrap();
let secret = keys.secret_key().to_secret_bytes();
// Write the credential to the keyring
let write_credential = cx.write_credentials(&username, "keys", &secret);
self.tasks.push(cx.spawn(async move |this, cx| {
match write_credential.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
}
}
Ok(())
}));
}
/// Add a nostr connect signer to keyring
pub fn add_nip46_signer(&mut self, nip46: &NostrConnect, cx: &mut Context<Self>) {
let nip46 = nip46.clone();
let async_nip46 = nip46.clone();
// Connect and verify the remote signer
let task: Task<Result<(PublicKey, NostrConnectUri), Error>> =
cx.background_spawn(async move {
let uri = async_nip46.bunker_uri().await?;
let public_key = async_nip46.get_public_key().await?;
Ok((public_key, uri))
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok((public_key, uri)) => {
let username = public_key.to_bech32().unwrap();
let write_credential = this.read_with(cx, |_this, cx| {
cx.write_credentials(&username, "nostrconnect", uri.to_string().as_bytes())
})?;
match write_credential.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_signer(nip46, cx);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
}
}
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
}
}
Ok(())
}));
}
/// Set the state of the relay list
fn set_relay_state(&mut self, state: RelayState, cx: &mut Context<Self>) {
self.relay_list_state = state;
cx.notify();
}
@@ -404,15 +601,16 @@ impl NostrRegistry {
pub fn ensure_relay_list(&mut self, cx: &mut Context<Self>) {
let task = self.verify_relay_list(cx);
// Reset state
self.set_relay_list_state(RelayState::default(), cx);
// Set the state to idle before starting the task
self.set_relay_state(RelayState::default(), cx);
self.tasks.push(cx.spawn(async move |this, cx| {
let result = task.await?;
// Update state
this.update(cx, |this, cx| {
this.set_relay_list_state(result, cx);
this.relay_list_state = result;
cx.notify();
})?;
Ok(())
@@ -432,9 +630,15 @@ impl NostrRegistry {
.author(public_key)
.limit(1);
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
// Stream events from the bootstrap relays
let mut stream = client
.stream_events(filter)
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await?;
@@ -454,46 +658,96 @@ impl NostrRegistry {
})
}
/// Generate a direct nostr connection initiated by the client
pub fn client_connect(&self, relay: Option<RelayUrl>) -> (NostrConnect, NostrConnectUri) {
let app_keys = self.app_keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
/// Ensure write relays for a given public key
pub fn ensure_write_relays(&self, public_key: &PublicKey, cx: &App) -> Task<Vec<RelayUrl>> {
let client = self.client();
let public_key = *public_key;
// Determine the relay will be used for Nostr Connect
let relay = match relay {
Some(relay) => relay,
None => RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap(),
};
cx.background_spawn(async move {
let mut relays = vec![];
// Generate the nostr connect uri
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
let filter = Filter::new()
.kind(Kind::RelayList)
.author(public_key)
.limit(1);
// Generate the nostr connect
let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
// Construct target for subscription
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect();
// Handle the auth request
signer.auth_url_handler(CoopAuthUrlHandler);
if let Ok(mut stream) = client
.stream_events(target)
.timeout(Duration::from_secs(TIMEOUT))
.await
{
while let Some((_url, res)) = stream.next().await {
match res {
Ok(event) => {
// Extract relay urls
relays.extend(nip65::extract_owned_relay_list(event).filter_map(
|(url, metadata)| {
if metadata.is_none() || metadata == Some(RelayMetadata::Write)
{
Some(url)
} else {
None
}
},
));
(signer, uri)
// Ensure connections
for url in relays.iter() {
client.add_relay(url).and_connect().await.ok();
}
return relays;
}
Err(e) => {
log::error!("Failed to receive relay list event: {e}");
}
}
}
}
relays
})
}
/// Store the bunker connection for the next login
pub fn persist_bunker(&mut self, uri: NostrConnectUri, cx: &mut App) {
/// Get a list of write relays for a given public key
pub fn write_relays(&self, public_key: &PublicKey, cx: &App) -> Task<Vec<RelayUrl>> {
let client = self.client();
let rng_keys = Keys::generate();
let relays = self.gossip.read(cx).write_relays(public_key);
self.tasks.push(cx.background_spawn(async move {
// Construct the event for application-specific data
let event = EventBuilder::new(Kind::ApplicationSpecificData, uri.to_string())
.tag(Tag::identifier("coop:account"))
.sign(&rng_keys)
.await?;
cx.background_spawn(async move {
// Ensure relay connections
for url in relays.iter() {
client.add_relay(url).and_connect().await.ok();
}
// Store the event in the database
client.database().save_event(&event).await?;
relays
})
}
Ok(())
}));
/// Get a list of read relays for a given public key
pub fn read_relays(&self, public_key: &PublicKey, cx: &App) -> Task<Vec<RelayUrl>> {
let client = self.client();
let relays = self.gossip.read(cx).read_relays(public_key);
cx.background_spawn(async move {
// Ensure relay connections
for url in relays.iter() {
client.add_relay(url).and_connect().await.ok();
}
relays
})
}
/// Get all relays for a given public key without ensuring connections
pub fn read_only_relays(&self, public_key: &PublicKey, cx: &App) -> Vec<SharedString> {
self.gossip.read(cx).read_only_relays(public_key)
}
/// Get the public key of a NIP-05 address
@@ -516,10 +770,10 @@ impl NostrRegistry {
.limit(1);
// Construct target for subscription
let target = BOOTSTRAP_RELAYS
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect::<HashMap<_, _>>();
.collect();
client.subscribe(target).close_on(opts).await?;
@@ -563,10 +817,10 @@ impl NostrRegistry {
.limit(FIND_LIMIT);
// Construct target for subscription
let target = SEARCH_RELAYS
let target: HashMap<&str, Vec<Filter>> = SEARCH_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect::<HashMap<_, _>>();
.collect();
// Stream events from the search relays
let mut stream = client
@@ -611,10 +865,10 @@ impl NostrRegistry {
.event(output.id().to_owned());
// Construct target for subscription
let target = WOT_RELAYS
let target: HashMap<&str, Vec<Filter>> = WOT_RELAYS
.into_iter()
.map(|relay| (relay, vec![filter.clone()]))
.collect::<HashMap<_, _>>();
.collect();
// Stream events from the wot relays
let mut stream = client
@@ -651,6 +905,8 @@ impl NostrRegistry {
}
}
impl EventEmitter<SignerEvent> for NostrRegistry {}
/// Get or create a new app keys
fn get_or_init_app_keys() -> Result<Keys, Error> {
let dir = config_dir().join(".app_keys");
@@ -666,11 +922,6 @@ fn get_or_init_app_keys() -> Result<Keys, Error> {
std::fs::create_dir_all(dir.parent().unwrap())?;
std::fs::write(&dir, secret_key.to_secret_bytes())?;
// Set permissions to readonly
let mut perms = std::fs::metadata(&dir)?.permissions();
perms.set_mode(0o400);
std::fs::set_permissions(&dir, perms)?;
return Ok(keys);
}
};
@@ -681,10 +932,56 @@ fn get_or_init_app_keys() -> Result<Keys, Error> {
Ok(keys)
}
async fn get_events_for_room(client: &Client, nip65: &Event) -> Result<(), Error> {
// Subscription options
let opts = SubscribeAutoCloseOptions::default()
.timeout(Some(Duration::from_secs(TIMEOUT)))
.exit_policy(ReqExitPolicy::ExitOnEOSE);
// Extract write relays from event
let write_relays: Vec<&RelayUrl> = nip65::extract_relay_list(nip65)
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url)
} else {
None
}
})
.collect();
// Ensure relay connections
for url in write_relays.iter() {
client.add_relay(*url).and_connect().await.ok();
}
// Construct filter for inbox relays
let inbox = Filter::new()
.kind(Kind::InboxRelays)
.author(nip65.pubkey)
.limit(1);
// Construct filter for encryption announcement
let announcement = Filter::new()
.kind(Kind::Custom(10044))
.author(nip65.pubkey)
.limit(1);
// Construct target for subscription
let target: HashMap<&RelayUrl, Vec<Filter>> = write_relays
.into_iter()
.map(|relay| (relay, vec![inbox.clone(), announcement.clone()]))
.collect();
// Subscribe to inbox relays and encryption announcements
client.subscribe(target).close_on(opts).await?;
Ok(())
}
fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
vec![
(
RelayUrl::parse("wss://relay.gulugulu.moe").unwrap(),
RelayUrl::parse("wss://relay.nostr.net").unwrap(),
Some(RelayMetadata::Write),
),
(
@@ -699,6 +996,10 @@ fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
RelayUrl::parse("wss://nos.lol").unwrap(),
Some(RelayMetadata::Read),
),
(
RelayUrl::parse("wss://nostr.superfriends.online").unwrap(),
None,
),
]
}
@@ -706,9 +1007,20 @@ fn default_messaging_relays() -> Vec<RelayUrl> {
vec![
RelayUrl::parse("wss://nos.lol").unwrap(),
RelayUrl::parse("wss://nip17.com").unwrap(),
RelayUrl::parse("wss://relay.0xchat.com").unwrap(),
]
}
/// Signer event.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerEvent {
/// A new signer has been set
Set,
/// An error occurred
Error(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum RelayState {
#[default]

View File

@@ -1,6 +1,5 @@
use std::borrow::Cow;
use std::result::Result;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use nostr_sdk::prelude::*;
@@ -16,11 +15,6 @@ pub struct CoopSigner {
/// Specific signer for encryption purposes
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
/// By default, Coop generates a new signer for new users.
///
/// This flag indicates whether the signer is user-owned or Coop-generated.
owned: AtomicBool,
}
impl CoopSigner {
@@ -32,7 +26,6 @@ impl CoopSigner {
signer: RwLock::new(signer.into_nostr_signer()),
signer_pkey: RwLock::new(None),
encryption_signer: RwLock::new(None),
owned: AtomicBool::new(false),
}
}
@@ -47,17 +40,15 @@ impl CoopSigner {
}
/// Get public key
///
/// Ensure to call this method after the signer has been initialized.
/// Otherwise, this method will panic.
pub fn public_key(&self) -> Option<PublicKey> {
self.signer_pkey.read_blocking().to_owned()
}
/// Get the flag indicating whether the signer is user-owned.
pub fn owned(&self) -> bool {
self.owned.load(Ordering::SeqCst)
*self.signer_pkey.read_blocking()
}
/// Switch the current signer to a new signer.
pub async fn switch<T>(&self, new: T, owned: bool)
pub async fn switch<T>(&self, new: T)
where
T: IntoNostrSigner,
{
@@ -75,9 +66,6 @@ impl CoopSigner {
// Reset the encryption signer
*encryption_signer = None;
// Update the owned flag
self.owned.store(owned, Ordering::SeqCst);
}
/// Set the encryption signer.

View File

@@ -1,7 +1,7 @@
use std::ops::{Deref, DerefMut};
use std::rc::Rc;
use gpui::{px, App, Global, Pixels, SharedString, Window};
use gpui::{App, Global, Pixels, SharedString, Window, px};
mod colors;
mod platform_kind;

View File

@@ -1,14 +1,12 @@
use gpui::prelude::FluentBuilder;
#[cfg(target_os = "linux")]
use gpui::MouseButton;
#[cfg(not(target_os = "windows"))]
use gpui::Pixels;
use gpui::prelude::FluentBuilder;
use gpui::{
px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea,
AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement,
Pixels, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, px,
};
use smallvec::{smallvec, SmallVec};
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING};
use smallvec::{SmallVec, smallvec};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, PlatformKind};
use ui::h_flex;
#[cfg(target_os = "linux")]

View File

@@ -1,10 +1,24 @@
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, rems, AbsoluteLength, App, Hsla, ImageSource, Img, IntoElement, ParentElement,
RenderOnce, Styled, StyledImage, Window,
div, img, px, AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement,
Interactivity, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
Window,
};
use theme::ActiveTheme;
use crate::{Sizable, Size};
/// Returns the size of the avatar based on the given [`Size`].
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
match size {
Size::Large => px(64.).into(),
Size::Medium => px(32.).into(),
Size::Small => px(24.).into(),
Size::XSmall => px(20.).into(),
Size::Size(size) => size.into(),
}
}
/// An element that renders a user avatar with customizable appearance options.
///
/// # Examples
@@ -18,8 +32,10 @@ use theme::ActiveTheme;
/// ```
#[derive(IntoElement)]
pub struct Avatar {
base: Div,
image: Img,
size: Option<AbsoluteLength>,
style: StyleRefinement,
size: Size,
border_color: Option<Hsla>,
}
@@ -27,8 +43,10 @@ impl Avatar {
/// Creates a new avatar element with the specified image source.
pub fn new(src: impl Into<ImageSource>) -> Self {
Avatar {
base: div(),
image: img(src),
size: None,
style: StyleRefinement::default(),
size: Size::Medium,
border_color: None,
}
}
@@ -56,14 +74,27 @@ impl Avatar {
self.border_color = Some(color.into());
self
}
}
/// Size overrides the avatar size. By default they are 1rem.
pub fn size<L: Into<AbsoluteLength>>(mut self, size: impl Into<Option<L>>) -> Self {
self.size = size.into().map(Into::into);
impl Sizable for Avatar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Styled for Avatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl InteractiveElement for Avatar {
fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity()
}
}
impl RenderOnce for Avatar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let border_width = if self.border_color.is_some() {
@@ -71,8 +102,7 @@ impl RenderOnce for Avatar {
} else {
px(0.)
};
let image_size = self.size.unwrap_or_else(|| rems(1.).into());
let image_size = avatar_size(self.size);
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
div()

View File

@@ -2,10 +2,11 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity,
EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window,
actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations,
Edges, Entity, EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement,
ParentElement as _, Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window,
};
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
use crate::dock_area::dock::{Dock, DockPlacement};
use crate::dock_area::panel::{Panel, PanelEvent, PanelStyle, PanelView};
@@ -202,19 +203,16 @@ impl DockItem {
/// Returns all panel ids
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
match self {
Self::Tabs { view, .. } => view.read(cx).panel_ids(cx),
Self::Split { items, .. } => {
let mut total = vec![];
for item in items.iter() {
if let DockItem::Tabs { view, .. } = item {
total.extend(view.read(cx).panel_ids(cx));
}
}
total
}
Self::Panel { .. } => vec![],
Self::Tabs { view, .. } => view.read(cx).panel_ids(cx),
Self::Split { items, .. } => items
.iter()
.filter_map(|item| match item {
DockItem::Tabs { view, .. } => Some(view.read(cx).panel_ids(cx)),
_ => None,
})
.flatten()
.collect(),
}
}
@@ -745,6 +743,7 @@ impl EventEmitter<DockEvent> for DockArea {}
impl Render for DockArea {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity().clone();
let decorations = window.window_decorations();
div()
.id("dock-area")
@@ -754,7 +753,17 @@ impl Render for DockArea {
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
.map(|this| {
if let Some(zoom_view) = self.zoom_view.clone() {
this.child(zoom_view)
this.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling } => this
.when(!(tiling.top || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.child(zoom_view)
} else {
// render dock
this.child(

View File

@@ -1080,8 +1080,10 @@ impl TabPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(panel) = self.active_panel(cx) {
self.remove_panel(&panel, window, cx);
if self.panels.len() > 1 {
if let Some(panel) = self.active_panel(cx) {
self.remove_panel(&panel, window, cx);
}
}
}
}

View File

@@ -23,6 +23,7 @@ pub enum IconName {
ArrowLeft,
ArrowRight,
Boom,
Book,
ChevronDown,
CaretDown,
CaretRight,
@@ -33,6 +34,7 @@ pub enum IconName {
CloseCircle,
CloseCircleFill,
Copy,
Device,
Door,
Ellipsis,
Emoji,
@@ -51,11 +53,14 @@ pub enum IconName {
Relay,
Reply,
Refresh,
Scan,
Search,
Settings,
Settings2,
Sun,
Ship,
Shield,
Group,
UserKey,
Upload,
Usb,
@@ -89,6 +94,7 @@ impl IconNamed for IconName {
Self::ArrowLeft => "icons/arrow-left.svg",
Self::ArrowRight => "icons/arrow-right.svg",
Self::Boom => "icons/boom.svg",
Self::Book => "icons/book.svg",
Self::ChevronDown => "icons/chevron-down.svg",
Self::CaretDown => "icons/caret-down.svg",
Self::CaretRight => "icons/caret-right.svg",
@@ -99,6 +105,7 @@ impl IconNamed for IconName {
Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Copy => "icons/copy.svg",
Self::Device => "icons/device.svg",
Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg",
@@ -117,14 +124,17 @@ impl IconNamed for IconName {
Self::Relay => "icons/relay.svg",
Self::Reply => "icons/reply.svg",
Self::Refresh => "icons/refresh.svg",
Self::Scan => "icons/scan.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Sun => "icons/sun.svg",
Self::Ship => "icons/ship.svg",
Self::Shield => "icons/shield.svg",
Self::UserKey => "icons/user-key.svg",
Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg",
Self::Group => "icons/group.svg",
Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRight => "icons/panel-right.svg",

View File

@@ -1026,7 +1026,7 @@ impl PopupMenu {
} else if checked {
Icon::new(IconName::Check)
} else {
return None;
Icon::empty()
};
Some(icon.small())
@@ -1112,25 +1112,17 @@ impl PopupMenu {
.border_color(cx.theme().border)
.disabled(true),
PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
h_flex()
.cursor_default()
.items_center()
.gap_x_1()
.children(Self::render_icon(has_left_icon, false, None, window, cx))
.child(
div()
.flex_1()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(label.clone()),
),
h_flex().cursor_default().items_center().gap_x_1().child(
div()
.flex_1()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(label.clone()),
),
),
PopupMenuItem::ElementItem {
render,
icon,
disabled,
..
render, disabled, ..
} => this
.when(!disabled, |this| {
this.on_click(
@@ -1144,13 +1136,6 @@ impl PopupMenu {
.min_h(item_height)
.items_center()
.gap_x_2()
.children(Self::render_icon(
has_left_icon,
is_left_check,
icon.clone(),
window,
cx,
))
.child((render)(window, cx))
.children(right_check_icon.map(|icon| icon.ml_3())),
),

View File

@@ -343,7 +343,7 @@ impl RenderOnce for Modal {
});
let window_paddings = crate::root::window_paddings(window, cx);
let radius = (cx.theme().radius_lg * 2.).min(px(20.));
let radius = cx.theme().radius_lg;
let view_size = window.viewport_size()
- gpui::size(
@@ -360,8 +360,8 @@ impl RenderOnce for Modal {
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.;
let mut padding_right = px(16.);
let mut padding_left = px(16.);
let mut padding_right = px(8.);
let mut padding_left = px(8.);
if let Some(pl) = self.style.padding.left {
padding_left = pl.to_pixels(self.width.into(), window.rem_size());

View File

@@ -2,10 +2,10 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
canvas, div, point, px, size, AnyView, App, AppContext, Bounds, Context, CursorStyle,
Decorations, Edges, Entity, FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement,
MouseButton, ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled,
Tiling, WeakFocusHandle, Window,
AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, Edges, Entity,
FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled, Tiling,
WeakFocusHandle, Window, canvas, div, point, px, size,
};
use theme::{
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
@@ -249,7 +249,6 @@ impl Render for Root {
div()
.id("window")
.size_full()
.bg(gpui::transparent_black())
.map(|div| match decorations {
Decorations::Server => div,
Decorations::Client { tiling } => div

View File

@@ -3,13 +3,13 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
div, App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window,
App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
};
use super::{Scrollbar, ScrollbarAxis};
use crate::scroll::ScrollbarHandle;
use crate::StyledExt;
use crate::scroll::ScrollbarHandle;
/// A trait for elements that can be made scrollable with scrollbars.
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
@@ -160,6 +160,7 @@ where
}
impl ScrollableElement for Div {}
impl<E> ScrollableElement for Stateful<E>
where
E: ParentElement + Styled + Element,
@@ -195,6 +196,7 @@ fn render_scrollbar<H: ScrollbarHandle + Clone>(
// Do not render scrollbar when inspector is picking elements,
// to allow us to pick the background elements.
let is_inspector_picking = window.is_inspector_picking(cx);
if is_inspector_picking {
return div();
}

View File

@@ -5,11 +5,11 @@ use std::rc::Rc;
use std::time::{Duration, Instant};
use gpui::{
fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner,
CursorStyle, Edges, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
InspectorElementId, IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style,
UniformListScrollHandle, Window,
App, Axis, BorderStyle, Bounds, ContentMask, Corner, CursorStyle, Edges, Element, ElementId,
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero,
LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
point, px, relative, size,
};
use theme::{ActiveTheme, ScrollbarMode};
@@ -407,7 +407,6 @@ impl Scrollbar {
ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
_ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
};
(
cx.theme().scrollbar_thumb_background,
cx.theme().scrollbar_track_background,
@@ -522,6 +521,7 @@ impl Element for Scrollbar {
let mut states = vec![];
let mut has_both = self.axis.is_both();
let scroll_size = self
.scroll_size
.unwrap_or(self.scroll_handle.content_size());

View File

@@ -1,3 +1,5 @@
edition = "2024"
style_edition = "2024"
tab_spaces = 4
newline_style = "Auto"
reorder_imports = true