Compare commits
11 Commits
v1.0.0-rc
...
7b27a7e864
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b27a7e864 | ||
|
|
cc4174a695 | ||
|
|
ecd25d04da | ||
|
|
0403d0e4ec | ||
|
|
707533c145 | ||
|
|
163865eb46 | ||
|
|
fbc9883680 | ||
|
|
d1f0373916 | ||
|
|
216c877ebf | ||
|
|
1b4aa02cc0 | ||
|
|
b547776263 |
10
.github/workflows/release.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
|||||||
# Windows and macOS builds using cargo-packager
|
# Windows and macOS builds using cargo-packager
|
||||||
- name: Build with cargo-packager (Windows/macOS)
|
- name: Build with cargo-packager (Windows/macOS)
|
||||||
if: runner.os != 'Linux'
|
if: runner.os != 'Linux'
|
||||||
working-directory: desktop
|
working-directory: crates/coop
|
||||||
run: |
|
run: |
|
||||||
cargo install cargo-packager --locked
|
cargo install cargo-packager --locked
|
||||||
cargo packager --release
|
cargo packager --release
|
||||||
@@ -154,13 +154,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: akkuman/gitea-release-action@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
server_url: "https://git.reya.su/"
|
tag_name: ${{ steps.version.outputs.tag }}
|
||||||
repository: "reya/coop"
|
name: ${{ steps.version.outputs.tag }}
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
draft: true
|
draft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
generate_release_notes: true
|
||||||
files: |
|
files: |
|
||||||
artifacts/**/*
|
artifacts/**/*
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
@@ -19,8 +19,8 @@ dist/
|
|||||||
|
|
||||||
# Useless stuffs
|
# Useless stuffs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
# Added by goreleaser init:
|
||||||
|
.intentionally-empty-file.o
|
||||||
|
|
||||||
.cargo/
|
.cargo/
|
||||||
vendor/
|
vendor/
|
||||||
wasm/
|
|
||||||
node_modules/
|
|
||||||
|
|||||||
3556
Cargo.lock
generated
24
Cargo.toml
@@ -1,10 +1,10 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["crates/*", "desktop", "web"]
|
members = ["crates/*"]
|
||||||
default-members = ["desktop"]
|
default-members = ["crates/coop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.0-beta5"
|
version = "1.0.0-beta2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -15,40 +15,34 @@ gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["fo
|
|||||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||||
gpui_windows = { 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" }
|
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
gpui_web = { git = "https://github.com/zed-industries/zed" }
|
||||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
|
||||||
# Nostr
|
# Nostr
|
||||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-memory = { 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-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
|
||||||
nostr-sdk = { 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 = [ "nip59", "nip49", "nip44" ] }
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.44"
|
||||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
chrono = "0.4.38"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
itertools = "0.13.0"
|
itertools = "0.13.0"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
oneshot = "0.1.10"
|
oneshot = "0.1.10"
|
||||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
rust-embed = "8.5.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
schemars = "1"
|
schemars = "1"
|
||||||
smallvec = "1.14.0"
|
smallvec = "1.14.0"
|
||||||
smol = "2"
|
smol = "2"
|
||||||
|
tracing = "0.1.40"
|
||||||
webbrowser = "1.0.4"
|
webbrowser = "1.0.4"
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
|
||||||
errno = { version = "0.3.14", default-features = false }
|
|
||||||
instant = "0.1"
|
|
||||||
|
|
||||||
[patch.crates-io]
|
|
||||||
# Use stacker's psm version which may have better WASM support
|
|
||||||
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
62
Cargo.toml-E
@@ -1,62 +0,0 @@
|
|||||||
[workspace]
|
|
||||||
resolver = "2"
|
|
||||||
members = ["crates/*", "desktop", "web"]
|
|
||||||
default-members = ["desktop"]
|
|
||||||
|
|
||||||
[workspace.package]
|
|
||||||
version = "1.0.0-beta5"
|
|
||||||
edition = "2024"
|
|
||||||
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", "x11", "wayland"] }
|
|
||||||
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" }
|
|
||||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
|
||||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
|
||||||
|
|
||||||
# Nostr
|
|
||||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
|
||||||
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
|
||||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
|
||||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
|
||||||
nostr-connect = { 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 = [ "nip59", "nip49", "nip44" ] }
|
|
||||||
|
|
||||||
# Others
|
|
||||||
anyhow = "1.0.44"
|
|
||||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
|
||||||
futures = "0.3"
|
|
||||||
itertools = "0.13.0"
|
|
||||||
log = "0.4"
|
|
||||||
oneshot = "0.1.10"
|
|
||||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
|
||||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
schemars = "1"
|
|
||||||
smallvec = "1.14.0"
|
|
||||||
smol = "2"
|
|
||||||
webbrowser = "1.0.4"
|
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
|
||||||
errno = { version = "0.3.14", default-features = false }
|
|
||||||
instant = "0.1"
|
|
||||||
|
|
||||||
[patch.crates-io]
|
|
||||||
# Use stacker's psm version which may have better WASM support
|
|
||||||
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
strip = true
|
|
||||||
opt-level = "z"
|
|
||||||
lto = true
|
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
|
||||||
|
|
||||||
[profile.profiling]
|
|
||||||
inherits = "release"
|
|
||||||
debug = true
|
|
||||||
16
README.md
@@ -1,12 +1,12 @@
|
|||||||

|

|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
|
<a href="https://github.com/lumehq/coop/actions/workflows/rust.yml">
|
||||||
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
|
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/rust.yml/badge.svg">
|
||||||
</a>
|
</a>
|
||||||
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
|
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/lumehq/coop">
|
||||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
|
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/lumehq/coop">
|
||||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
|
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/lumehq/coop">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
||||||
@@ -36,7 +36,7 @@ To install Coop, follow these steps:
|
|||||||
|
|
||||||
1. **Download the Latest Release**:
|
1. **Download the Latest Release**:
|
||||||
|
|
||||||
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
|
- Visit the [Coop Releases page on GitHub](https://github.com/lumehq/coop/releases).
|
||||||
- Download the package that matches your operating system (Windows, macOS, or Linux).
|
- Download the package that matches your operating system (Windows, macOS, or Linux).
|
||||||
|
|
||||||
2. **Install**:
|
2. **Install**:
|
||||||
@@ -65,7 +65,7 @@ Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nos
|
|||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/reyakov/coop.git
|
git clone https://github.com/lumehq/coop.git
|
||||||
cd coop
|
cd coop
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ For more information, see the [Contributing](#contributing) section.
|
|||||||
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
|
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
|
||||||
- [GPUI](https://www.gpui.rs/)
|
- [GPUI](https://www.gpui.rs/)
|
||||||
- [GPUI Components](https://github.com/longbridge/gpui-component/)
|
- [GPUI Components](https://github.com/longbridge/gpui-component/)
|
||||||
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
|
- [Coop Issue Tracker](https://github.com/lumehq/coop/issues/)
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ publish.workspace = true
|
|||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
instant.workspace = true
|
gpui_tokio.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
@@ -17,6 +18,4 @@ serde_json.workspace = true
|
|||||||
|
|
||||||
semver = "1.0.27"
|
semver = "1.0.27"
|
||||||
tempfile = "3.23.0"
|
tempfile = "3.23.0"
|
||||||
|
futures.workspace = true
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
|
||||||
smol.workspace = true
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
#![cfg(not(target_arch = "wasm32"))]
|
|
||||||
|
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use gpui::http_client::{AsyncBody, HttpClient};
|
use gpui::http_client::{AsyncBody, HttpClient};
|
||||||
@@ -10,7 +9,6 @@ use gpui::{
|
|||||||
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||||
Window,
|
Window,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use semver::Version;
|
use semver::Version;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
|||||||
@@ -13,16 +13,15 @@ settings = { path = "../settings" }
|
|||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
instant.workspace = true
|
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
log.workspace = true
|
|
||||||
flume.workspace = true
|
|
||||||
|
|
||||||
futures.workspace = true
|
|
||||||
fuzzy-matcher = "0.3.7"
|
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
flume.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
fuzzy-matcher = "0.3.7"
|
||||||
|
|||||||
@@ -1,110 +1,10 @@
|
|||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
use common::{EventExt, NostrParser};
|
||||||
use gpui::{SharedString, SharedUri};
|
use gpui::SharedString;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
/// Rendered message.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Message {
|
|
||||||
pub id: EventId,
|
|
||||||
/// Author's public key
|
|
||||||
pub author: PublicKey,
|
|
||||||
/// The content/text of the message
|
|
||||||
pub content: String,
|
|
||||||
/// List of media URLs in the message
|
|
||||||
pub media: Vec<SharedUri>,
|
|
||||||
/// Message created time as unix timestamp
|
|
||||||
pub created_at: Timestamp,
|
|
||||||
/// List of mentioned public keys in the message
|
|
||||||
pub mentions: Vec<Mention>,
|
|
||||||
/// List of event of the message this message is a reply to
|
|
||||||
pub replies_to: Vec<EventId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&Event> for Message {
|
|
||||||
fn from(val: &Event) -> Self {
|
|
||||||
let mentions = extract_mentions(&val.content);
|
|
||||||
let replies_to = extract_reply_ids(&val.tags);
|
|
||||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
id: val.id,
|
|
||||||
author: val.pubkey,
|
|
||||||
content: string,
|
|
||||||
media,
|
|
||||||
created_at: val.created_at,
|
|
||||||
mentions,
|
|
||||||
replies_to,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&UnsignedEvent> for Message {
|
|
||||||
fn from(val: &UnsignedEvent) -> Self {
|
|
||||||
let mentions = extract_mentions(&val.content);
|
|
||||||
let replies_to = extract_reply_ids(&val.tags);
|
|
||||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
// Event ID must be known
|
|
||||||
id: val.id.unwrap(),
|
|
||||||
author: val.pubkey,
|
|
||||||
content: string,
|
|
||||||
media,
|
|
||||||
created_at: val.created_at,
|
|
||||||
mentions,
|
|
||||||
replies_to,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&NewMessage> for Message {
|
|
||||||
fn from(val: &NewMessage) -> Self {
|
|
||||||
let mentions = extract_mentions(&val.rumor.content);
|
|
||||||
let replies_to = extract_reply_ids(&val.rumor.tags);
|
|
||||||
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
// Event ID must be known
|
|
||||||
id: val.rumor.id.unwrap(),
|
|
||||||
author: val.rumor.pubkey,
|
|
||||||
content: string,
|
|
||||||
media,
|
|
||||||
created_at: val.rumor.created_at,
|
|
||||||
mentions,
|
|
||||||
replies_to,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Eq for Message {}
|
|
||||||
|
|
||||||
impl PartialEq for Message {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
self.id == other.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Ord for Message {
|
|
||||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
|
||||||
self.created_at.cmp(&other.created_at)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialOrd for Message {
|
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
||||||
Some(self.cmp(other))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Hash for Message {
|
|
||||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
|
||||||
self.id.hash(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// New message.
|
/// New message.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct NewMessage {
|
pub struct NewMessage {
|
||||||
@@ -144,6 +44,74 @@ impl FailedMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Message.
|
||||||
|
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||||
|
pub enum Message {
|
||||||
|
User(RenderedMessage),
|
||||||
|
Warning(String, Timestamp),
|
||||||
|
System(Timestamp),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
pub fn user<I>(user: I) -> Self
|
||||||
|
where
|
||||||
|
I: Into<RenderedMessage>,
|
||||||
|
{
|
||||||
|
Self::User(user.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warning<I>(content: I) -> Self
|
||||||
|
where
|
||||||
|
I: Into<String>,
|
||||||
|
{
|
||||||
|
Self::Warning(content.into(), Timestamp::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn system() -> Self {
|
||||||
|
Self::System(Timestamp::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamp(&self) -> &Timestamp {
|
||||||
|
match self {
|
||||||
|
Message::User(msg) => &msg.created_at,
|
||||||
|
Message::Warning(_, ts) => ts,
|
||||||
|
Message::System(ts) => ts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&NewMessage> for Message {
|
||||||
|
fn from(val: &NewMessage) -> Self {
|
||||||
|
Self::User(val.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&UnsignedEvent> for Message {
|
||||||
|
fn from(val: &UnsignedEvent) -> Self {
|
||||||
|
Self::User(val.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for Message {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
match (self, other) {
|
||||||
|
// System always comes first
|
||||||
|
(Message::System(_), Message::System(_)) => self.timestamp().cmp(other.timestamp()),
|
||||||
|
(Message::System(_), _) => std::cmp::Ordering::Less,
|
||||||
|
(_, Message::System(_)) => std::cmp::Ordering::Greater,
|
||||||
|
|
||||||
|
// For non-system messages, compare by timestamp
|
||||||
|
_ => self.timestamp().cmp(other.timestamp()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for Message {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Mention {
|
pub struct Mention {
|
||||||
pub public_key: PublicKey,
|
pub public_key: PublicKey,
|
||||||
@@ -156,6 +124,98 @@ impl Mention {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rendered message.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RenderedMessage {
|
||||||
|
pub id: EventId,
|
||||||
|
/// Author's public key
|
||||||
|
pub author: PublicKey,
|
||||||
|
/// The content/text of the message
|
||||||
|
pub content: String,
|
||||||
|
/// Message created time as unix timestamp
|
||||||
|
pub created_at: Timestamp,
|
||||||
|
/// List of mentioned public keys in the message
|
||||||
|
pub mentions: Vec<Mention>,
|
||||||
|
/// List of event of the message this message is a reply to
|
||||||
|
pub replies_to: Vec<EventId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Event> for RenderedMessage {
|
||||||
|
fn from(val: &Event) -> Self {
|
||||||
|
let mentions = extract_mentions(&val.content);
|
||||||
|
let replies_to = extract_reply_ids(&val.tags);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: val.id,
|
||||||
|
author: val.pubkey,
|
||||||
|
content: val.content.clone(),
|
||||||
|
created_at: val.created_at,
|
||||||
|
mentions,
|
||||||
|
replies_to,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&UnsignedEvent> for RenderedMessage {
|
||||||
|
fn from(val: &UnsignedEvent) -> Self {
|
||||||
|
let mentions = extract_mentions(&val.content);
|
||||||
|
let replies_to = extract_reply_ids(&val.tags);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
// Event ID must be known
|
||||||
|
id: val.id.unwrap(),
|
||||||
|
author: val.pubkey,
|
||||||
|
content: val.content.clone(),
|
||||||
|
created_at: val.created_at,
|
||||||
|
mentions,
|
||||||
|
replies_to,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&NewMessage> for RenderedMessage {
|
||||||
|
fn from(val: &NewMessage) -> Self {
|
||||||
|
let mentions = extract_mentions(&val.rumor.content);
|
||||||
|
let replies_to = extract_reply_ids(&val.rumor.tags);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
// Event ID must be known
|
||||||
|
id: val.rumor.id.unwrap(),
|
||||||
|
author: val.rumor.pubkey,
|
||||||
|
content: val.rumor.content.clone(),
|
||||||
|
created_at: val.rumor.created_at,
|
||||||
|
mentions,
|
||||||
|
replies_to,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for RenderedMessage {}
|
||||||
|
|
||||||
|
impl PartialEq for RenderedMessage {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.id == other.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for RenderedMessage {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
self.created_at.cmp(&other.created_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for RenderedMessage {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hash for RenderedMessage {
|
||||||
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||||
|
self.id.hash(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Extracts all mentions (public keys) from a content string.
|
/// Extracts all mentions (public keys) from a content string.
|
||||||
fn extract_mentions(content: &str) -> Vec<Mention> {
|
fn extract_mentions(content: &str) -> Vec<Mention> {
|
||||||
let parser = NostrParser::new();
|
let parser = NostrParser::new();
|
||||||
@@ -174,13 +234,13 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
|
|||||||
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||||
let mut replies_to = vec![];
|
let mut replies_to = vec![];
|
||||||
|
|
||||||
for tag in inner.iter().filter(|tag| tag.kind() == "e") {
|
for tag in inner.filter(TagKind::e()) {
|
||||||
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
||||||
replies_to.push(id);
|
replies_to.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for tag in inner.iter().filter(|tag| tag.kind() == "q") {
|
for tag in inner.filter(TagKind::q()) {
|
||||||
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
||||||
replies_to.push(id);
|
replies_to.push(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use instant::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::EventExt;
|
use common::EventExt;
|
||||||
use device::DeviceRegistry;
|
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
use settings::{RoomConfig, SignerKind};
|
use settings::{RoomConfig, SignerKind};
|
||||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
use state::{NostrRegistry, TIMEOUT};
|
||||||
|
|
||||||
use crate::NewMessage;
|
use crate::NewMessage;
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ pub struct SendReport {
|
|||||||
pub receiver: PublicKey,
|
pub receiver: PublicKey,
|
||||||
pub gift_wrap_id: Option<EventId>,
|
pub gift_wrap_id: Option<EventId>,
|
||||||
pub error: Option<SharedString>,
|
pub error: Option<SharedString>,
|
||||||
pub output: Option<Output<EventId, EventSendStatus>>,
|
pub output: Option<Output<EventId>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SendReport {
|
impl SendReport {
|
||||||
@@ -42,7 +41,7 @@ impl SendReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the output.
|
/// Set the output.
|
||||||
pub fn output(mut self, output: Output<EventId, EventSendStatus>) -> Self {
|
pub fn output(mut self, output: Output<EventId>) -> Self {
|
||||||
self.output = Some(output);
|
self.output = Some(output);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -172,8 +171,7 @@ impl From<&UnsignedEvent> for Room {
|
|||||||
let members = val.extract_public_keys();
|
let members = val.extract_public_keys();
|
||||||
let subject = val
|
let subject = val
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.find(TagKind::Subject)
|
||||||
.find(|tag| tag.kind() == "subject")
|
|
||||||
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
|
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
|
||||||
|
|
||||||
Room {
|
Room {
|
||||||
@@ -207,7 +205,7 @@ impl Room {
|
|||||||
// WARNING: never sign this event
|
// WARNING: never sign this event
|
||||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.finalize_unsigned(author);
|
.build(author);
|
||||||
|
|
||||||
// Ensure that the ID is set
|
// Ensure that the ID is set
|
||||||
event.ensure_id();
|
event.ensure_id();
|
||||||
@@ -363,11 +361,7 @@ impl Room {
|
|||||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||||
|
|
||||||
let tasks: Vec<_> = members
|
for public_key in members.into_iter() {
|
||||||
.into_iter()
|
|
||||||
.map(|public_key| {
|
|
||||||
let client = client.clone();
|
|
||||||
async move {
|
|
||||||
let inbox = Filter::new()
|
let inbox = Filter::new()
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
.kind(Kind::InboxRelays)
|
.kind(Kind::InboxRelays)
|
||||||
@@ -378,16 +372,11 @@ impl Room {
|
|||||||
.kind(Kind::Custom(10044))
|
.kind(Kind::Custom(10044))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
// Subscribe to the target
|
||||||
client
|
client
|
||||||
.subscribe(vec![inbox, announcement])
|
.subscribe(vec![inbox, announcement])
|
||||||
.close_on(opts)
|
.close_on(opts)
|
||||||
.await
|
.await?;
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for result in futures::future::join_all(tasks).await {
|
|
||||||
result?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -398,12 +387,12 @@ impl Room {
|
|||||||
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
|
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let room_id = self.id.to_string();
|
let conversation_id = self.id.to_string();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::R), room_id);
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
|
||||||
|
|
||||||
let messages = client
|
let messages = client
|
||||||
.database()
|
.database()
|
||||||
@@ -432,14 +421,24 @@ impl Room {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
// Get current user's public key
|
// Get current user's public key
|
||||||
let sender = nostr.read(cx).current_user()?;
|
let sender = nostr.read(cx).signer().public_key()?;
|
||||||
|
|
||||||
|
// Get all members, excluding the sender
|
||||||
|
let members: Vec<Person> = self
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.filter(|public_key| public_key != &&sender)
|
||||||
|
.map(|member| persons.read(cx).get(member, cx))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Construct event's tags
|
// Construct event's tags
|
||||||
let mut tags = vec![];
|
let mut tags = vec![];
|
||||||
|
|
||||||
// Add subject tag if present
|
// Add subject tag if present
|
||||||
if let Some(value) = self.subject.as_ref() {
|
if let Some(value) = self.subject.as_ref() {
|
||||||
tags.push(Tag::custom("subject", vec![value.to_string()]));
|
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
||||||
|
value.to_string(),
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all reply tags
|
// Add all reply tags
|
||||||
@@ -447,23 +446,21 @@ impl Room {
|
|||||||
tags.push(Tag::event(id))
|
tags.push(Tag::event(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all receiver tags (no intermediate allocation)
|
// Add all receiver tags
|
||||||
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
for member in members.into_iter() {
|
||||||
let member = persons.read(cx).get(public_key, cx);
|
tags.push(Tag::from_standardized_without_cell(
|
||||||
tags.push(
|
TagStandard::PublicKey {
|
||||||
Nip01Tag::PublicKey {
|
|
||||||
public_key: member.public_key(),
|
public_key: member.public_key(),
|
||||||
relay_hint: member.messaging_relay_hint(),
|
relay_url: member.messaging_relay_hint(),
|
||||||
}
|
alias: None,
|
||||||
.to_tag(),
|
uppercase: false,
|
||||||
);
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct a direct message rumor event
|
// Construct a direct message rumor event
|
||||||
// WARNING: never sign and send this event to relays
|
// WARNING: never sign and send this event to relays
|
||||||
let mut event = EventBuilder::new(kind, content)
|
let mut event = EventBuilder::new(kind, content).tags(tags).build(sender);
|
||||||
.tags(tags)
|
|
||||||
.finalize_unsigned(sender);
|
|
||||||
|
|
||||||
// Ensure that the ID is set
|
// Ensure that the ID is set
|
||||||
event.ensure_id();
|
event.ensure_id();
|
||||||
@@ -471,45 +468,17 @@ impl Room {
|
|||||||
Some(event)
|
Some(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the appropriate signer based on signer kind and available keys.
|
|
||||||
fn select_signer(
|
|
||||||
signer_kind: &SignerKind,
|
|
||||||
has_announcement: bool,
|
|
||||||
encryption_signer: &Option<UniversalSigner>,
|
|
||||||
user_signer: &UniversalSigner,
|
|
||||||
) -> UniversalSigner {
|
|
||||||
match signer_kind {
|
|
||||||
SignerKind::Auto => {
|
|
||||||
if has_announcement {
|
|
||||||
encryption_signer
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| user_signer.clone())
|
|
||||||
} else {
|
|
||||||
user_signer.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SignerKind::Encryption => encryption_signer
|
|
||||||
.clone()
|
|
||||||
.expect("encryption signer must be set"),
|
|
||||||
SignerKind::User => user_signer.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send rumor event to all members's messaging relays
|
/// Send rumor event to all members's messaging relays
|
||||||
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
||||||
let config = self.config.clone();
|
let config = self.config.clone();
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
let device = DeviceRegistry::global(cx);
|
|
||||||
let encryption_signer = device.read(cx).signer(cx);
|
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let user_signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
let current_user = nostr.read(cx).current_user()?;
|
|
||||||
|
|
||||||
// Get sender's profile
|
// Get current user's public key
|
||||||
let persons = PersonRegistry::global(cx);
|
let public_key = nostr.read(cx).signer().public_key()?;
|
||||||
let sender = persons.read(cx).get(¤t_user, cx);
|
let sender = persons.read(cx).get(&public_key, cx);
|
||||||
|
|
||||||
// Get all members (excluding sender)
|
// Get all members (excluding sender)
|
||||||
let members: Vec<Person> = self
|
let members: Vec<Person> = self
|
||||||
@@ -523,6 +492,9 @@ impl Room {
|
|||||||
let signer_kind = config.signer_kind();
|
let signer_kind = config.signer_kind();
|
||||||
let backup = config.backup();
|
let backup = config.backup();
|
||||||
|
|
||||||
|
let user_signer = signer.get().await;
|
||||||
|
let encryption_signer = signer.get_encryption_signer().await;
|
||||||
|
|
||||||
let mut sents = 0;
|
let mut sents = 0;
|
||||||
let mut reports = Vec::new();
|
let mut reports = Vec::new();
|
||||||
|
|
||||||
@@ -547,12 +519,23 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine the signer to use
|
// Determine the signer to use
|
||||||
let signer = Self::select_signer(
|
let signer = match signer_kind {
|
||||||
signer_kind,
|
SignerKind::Auto => {
|
||||||
announcement.is_some(),
|
if announcement.is_some()
|
||||||
&encryption_signer,
|
&& let Some(encryption_signer) = encryption_signer.clone()
|
||||||
&user_signer,
|
{
|
||||||
);
|
// Safe to unwrap due to earlier checks
|
||||||
|
encryption_signer
|
||||||
|
} else {
|
||||||
|
user_signer.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SignerKind::Encryption => {
|
||||||
|
// Safe to unwrap due to earlier checks
|
||||||
|
encryption_signer.as_ref().unwrap().clone()
|
||||||
|
}
|
||||||
|
SignerKind::User => user_signer.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
// Send the gift wrap event and collect the report
|
// Send the gift wrap event and collect the report
|
||||||
match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
|
match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
|
||||||
@@ -572,12 +555,23 @@ impl Room {
|
|||||||
let public_key = sender.public_key();
|
let public_key = sender.public_key();
|
||||||
|
|
||||||
// Determine the signer to use
|
// Determine the signer to use
|
||||||
let signer = Self::select_signer(
|
let signer = match signer_kind {
|
||||||
signer_kind,
|
SignerKind::Auto => {
|
||||||
sender.announcement().is_some(),
|
if sender.announcement().is_some()
|
||||||
&encryption_signer,
|
&& let Some(encryption_signer) = encryption_signer.clone()
|
||||||
&user_signer,
|
{
|
||||||
);
|
// Safe to unwrap due to earlier checks
|
||||||
|
encryption_signer
|
||||||
|
} else {
|
||||||
|
user_signer.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SignerKind::Encryption => {
|
||||||
|
// Safe to unwrap due to earlier checks
|
||||||
|
encryption_signer.as_ref().unwrap().clone()
|
||||||
|
}
|
||||||
|
SignerKind::User => user_signer.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
|
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
|
||||||
Ok(report) => reports.push(report),
|
Ok(report) => reports.push(report),
|
||||||
@@ -594,15 +588,17 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to send a gift-wrapped event
|
// Helper function to send a gift-wrapped event
|
||||||
async fn send_gift_wrap(
|
async fn send_gift_wrap<T>(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
signer: &UniversalSigner,
|
signer: &T,
|
||||||
receiver: &Person,
|
receiver: &Person,
|
||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
config: &SignerKind,
|
config: &SignerKind,
|
||||||
) -> Result<SendReport, Error> {
|
) -> Result<SendReport, Error>
|
||||||
let k_tag = Tag::custom("k", vec!["14"]);
|
where
|
||||||
let mut extra_tags = vec![k_tag];
|
T: NostrSigner + 'static,
|
||||||
|
{
|
||||||
|
let mut extra_tags = vec![];
|
||||||
|
|
||||||
// Determine the receiver public key based on the config
|
// Determine the receiver public key based on the config
|
||||||
let receiver = match config {
|
let receiver = match config {
|
||||||
@@ -626,10 +622,7 @@ async fn send_gift_wrap(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Construct the gift wrap event
|
// Construct the gift wrap event
|
||||||
let event = nip59::GiftWrapBuilder::new(receiver, rumor.clone())
|
let event = EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), extra_tags).await?;
|
||||||
.extra_tags(extra_tags)
|
|
||||||
.finalize_async(signer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Send the gift wrap event and collect the report
|
// Send the gift wrap event and collect the report
|
||||||
let report = client
|
let report = client
|
||||||
|
|||||||
@@ -14,15 +14,19 @@ chat = { path = "../chat" }
|
|||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
gpui_tokio.workspace = true
|
||||||
|
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
futures.workspace = true
|
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
once_cell = "1.19.0"
|
||||||
|
regex = "1"
|
||||||
linkify = "0.10.0"
|
linkify = "0.10.0"
|
||||||
pulldown-cmark = "0.13.1"
|
pulldown-cmark = "0.13.1"
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::{Context as AnyhowContext, Error};
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
use chat::{ChatRegistry, Message, RenderedMessage, Room, RoomEvent, SendReport, SendStatus};
|
||||||
use common::{TimestampExt, coop_cache};
|
use common::TimestampExt;
|
||||||
use futures::lock::Mutex;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, div,
|
Styled, StyledImage, Subscription, Task, WeakEntity, Window, deferred, div, img, list, px, red,
|
||||||
img, list, px, red, relative, svg, white,
|
relative, svg, white,
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
use settings::{AppSettings, SignerKind};
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use smol::lock::RwLock;
|
||||||
use state::{NostrRegistry, upload};
|
use state::{NostrRegistry, upload};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::menu::DropdownMenu;
|
use ui::menu::DropdownMenu;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
@@ -38,6 +38,9 @@ use crate::text::RenderedText;
|
|||||||
mod actions;
|
mod actions;
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
|
const ANNOUNCEMENT: &str =
|
||||||
|
"This conversation is private. Only members can see each other's messages.";
|
||||||
|
|
||||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||||
cx.new(|cx| ChatPanel::new(room, window, cx))
|
cx.new(|cx| ChatPanel::new(room, window, cx))
|
||||||
}
|
}
|
||||||
@@ -53,14 +56,14 @@ pub struct ChatPanel {
|
|||||||
/// Message list state
|
/// Message list state
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
|
|
||||||
/// All messages (sorted by created_at)
|
/// All messages
|
||||||
messages: Vec<Message>,
|
messages: BTreeSet<Message>,
|
||||||
|
|
||||||
/// Mapping message ids to their rendered texts
|
/// Mapping message ids to their rendered texts
|
||||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||||
|
|
||||||
/// Mapping message (rumor event) ids to their reports
|
/// Mapping message (rumor event) ids to their reports
|
||||||
reports_by_id: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
|
reports_by_id: Entity<BTreeMap<EventId, Vec<SendReport>>>,
|
||||||
|
|
||||||
/// Chat input state
|
/// Chat input state
|
||||||
input: Entity<InputState>,
|
input: Entity<InputState>,
|
||||||
@@ -72,7 +75,7 @@ pub struct ChatPanel {
|
|||||||
subject_bar: Entity<bool>,
|
subject_bar: Entity<bool>,
|
||||||
|
|
||||||
/// Sent message ids
|
/// Sent message ids
|
||||||
sent_ids: Arc<Mutex<Vec<EventId>>>,
|
sent_ids: Arc<RwLock<Vec<EventId>>>,
|
||||||
|
|
||||||
/// Replies to
|
/// Replies to
|
||||||
replies_to: Entity<HashSet<EventId>>,
|
replies_to: Entity<HashSet<EventId>>,
|
||||||
@@ -95,10 +98,10 @@ impl ChatPanel {
|
|||||||
// Define attachments and replies_to entities
|
// Define attachments and replies_to entities
|
||||||
let attachments = cx.new(|_| vec![]);
|
let attachments = cx.new(|_| vec![]);
|
||||||
let replies_to = cx.new(|_| HashSet::new());
|
let replies_to = cx.new(|_| HashSet::new());
|
||||||
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
let reports_by_id = cx.new(|_| BTreeMap::new());
|
||||||
|
|
||||||
// Define list of messages
|
// Define list of messages
|
||||||
let messages = Vec::new();
|
let messages = BTreeSet::from([Message::system()]);
|
||||||
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
||||||
|
|
||||||
// Get room id and name
|
// Get room id and name
|
||||||
@@ -116,6 +119,7 @@ impl ChatPanel {
|
|||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
.placeholder(format!("Message {}", name))
|
.placeholder(format!("Message {}", name))
|
||||||
.auto_grow(1, 20)
|
.auto_grow(1, 20)
|
||||||
|
.prevent_new_line_on_enter()
|
||||||
.clean_on_escape()
|
.clean_on_escape()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,7 +173,7 @@ impl ChatPanel {
|
|||||||
attachments,
|
attachments,
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
reports_by_id,
|
reports_by_id,
|
||||||
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
sent_ids: Arc::new(RwLock::new(Vec::new())),
|
||||||
uploading: false,
|
uploading: false,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
@@ -189,7 +193,7 @@ impl ChatPanel {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let sent_ids = self.sent_ids.clone();
|
let sent_ids = self.sent_ids.clone();
|
||||||
let reports = self.reports_by_id.clone();
|
let reports = self.reports_by_id.downgrade();
|
||||||
|
|
||||||
let (tx, rx) = flume::bounded::<Arc<SendStatus>>(256);
|
let (tx, rx) = flume::bounded::<Arc<SendStatus>>(256);
|
||||||
|
|
||||||
@@ -204,7 +208,7 @@ impl ChatPanel {
|
|||||||
message,
|
message,
|
||||||
} = *message
|
} = *message
|
||||||
{
|
{
|
||||||
let sent_ids = sent_ids.lock().await;
|
let sent_ids = sent_ids.read().await;
|
||||||
|
|
||||||
if sent_ids.contains(&event_id) {
|
if sent_ids.contains(&event_id) {
|
||||||
let status = if status {
|
let status = if status {
|
||||||
@@ -220,11 +224,10 @@ impl ChatPanel {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |_this, cx| {
|
||||||
while let Ok(status) = rx.recv_async().await {
|
while let Ok(status) = rx.recv_async().await {
|
||||||
{
|
reports.update(cx, |this, cx| {
|
||||||
let mut map = reports.write().unwrap();
|
for reports in this.values_mut() {
|
||||||
for reports in map.values_mut() {
|
|
||||||
for report in reports.iter_mut() {
|
for report in reports.iter_mut() {
|
||||||
let Some(output) = report.output.as_mut() else {
|
let Some(output) = report.output.as_mut() else {
|
||||||
continue;
|
continue;
|
||||||
@@ -232,7 +235,7 @@ impl ChatPanel {
|
|||||||
match &*status {
|
match &*status {
|
||||||
SendStatus::Ok { id, relay } => {
|
SendStatus::Ok { id, relay } => {
|
||||||
if output.id() == id {
|
if output.id() == id {
|
||||||
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
output.success.insert(relay.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SendStatus::Failed { id, relay, message } => {
|
SendStatus::Failed { id, relay, message } => {
|
||||||
@@ -241,10 +244,10 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})?;
|
||||||
this.update(cx, |_, cx| cx.notify()).ok();
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
@@ -262,11 +265,7 @@ impl ChatPanel {
|
|||||||
this.insert_message(message, false, cx);
|
this.insert_message(message, false, cx);
|
||||||
}
|
}
|
||||||
RoomEvent::Reload => {
|
RoomEvent::Reload => {
|
||||||
// Defer to avoid re-entrant read on Room while
|
|
||||||
// emit_refresh holds a write lock (via refresh_rooms).
|
|
||||||
cx.defer_in(window, |this, window, cx| {
|
|
||||||
this.get_messages(window, cx);
|
this.get_messages(window, cx);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -347,47 +346,69 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get room entity
|
||||||
let room = self.room.clone();
|
let room = self.room.clone();
|
||||||
|
|
||||||
|
// Get content and replies
|
||||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||||
let content = value.to_string();
|
let content = value.to_string();
|
||||||
let sent_ids = self.sent_ids.clone();
|
|
||||||
|
|
||||||
// Upgrade room and create rumor + send task in a single read lock
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
let Some(room_entity) = room.upgrade() else {
|
let room = room.upgrade().context("Room is not available")?;
|
||||||
return;
|
|
||||||
};
|
this.update_in(cx, |this, window, cx| {
|
||||||
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
|
match room.read(cx).rumor(content, replies, cx) {
|
||||||
let rumor = room.rumor(content.clone(), replies, cx)?;
|
Some(rumor) => {
|
||||||
let send_task = room.send(rumor.clone(), cx)?;
|
this.insert_message(&rumor, true, cx);
|
||||||
Some((rumor, send_task))
|
this.send_and_wait(rumor, window, cx);
|
||||||
}) {
|
this.clear(window, cx);
|
||||||
Some(pair) => pair,
|
}
|
||||||
None => {
|
None => {
|
||||||
window.push_notification("Failed to create message", cx);
|
window.push_notification("Failed to create message", cx);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send message in the background and wait for the response
|
||||||
|
fn send_and_wait(&mut self, rumor: UnsignedEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let sent_ids = self.sent_ids.clone();
|
||||||
|
|
||||||
|
// This can't fail, because we already ensured that the ID is set
|
||||||
|
let id = rumor.id.unwrap();
|
||||||
|
|
||||||
|
// Add empty reports
|
||||||
|
self.insert_reports(id, vec![], cx);
|
||||||
|
|
||||||
|
// Upgrade room reference
|
||||||
|
let Some(room) = self.room.upgrade() else {
|
||||||
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let id = rumor.id.expect("rumor must have an id");
|
// Get the send message task
|
||||||
|
let Some(task) = room.read(cx).send(rumor, cx) else {
|
||||||
|
window.push_notification("Failed to send message", cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
// Insert optimistic message and clear input
|
|
||||||
self.insert_message(&rumor, true, cx);
|
|
||||||
self.insert_reports(id, vec![], cx);
|
|
||||||
self.clear(window, cx);
|
|
||||||
|
|
||||||
// Spawn a single task to await the send and update reports
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
let outputs = send_task.await;
|
// Send and get reports
|
||||||
|
let outputs = task.await;
|
||||||
|
|
||||||
let mut sent_ids = sent_ids.lock().await;
|
// Add sent IDs to the list
|
||||||
|
let mut sent_ids = sent_ids.write().await;
|
||||||
sent_ids.extend(outputs.iter().filter_map(|output| output.gift_wrap_id));
|
sent_ids.extend(outputs.iter().filter_map(|output| output.gift_wrap_id));
|
||||||
|
|
||||||
|
// Update the state
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.insert_reports(id, outputs, cx);
|
this.insert_reports(id, outputs, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the input field, attachments, and replies
|
/// Clear the input field, attachments, and replies
|
||||||
@@ -409,13 +430,10 @@ impl ChatPanel {
|
|||||||
|
|
||||||
/// Insert reports
|
/// Insert reports
|
||||||
fn insert_reports(&mut self, id: EventId, reports: Vec<SendReport>, cx: &mut Context<Self>) {
|
fn insert_reports(&mut self, id: EventId, reports: Vec<SendReport>, cx: &mut Context<Self>) {
|
||||||
self.reports_by_id
|
self.reports_by_id.update(cx, |this, cx| {
|
||||||
.write()
|
this.entry(id).or_default().extend(reports);
|
||||||
.unwrap()
|
|
||||||
.entry(id)
|
|
||||||
.or_default()
|
|
||||||
.extend(reports);
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a message into the chat panel
|
/// Insert a message into the chat panel
|
||||||
@@ -424,10 +442,9 @@ impl ChatPanel {
|
|||||||
E: Into<Message>,
|
E: Into<Message>,
|
||||||
{
|
{
|
||||||
let old_len = self.messages.len();
|
let old_len = self.messages.len();
|
||||||
let msg: Message = m.into();
|
|
||||||
|
|
||||||
if let Err(pos) = self.messages.binary_search(&msg) {
|
// Extend the messages list with the new events
|
||||||
self.messages.insert(pos, msg);
|
if self.messages.insert(m.into()) {
|
||||||
self.list_state.splice(old_len..old_len, 1);
|
self.list_state.splice(old_len..old_len, 1);
|
||||||
|
|
||||||
if scroll {
|
if scroll {
|
||||||
@@ -450,22 +467,41 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message has any reports
|
/// Check if a message has any reports
|
||||||
fn has_reports(&self, id: &EventId, _cx: &App) -> bool {
|
fn has_reports(&self, id: &EventId, cx: &App) -> bool {
|
||||||
self.reports_by_id.read().unwrap().get(id).is_some()
|
self.reports_by_id.read(cx).get(id).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sent_reports(&self, id: &EventId, _cx: &App) -> Option<Vec<SendReport>> {
|
/// Check if a message was encrypted by the dekey
|
||||||
self.reports_by_id.read().unwrap().get(id).cloned()
|
fn encrypted_by_dekey(&self, id: &EventId, cx: &App) -> bool {
|
||||||
|
let chat = ChatRegistry::global(cx);
|
||||||
|
chat.read(cx).encrypted_by_dekey(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all sent reports for a message by its ID
|
||||||
|
fn sent_reports(&self, id: &EventId, cx: &App) -> Option<Vec<SendReport>> {
|
||||||
|
self.reports_by_id.read(cx).get(id).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a message by its ID
|
/// Get a message by its ID
|
||||||
fn message(&self, id: &EventId) -> Option<&Message> {
|
fn message(&self, id: &EventId) -> Option<&RenderedMessage> {
|
||||||
self.messages.iter().find(|msg| &msg.id == id)
|
self.messages.iter().find_map(|msg| {
|
||||||
|
if let Message::User(rendered) = msg
|
||||||
|
&& &rendered.id == id
|
||||||
|
{
|
||||||
|
return Some(rendered);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scroll to a message by its ID
|
fn scroll_to(&self, id: EventId) {
|
||||||
fn scroll_to(&self, id: &EventId) {
|
if let Some(ix) = self.messages.iter().position(|m| {
|
||||||
if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) {
|
if let Message::User(msg) = m {
|
||||||
|
msg.id == id
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}) {
|
||||||
self.list_state.scroll_to_reveal_item(ix);
|
self.list_state.scroll_to_reveal_item(ix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -585,19 +621,13 @@ impl ChatPanel {
|
|||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
window.push_notification(Notification::error("Failed to change subject"), cx);
|
window.push_notification(
|
||||||
|
Notification::error("Failed to change subject").autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::ChangeSigner(kind) => {
|
Command::ChangeSigner(kind) => {
|
||||||
let settings = AppSettings::global(cx);
|
|
||||||
let is_nip4e_enabled = settings.read(cx).is_nip4e_enabled(cx);
|
|
||||||
let is_force_nip4e = *kind == SignerKind::Encryption || *kind == SignerKind::Auto;
|
|
||||||
|
|
||||||
if !is_nip4e_enabled && is_force_nip4e {
|
|
||||||
window.push_notification("Decoupling Encryption Key is not enabled", cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self
|
if self
|
||||||
.room
|
.room
|
||||||
.update(cx, |this, cx| {
|
.update(cx, |this, cx| {
|
||||||
@@ -605,7 +635,10 @@ impl ChatPanel {
|
|||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
window.push_notification(Notification::error("Failed to change signer"), cx);
|
window.push_notification(
|
||||||
|
Notification::error("Failed to change signer").autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::ToggleBackup => {
|
Command::ToggleBackup => {
|
||||||
@@ -616,7 +649,10 @@ impl ChatPanel {
|
|||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
window.push_notification(Notification::error("Failed to toggle backup"), cx);
|
window.push_notification(
|
||||||
|
Notification::error("Failed to toggle backup").autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::Copy(public_key) => {
|
Command::Copy(public_key) => {
|
||||||
@@ -713,11 +749,9 @@ impl ChatPanel {
|
|||||||
cx.open_url(&content);
|
cx.open_url(&content);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_announcement(&self, cx: &Context<Self>) -> AnyElement {
|
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()
|
v_flex()
|
||||||
|
.id(ix)
|
||||||
.h_40()
|
.h_40()
|
||||||
.w_full()
|
.w_full()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
@@ -734,7 +768,7 @@ impl ChatPanel {
|
|||||||
.size_12()
|
.size_12()
|
||||||
.text_color(cx.theme().ghost_element_active),
|
.text_color(cx.theme().ghost_element_active),
|
||||||
)
|
)
|
||||||
.child(MSG)
|
.child(SharedString::from(ANNOUNCEMENT))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,50 +805,31 @@ impl ChatPanel {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_group_start(&self, ix: usize) -> bool {
|
|
||||||
// 5 minutes
|
|
||||||
const GROUP_WINDOW: u64 = 300;
|
|
||||||
|
|
||||||
if ix == 0 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(previous) = self.messages.get(ix - 1)
|
|
||||||
&& let Some(current) = self.messages.get(ix)
|
|
||||||
{
|
|
||||||
if current.author != previous.author {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let gap = current
|
|
||||||
.created_at
|
|
||||||
.as_secs()
|
|
||||||
.saturating_sub(previous.created_at.as_secs());
|
|
||||||
|
|
||||||
return gap > GROUP_WINDOW;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_message(
|
fn render_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
ix: usize,
|
ix: usize,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
if let Some(message) = self.messages.get(ix) {
|
if let Some(message) = self.messages.iter().nth(ix) {
|
||||||
|
match message {
|
||||||
|
Message::User(rendered) => {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let show_author = self.is_group_start(ix);
|
|
||||||
let text = self
|
let text = self
|
||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(message.id)
|
.entry(rendered.id)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
RenderedText::new(&message.content, &message.mentions, &persons, cx)
|
RenderedText::new(&rendered.content, &rendered.mentions, &persons, cx)
|
||||||
})
|
})
|
||||||
.element(ix.into(), window, cx);
|
.element(ix.into(), window, cx);
|
||||||
|
|
||||||
self.render_text_message(ix, message, text, show_author, cx)
|
self.render_text_message(ix, rendered, text, cx)
|
||||||
|
}
|
||||||
|
Message::Warning(content, _timestamp) => {
|
||||||
|
self.render_warning(ix, SharedString::from(content), cx)
|
||||||
|
}
|
||||||
|
Message::System(_timestamp) => self.render_announcement(ix, cx),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.render_warning(ix, SharedString::from("Message not found"), cx)
|
self.render_warning(ix, SharedString::from("Message not found"), cx)
|
||||||
}
|
}
|
||||||
@@ -823,9 +838,8 @@ impl ChatPanel {
|
|||||||
fn render_text_message(
|
fn render_text_message(
|
||||||
&self,
|
&self,
|
||||||
ix: usize,
|
ix: usize,
|
||||||
message: &Message,
|
message: &RenderedMessage,
|
||||||
rendered_text: AnyElement,
|
rendered_text: AnyElement,
|
||||||
show_author: bool,
|
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let id = message.id;
|
let id = message.id;
|
||||||
@@ -835,6 +849,7 @@ impl ChatPanel {
|
|||||||
let replies = message.replies_to.as_slice();
|
let replies = message.replies_to.as_slice();
|
||||||
let has_replies = !replies.is_empty();
|
let has_replies = !replies.is_empty();
|
||||||
let has_reports = self.has_reports(&id, cx);
|
let has_reports = self.has_reports(&id, cx);
|
||||||
|
let encrypted_by_dekey = self.encrypted_by_dekey(&id, cx);
|
||||||
|
|
||||||
// Hide avatar setting
|
// Hide avatar setting
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
@@ -851,7 +866,6 @@ impl ChatPanel {
|
|||||||
.flex()
|
.flex()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.when(!hide_avatar, |this| {
|
.when(!hide_avatar, |this| {
|
||||||
if show_author {
|
|
||||||
this.child(
|
this.child(
|
||||||
Avatar::new(author.avatar())
|
Avatar::new(author.avatar())
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
@@ -863,9 +877,6 @@ impl ChatPanel {
|
|||||||
.menu("View on njump.me", Box::new(Command::Njump(pk)))
|
.menu("View on njump.me", Box::new(Command::Njump(pk)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
this.child(div().flex_shrink_0().w(px(32.)))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -873,8 +884,7 @@ impl ChatPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.flex_initial()
|
.flex_initial()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.when(show_author, |this| {
|
.child(
|
||||||
this.child(
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
@@ -885,17 +895,26 @@ impl ChatPanel {
|
|||||||
.text_color(cx.theme().text)
|
.text_color(cx.theme().text)
|
||||||
.child(author.name()),
|
.child(author.name()),
|
||||||
)
|
)
|
||||||
.child(message.created_at.to_human_time())
|
.when(encrypted_by_dekey, |this| {
|
||||||
.when(has_reports, |this| {
|
this.child(
|
||||||
this.child(self.render_sent_reports(&id, cx))
|
Button::new(format!("dekey-{ix}"))
|
||||||
}),
|
.icon(IconName::Shield)
|
||||||
|
.ghost()
|
||||||
|
.xsmall()
|
||||||
|
.px_4()
|
||||||
|
.tooltip("Encrypted by Dekey")
|
||||||
|
.disabled(true),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.child(message.created_at.to_human_time())
|
||||||
|
.when(has_reports, |this| {
|
||||||
|
this.child(deferred(self.render_sent_reports(&id, cx)))
|
||||||
|
}),
|
||||||
|
)
|
||||||
.when(has_replies, |this| {
|
.when(has_replies, |this| {
|
||||||
this.children(self.render_message_replies(replies, cx))
|
this.children(self.render_message_replies(replies, cx))
|
||||||
})
|
})
|
||||||
.child(rendered_text)
|
.child(rendered_text),
|
||||||
.child(self.render_media(&message.media, cx)),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -922,55 +941,6 @@ impl ChatPanel {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_media(&self, media: &[SharedUri], cx: &Context<Self>) -> impl IntoElement {
|
|
||||||
// No media: return empty div
|
|
||||||
if media.is_empty() {
|
|
||||||
return div();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Single media item: render full-width image
|
|
||||||
if media.len() == 1 {
|
|
||||||
return div().child(
|
|
||||||
img(media[0].clone())
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border_variant)
|
|
||||||
.h(px(250.))
|
|
||||||
.object_fit(ObjectFit::Cover)
|
|
||||||
.rounded(cx.theme().radius),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple media items: render in a row
|
|
||||||
div()
|
|
||||||
.w_full()
|
|
||||||
.flex_1()
|
|
||||||
.flex()
|
|
||||||
.flex_row()
|
|
||||||
.flex_wrap()
|
|
||||||
.gap_2()
|
|
||||||
.children({
|
|
||||||
let mut items = vec![];
|
|
||||||
|
|
||||||
for (ix, item) in media.iter().enumerate() {
|
|
||||||
items.push(
|
|
||||||
div()
|
|
||||||
.id(format!("media-{ix}"))
|
|
||||||
.flex_grow_0()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.child(
|
|
||||||
img(item.clone())
|
|
||||||
.h_32()
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border_variant)
|
|
||||||
.rounded(cx.theme().radius),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
items
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_message_replies(
|
fn render_message_replies(
|
||||||
&self,
|
&self,
|
||||||
replies: &[EventId],
|
replies: &[EventId],
|
||||||
@@ -1008,7 +978,7 @@ impl ChatPanel {
|
|||||||
.on_click({
|
.on_click({
|
||||||
let id = *id;
|
let id = *id;
|
||||||
cx.listener(move |this, _event, _window, _cx| {
|
cx.listener(move |this, _event, _window, _cx| {
|
||||||
this.scroll_to(&id);
|
this.scroll_to(id);
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1157,7 +1127,7 @@ impl ChatPanel {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.line_height(relative(1.25))
|
.line_height(relative(1.25))
|
||||||
.child(SharedString::from(url.0.to_string())),
|
.child(SharedString::from(url.to_string())),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -1401,7 +1371,7 @@ impl ChatPanel {
|
|||||||
.icon(IconName::Emoji)
|
.icon(IconName::Emoji)
|
||||||
.ghost()
|
.ghost()
|
||||||
.large()
|
.large()
|
||||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
.dropdown_menu_with_anchor(gpui::Corner::BottomLeft, move |this, _window, _cx| {
|
||||||
this.horizontal()
|
this.horizontal()
|
||||||
.menu("👍", Box::new(Command::Insert("👍")))
|
.menu("👍", Box::new(Command::Insert("👍")))
|
||||||
.menu("👎", Box::new(Command::Insert("👎")))
|
.menu("👎", Box::new(Command::Insert("👎")))
|
||||||
@@ -1465,7 +1435,6 @@ impl Focusable for ChatPanel {
|
|||||||
impl Render for ChatPanel {
|
impl Render for ChatPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(coop_cache(self.id.clone(), 100))
|
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
.size_full()
|
.size_full()
|
||||||
.when(*self.subject_bar.read(cx), |this| {
|
.when(*self.subject_bar.read(cx), |this| {
|
||||||
@@ -1477,7 +1446,12 @@ impl Render for ChatPanel {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.border_b_1()
|
.border_b_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.child(Input::new(&self.subject_input).text_sm().small())
|
.child(
|
||||||
|
TextInput::new(&self.subject_input)
|
||||||
|
.text_sm()
|
||||||
|
.small()
|
||||||
|
.bordered(false),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("change")
|
Button::new("change")
|
||||||
.icon(IconName::CheckCircle)
|
.icon(IconName::CheckCircle)
|
||||||
@@ -1494,18 +1468,7 @@ impl Render for ChatPanel {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.relative()
|
.relative()
|
||||||
.map(|this| {
|
.child(
|
||||||
if self.messages.is_empty() {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.size_full()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_end()
|
|
||||||
.child(self.render_announcement(cx)),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this.child(
|
|
||||||
list(
|
list(
|
||||||
self.list_state.clone(),
|
self.list_state.clone(),
|
||||||
cx.processor(move |this, ix, window, cx| {
|
cx.processor(move |this, ix, window, cx| {
|
||||||
@@ -1514,8 +1477,6 @@ impl Render for ChatPanel {
|
|||||||
)
|
)
|
||||||
.size_full(),
|
.size_full(),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
})
|
|
||||||
.child(Scrollbar::vertical(&self.list_state)),
|
.child(Scrollbar::vertical(&self.list_state)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -1541,7 +1502,12 @@ impl Render for ChatPanel {
|
|||||||
this.upload(window, cx);
|
this.upload(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(Input::new(&self.input).appearance(false).flex_1())
|
.child(
|
||||||
|
TextInput::new(&self.input)
|
||||||
|
.appearance(false)
|
||||||
|
.text_sm()
|
||||||
|
.flex_1(),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.pl_1()
|
.pl_1()
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ impl RenderedText {
|
|||||||
|
|
||||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||||
let code_background = cx.theme().elevated_surface_background;
|
let code_background = cx.theme().elevated_surface_background;
|
||||||
let color = cx.theme().text_accent;
|
|
||||||
|
|
||||||
InteractiveText::new(
|
InteractiveText::new(
|
||||||
id,
|
id,
|
||||||
@@ -101,7 +100,6 @@ impl RenderedText {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Highlight::Mention => HighlightStyle {
|
Highlight::Mention => HighlightStyle {
|
||||||
color: Some(color),
|
|
||||||
underline: Some(UnderlineStyle {
|
underline: Some(UnderlineStyle {
|
||||||
thickness: 1.0.into(),
|
thickness: 1.0.into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
instant.workspace = true
|
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
qrcode = "0.14.1"
|
qrcode = "0.14.1"
|
||||||
regex = "1.10"
|
bech32 = "0.11.1"
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
|
||||||
use std::mem::take;
|
|
||||||
|
|
||||||
use futures::FutureExt;
|
|
||||||
use gpui::{
|
|
||||||
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
|
||||||
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
|
|
||||||
CoopImageCacheProvider {
|
|
||||||
id: id.into(),
|
|
||||||
max_items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CoopImageCacheProvider {
|
|
||||||
id: ElementId,
|
|
||||||
max_items: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageCacheProvider for CoopImageCacheProvider {
|
|
||||||
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
|
||||||
window
|
|
||||||
.with_global_id(self.id.clone(), |id, window| {
|
|
||||||
window.with_element_state(id, |cache, _| {
|
|
||||||
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
|
|
||||||
(cache.clone(), cache)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CoopImageCache {
|
|
||||||
max_items: usize,
|
|
||||||
usage_list: VecDeque<u64>,
|
|
||||||
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CoopImageCache {
|
|
||||||
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
|
||||||
cx.new(|cx| {
|
|
||||||
log::info!("Creating CoopImageCache");
|
|
||||||
cx.on_release(|this: &mut Self, cx| {
|
|
||||||
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
|
||||||
if let Some(Ok(image)) = image.get() {
|
|
||||||
log::info!("Dropping image {ix}");
|
|
||||||
cx.drop_image(image, None);
|
|
||||||
}
|
|
||||||
ImageSource::Resource(resource).remove_asset(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
CoopImageCache {
|
|
||||||
max_items,
|
|
||||||
usage_list: VecDeque::with_capacity(max_items),
|
|
||||||
cache: HashMap::with_capacity(max_items),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageCache for CoopImageCache {
|
|
||||||
fn load(
|
|
||||||
&mut self,
|
|
||||||
resource: &Resource,
|
|
||||||
window: &mut gpui::Window,
|
|
||||||
cx: &mut gpui::App,
|
|
||||||
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
|
||||||
let hash = hash(resource);
|
|
||||||
|
|
||||||
if let Some(item) = self.cache.get_mut(&hash) {
|
|
||||||
let current_idx = self
|
|
||||||
.usage_list
|
|
||||||
.iter()
|
|
||||||
.position(|item| *item == hash)
|
|
||||||
.expect("cache has an item usage_list doesn't");
|
|
||||||
|
|
||||||
self.usage_list.remove(current_idx);
|
|
||||||
self.usage_list.push_front(hash);
|
|
||||||
|
|
||||||
return item.0.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
|
||||||
let task = cx.background_executor().spawn(load_future).shared();
|
|
||||||
|
|
||||||
if self.usage_list.len() >= self.max_items {
|
|
||||||
log::info!("Image cache is full, evicting oldest item");
|
|
||||||
|
|
||||||
if let Some(oldest) = self.usage_list.pop_back() {
|
|
||||||
let mut image = self
|
|
||||||
.cache
|
|
||||||
.remove(&oldest)
|
|
||||||
.expect("usage_list has an item cache doesn't");
|
|
||||||
|
|
||||||
if let Some(Ok(image)) = image.0.get() {
|
|
||||||
log::info!("requesting image to be dropped");
|
|
||||||
cx.drop_image(image, Some(window));
|
|
||||||
}
|
|
||||||
|
|
||||||
ImageSource::Resource(image.1).remove_asset(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.cache.insert(
|
|
||||||
hash,
|
|
||||||
(
|
|
||||||
gpui::ImageCacheItem::Loading(task.clone()),
|
|
||||||
resource.clone(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
self.usage_list.push_front(hash);
|
|
||||||
|
|
||||||
let entity = window.current_view();
|
|
||||||
|
|
||||||
window
|
|
||||||
.spawn(cx, async move |cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
if let Err(err) = result {
|
|
||||||
log::error!("error loading image into cache: {:?}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.on_next_frame(move |_, cx| {
|
|
||||||
cx.notify(entity);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
use instant::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures::channel::oneshot;
|
use futures::channel::oneshot;
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ impl EventExt for Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
|
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||||
public_keys.push(self.pubkey);
|
public_keys.push(self.pubkey);
|
||||||
|
|
||||||
public_keys.into_iter().unique().collect()
|
public_keys.into_iter().unique().collect()
|
||||||
@@ -46,7 +46,7 @@ impl EventExt for UnsignedEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
|
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||||
public_keys.push(self.pubkey);
|
public_keys.push(self.pubkey);
|
||||||
public_keys.into_iter().unique().sorted().collect()
|
public_keys.into_iter().unique().sorted().collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
pub use caching::*;
|
|
||||||
pub use debounced_delay::*;
|
pub use debounced_delay::*;
|
||||||
pub use display::*;
|
pub use display::*;
|
||||||
pub use event::*;
|
pub use event::*;
|
||||||
pub use media_extractor::*;
|
|
||||||
pub use parser::*;
|
pub use parser::*;
|
||||||
pub use paths::*;
|
pub use paths::*;
|
||||||
pub use range::*;
|
pub use range::*;
|
||||||
|
|
||||||
mod caching;
|
|
||||||
mod debounced_delay;
|
mod debounced_delay;
|
||||||
mod display;
|
mod display;
|
||||||
mod event;
|
mod event;
|
||||||
mod media_extractor;
|
|
||||||
mod parser;
|
mod parser;
|
||||||
mod paths;
|
mod paths;
|
||||||
mod range;
|
mod range;
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
use gpui::SharedUri;
|
|
||||||
use regex::Regex;
|
|
||||||
|
|
||||||
/// Extracts media URLs from a string and returns both the extracted URLs
|
|
||||||
/// and the string with media URLs removed
|
|
||||||
pub struct MediaExtractor {
|
|
||||||
image_regex: Regex,
|
|
||||||
video_regex: Regex,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MediaExtractor {
|
|
||||||
/// Creates a new MediaExtractor with compiled regex patterns
|
|
||||||
pub fn new() -> Self {
|
|
||||||
MediaExtractor {
|
|
||||||
// Match common image extensions
|
|
||||||
image_regex: Regex::new(
|
|
||||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
|
|
||||||
).unwrap(),
|
|
||||||
// Match common video extensions
|
|
||||||
video_regex: Regex::new(
|
|
||||||
r#"(?i)\bhttps?://[^\s<>"']+\.(?:mp4|mov|avi|mkv|webm|flv|wmv|m4v|3gp)(?:\?[^\s<>"']*)?\b"#,
|
|
||||||
).unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts all media URLs from a string
|
|
||||||
pub fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
|
||||||
let mut urls = Vec::new();
|
|
||||||
|
|
||||||
// Extract image URLs
|
|
||||||
for capture in self.image_regex.find_iter(text) {
|
|
||||||
urls.push(capture.as_str().to_string().into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract video URLs
|
|
||||||
// for capture in self.video_regex.find_iter(text) {
|
|
||||||
// urls.push(capture.as_str().to_string().into());
|
|
||||||
// }
|
|
||||||
|
|
||||||
urls
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes all media URLs from a string and returns the cleaned text
|
|
||||||
pub fn remove_media_urls(&self, text: &str) -> String {
|
|
||||||
let mut result = text.to_string();
|
|
||||||
|
|
||||||
// Remove image URLs
|
|
||||||
result = self.image_regex.replace_all(&result, "").to_string();
|
|
||||||
|
|
||||||
// Remove video URLs
|
|
||||||
// result = self.video_regex.replace_all(&result, "").to_string();
|
|
||||||
|
|
||||||
// Clean up extra whitespace that might result from removal
|
|
||||||
self.cleanup_text(&result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts media URLs and removes them from the string, returning both
|
|
||||||
pub fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
|
||||||
let urls = self.extract_media_urls(text);
|
|
||||||
let cleaned_text = self.remove_media_urls(text);
|
|
||||||
(urls, cleaned_text)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper function to clean up text after URL removal
|
|
||||||
fn cleanup_text(&self, text: &str) -> String {
|
|
||||||
let text = text.trim();
|
|
||||||
|
|
||||||
// Remove multiple consecutive spaces
|
|
||||||
let re = Regex::new(r"\s+").unwrap();
|
|
||||||
re.replace_all(text, " ").trim().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates if a URL is a valid media URL
|
|
||||||
pub fn is_media_url(&self, url: &str) -> bool {
|
|
||||||
self.image_regex.is_match(url) || self.video_regex.is_match(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Categorizes extracted URLs into images and videos
|
|
||||||
pub fn categorize_urls(&self, urls: &[SharedUri]) -> (Vec<SharedUri>, Vec<SharedUri>) {
|
|
||||||
let mut images = Vec::new();
|
|
||||||
let mut videos = Vec::new();
|
|
||||||
|
|
||||||
for url in urls {
|
|
||||||
if self.image_regex.is_match(url) {
|
|
||||||
images.push(url.clone());
|
|
||||||
} else if self.video_regex.is_match(url) {
|
|
||||||
videos.push(url.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(images, videos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MediaExtractor {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience function for one-time extraction and removal
|
|
||||||
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
|
|
||||||
let extractor = MediaExtractor::new();
|
|
||||||
extractor.extract_and_remove(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience function for just extracting media URLs
|
|
||||||
pub fn extract_media_urls(text: &str) -> Vec<SharedUri> {
|
|
||||||
let extractor = MediaExtractor::new();
|
|
||||||
extractor.extract_media_urls(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience function for just removing media URLs
|
|
||||||
pub fn remove_media_urls(text: &str) -> String {
|
|
||||||
let extractor = MediaExtractor::new();
|
|
||||||
extractor.remove_media_urls(text)
|
|
||||||
}
|
|
||||||
71
crates/coop/Cargo.toml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
[package]
|
||||||
|
name = "coop"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "coop"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[package.metadata.packager]
|
||||||
|
name = "Coop"
|
||||||
|
product-name = "Coop"
|
||||||
|
description = "Chat Freely, Stay Private on Nostr"
|
||||||
|
identifier = "su.reya.coop"
|
||||||
|
category = "SocialNetworking"
|
||||||
|
version = "1.0.0-beta2"
|
||||||
|
out-dir = "../../dist"
|
||||||
|
before-packaging-command = "cargo build --release"
|
||||||
|
resources = ["Cargo.toml", "src"]
|
||||||
|
icons = [
|
||||||
|
"resources/32x32.png",
|
||||||
|
"resources/128x128.png",
|
||||||
|
"resources/128x128@2x.png",
|
||||||
|
"resources/icon.icns",
|
||||||
|
"resources/icon.ico",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
assets = { path = "../assets" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
title_bar = { path = "../title_bar" }
|
||||||
|
theme = { path = "../theme" }
|
||||||
|
common = { path = "../common" }
|
||||||
|
state = { path = "../state" }
|
||||||
|
device = { path = "../device" }
|
||||||
|
chat = { path = "../chat" }
|
||||||
|
chat_ui = { path = "../chat_ui" }
|
||||||
|
settings = { path = "../settings" }
|
||||||
|
auto_update = { path = "../auto_update" }
|
||||||
|
person = { path = "../person" }
|
||||||
|
relay_auth = { path = "../relay_auth" }
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
gpui_platform.workspace = true
|
||||||
|
gpui_linux.workspace = true
|
||||||
|
gpui_windows.workspace = true
|
||||||
|
gpui_macos.workspace = true
|
||||||
|
gpui_tokio.workspace = true
|
||||||
|
reqwest_client.workspace = true
|
||||||
|
|
||||||
|
nostr-connect.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
oneshot.workspace = true
|
||||||
|
webbrowser.workspace = true
|
||||||
|
|
||||||
|
indexset = "0.12.3"
|
||||||
|
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"
|
||||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -50,7 +50,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "dir",
|
"type": "dir",
|
||||||
"path": "./desktop/resources"
|
"path": "./crates/coop/resources"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
257
crates/coop/src/dialogs/accounts.rs
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
use anyhow::Error;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render,
|
||||||
|
SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, div, px,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use person::PersonRegistry;
|
||||||
|
use state::{NostrRegistry, StateEvent};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::avatar::Avatar;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::indicator::Indicator;
|
||||||
|
use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
StateEvent::SignerSet => {
|
||||||
|
window.close_all_modals(cx);
|
||||||
|
window.refresh();
|
||||||
|
}
|
||||||
|
StateEvent::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_secret(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_secret(&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().text_danger)
|
||||||
|
.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);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
115
crates/coop/src/dialogs/connect.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use common::StringExt;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
AppContext, Context, Entity, Image, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
|
Subscription, Window, div, img, px,
|
||||||
|
};
|
||||||
|
use nostr_connect::prelude::*;
|
||||||
|
use state::{
|
||||||
|
CLIENT_NAME, CoopAuthUrlHandler, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT, NostrRegistry,
|
||||||
|
StateEvent,
|
||||||
|
};
|
||||||
|
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).keys();
|
||||||
|
|
||||||
|
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 StateEvent::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().text_danger)
|
||||||
|
.child(error.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(MSG)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
Subscription, Task, Window, div,
|
Subscription, Task, Window, div,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use state::{CoopAuthUrlHandler, NostrRegistry, StateEvent};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
|
use ui::{Disableable, v_flex};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ImportIdentity {
|
pub struct ImportKey {
|
||||||
/// Secret key input
|
/// Secret key input
|
||||||
key_input: Entity<InputState>,
|
key_input: Entity<InputState>,
|
||||||
|
|
||||||
@@ -23,60 +25,75 @@ pub struct ImportIdentity {
|
|||||||
/// Error message
|
/// Error message
|
||||||
error: Entity<Option<SharedString>>,
|
error: Entity<Option<SharedString>>,
|
||||||
|
|
||||||
|
/// Countdown timer for nostr connect
|
||||||
|
countdown: Entity<Option<u64>>,
|
||||||
|
|
||||||
/// Whether the user is currently loading
|
/// Whether the user is currently loading
|
||||||
loading: bool,
|
loading: bool,
|
||||||
|
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
/// Input subscription
|
/// Event subscriptions
|
||||||
_subscription: Option<Subscription>,
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImportIdentity {
|
impl ImportKey {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("nsec or bunker://"));
|
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 pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
let error = cx.new(|_| None);
|
let error = cx.new(|_| None);
|
||||||
|
let countdown = cx.new(|_| None);
|
||||||
|
|
||||||
let input_subscription =
|
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| {
|
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
this.login(window, cx);
|
this.login(window, cx);
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Subscribe to the nostr signer event
|
||||||
|
cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
|
||||||
|
if let StateEvent::Error(e) = event {
|
||||||
|
this.set_error(e, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
key_input,
|
key_input,
|
||||||
pass_input,
|
pass_input,
|
||||||
error,
|
error,
|
||||||
|
countdown,
|
||||||
loading: false,
|
loading: false,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscription: Some(input_subscription),
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn login(&mut self, 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 value = self.key_input.read(cx).value();
|
||||||
let password = self.pass_input.read(cx).value();
|
let password = self.pass_input.read(cx).value();
|
||||||
|
|
||||||
// Set loading state
|
if value.starts_with("bunker://") {
|
||||||
self.set_loading(true, cx);
|
self.bunker(&value, window, cx);
|
||||||
|
|
||||||
if value.starts_with("ncryptsec1") {
|
|
||||||
self.ncryptsec(value, password, window, cx);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if value.starts_with("bunker://") {
|
if value.starts_with("ncryptsec1") {
|
||||||
match NostrConnectUri::parse(value) {
|
self.ncryptsec(value, password, window, cx);
|
||||||
Ok(uri) => {
|
|
||||||
self.bunker(uri, window, cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.set_error(e.to_string(), cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,13 +103,52 @@ impl ImportIdentity {
|
|||||||
|
|
||||||
// Update the signer
|
// Update the signer
|
||||||
nostr.update(cx, |this, cx| {
|
nostr.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, cx);
|
this.add_key_signer(&keys, cx);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
self.set_error("Invalid key", cx);
|
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).keys();
|
||||||
|
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>)
|
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
S: Into<String>,
|
S: Into<String>,
|
||||||
@@ -123,10 +179,9 @@ impl ImportIdentity {
|
|||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(keys) => {
|
Ok(keys) => {
|
||||||
nostr.update_in(cx, |this, window, cx| {
|
nostr.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, cx);
|
this.add_key_signer(&keys, cx);
|
||||||
window.close_modal(cx);
|
});
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -134,47 +189,21 @@ impl ImportIdentity {
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bunker(&mut self, uri: NostrConnectUri, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let master_keys = nostr.read(cx).get_master_key(cx);
|
|
||||||
let password = uri.to_string();
|
|
||||||
let save = cx.write_credentials(USER_KEYRING, "bunker", password.as_bytes());
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
let keys = master_keys.await;
|
|
||||||
let timeout = Duration::from_secs(30);
|
|
||||||
|
|
||||||
// Construct the nostr connect signer
|
|
||||||
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(signer, cx);
|
|
||||||
cx.background_spawn(async move { save.await.ok() }).detach();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.loading = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
S: Into<SharedString>,
|
S: Into<SharedString>,
|
||||||
{
|
{
|
||||||
|
// Reset the log in state
|
||||||
self.set_loading(false, cx);
|
self.set_loading(false, cx);
|
||||||
|
|
||||||
|
// Reset the countdown
|
||||||
|
self.set_countdown(None, cx);
|
||||||
|
|
||||||
// Update error message
|
// Update error message
|
||||||
self.error.update(cx, |this, cx| {
|
self.error.update(cx, |this, cx| {
|
||||||
*this = Some(message.into());
|
*this = Some(message.into());
|
||||||
@@ -195,59 +224,69 @@ impl ImportIdentity {
|
|||||||
Ok(())
|
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 ImportIdentity {
|
impl Render for ImportKey {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
const MSG: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
|
|
||||||
|
|
||||||
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
|
|
||||||
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
|
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.gap_4()
|
.gap_2()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child("Continue with existing key or bunker connection")
|
.child("nsec or bunker://")
|
||||||
.child(Input::new(&self.key_input)),
|
.child(TextInput::new(&self.key_input)),
|
||||||
)
|
)
|
||||||
.when(require_password, |this| {
|
.when(
|
||||||
|
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|
||||||
|
|this| {
|
||||||
this.child(
|
this.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child("Decrypt Password:")
|
.child("Password:")
|
||||||
.child(Input::new(&self.pass_input)),
|
.child(TextInput::new(&self.pass_input)),
|
||||||
)
|
)
|
||||||
})
|
},
|
||||||
.when(key_warning, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_warning)
|
|
||||||
.child(div().font_semibold().child("Warning"))
|
|
||||||
.child(div().child(MSG)),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("login")
|
Button::new("login")
|
||||||
.label("Continue")
|
.label("Continue")
|
||||||
.primary()
|
.primary()
|
||||||
.font_semibold()
|
|
||||||
.loading(self.loading)
|
.loading(self.loading)
|
||||||
.disabled(self.loading)
|
.disabled(self.loading)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
this.login(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| {
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
pub mod accounts;
|
||||||
|
pub mod connect;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod restore;
|
pub mod restore;
|
||||||
pub mod screening;
|
pub mod screening;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use instant::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use device::DeviceRegistry;
|
use device::DeviceRegistry;
|
||||||
@@ -7,10 +7,10 @@ use gpui::{
|
|||||||
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
Subscription, Task, Window, div,
|
Subscription, Task, Window, div,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::{WindowExtension, v_flex};
|
use ui::{WindowExtension, v_flex};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -107,7 +107,7 @@ impl Render for RestoreEncryption {
|
|||||||
.text_sm()
|
.text_sm()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child("Secret Key")
|
.child("Secret Key")
|
||||||
.child(Input::new(&self.key_input)),
|
.child(TextInput::new(&self.key_input)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("restore")
|
Button::new("restore")
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use common::TimestampExt;
|
use common::TimestampExt;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
|
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
|
||||||
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
|
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry, shorten_pubkey};
|
use person::{Person, PersonRegistry, shorten_pubkey};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -55,7 +55,7 @@ impl Screening {
|
|||||||
window.close_all_modals(cx);
|
window.close_all_modals(cx);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
cx.defer_in(window, |this, _window, cx| {
|
cx.defer_in(window, move |this, _window, cx| {
|
||||||
this.check_contact(cx);
|
this.check_contact(cx);
|
||||||
this.check_wot(cx);
|
this.check_wot(cx);
|
||||||
this.check_last_activity(cx);
|
this.check_last_activity(cx);
|
||||||
@@ -78,13 +78,12 @@ impl Screening {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let public_key = self.public_key;
|
let public_key = self.public_key;
|
||||||
|
|
||||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let signer_pubkey = signer.get_public_key().await?;
|
||||||
|
|
||||||
// Check if user is in contact list
|
// Check if user is in contact list
|
||||||
let contacts = client.database().contacts_public_keys(current_user).await;
|
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
|
||||||
let followed = contacts.unwrap_or_default().contains(&public_key);
|
let followed = contacts.unwrap_or_default().contains(&public_key);
|
||||||
|
|
||||||
Ok(followed)
|
Ok(followed)
|
||||||
@@ -106,17 +105,16 @@ impl Screening {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let public_key = self.public_key;
|
let public_key = self.public_key;
|
||||||
|
|
||||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let signer_pubkey = signer.get_public_key().await?;
|
||||||
|
|
||||||
// Check mutual contacts
|
// Check mutual contacts
|
||||||
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
|
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
|
||||||
let mut mutual_contacts = vec![];
|
let mut mutual_contacts = vec![];
|
||||||
|
|
||||||
if let Ok(events) = client.database().query(filter).await {
|
if let Ok(events) = client.database().query(filter).await {
|
||||||
for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
|
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
|
||||||
mutual_contacts.push(event.pubkey);
|
mutual_contacts.push(event.pubkey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,20 +222,12 @@ impl Screening {
|
|||||||
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
let public_key = self.public_key;
|
let public_key = self.public_key;
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let tag = Nip56Tag::PublicKey {
|
let tag = Tag::public_key_report(public_key, Report::Impersonation);
|
||||||
public_key,
|
let builder = EventBuilder::report(vec![tag], "");
|
||||||
report: Report::Impersonation,
|
let event = client.sign_event_builder(builder).await?;
|
||||||
}
|
|
||||||
.to_tag();
|
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::Reporting, "")
|
|
||||||
.tag(tag)
|
|
||||||
.finalize_async(&signer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Send the report to the public relays
|
// Send the report to the public relays
|
||||||
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
|
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
|
||||||
@@ -3,11 +3,11 @@ use gpui::{
|
|||||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
Window, div, px,
|
Window, div, px,
|
||||||
};
|
};
|
||||||
use settings::AppSettings;
|
use settings::{AppSettings, AuthMode};
|
||||||
use theme::{ActiveTheme, Theme, ThemeMode};
|
use theme::{ActiveTheme, Theme, ThemeMode};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::group_box::{GroupBox, GroupBoxVariants};
|
use ui::group_box::{GroupBox, GroupBoxVariants};
|
||||||
use ui::input::{Input, InputState};
|
use ui::input::{InputState, TextInput};
|
||||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::switch::Switch;
|
use ui::switch::Switch;
|
||||||
@@ -56,15 +56,18 @@ impl Preferences {
|
|||||||
|
|
||||||
impl Render for Preferences {
|
impl Render for Preferences {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
|
const SCREENING: &str =
|
||||||
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
"When opening a request, a popup will appear to help you identify the sender.";
|
||||||
const MODE: &str = "Use the selected light or dark theme, or to follow the OS.";
|
const AVATAR: &str =
|
||||||
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
"Hide all avatar pictures to improve performance and protect your privacy.";
|
||||||
|
const MODE: &str =
|
||||||
|
"Choose whether to use the selected light or dark theme, or to follow the OS.";
|
||||||
|
const AUTH: &str = "Choose the authentication behavior for relays.";
|
||||||
const RESET: &str = "Reset the theme to the default one.";
|
const RESET: &str = "Reset the theme to the default one.";
|
||||||
|
|
||||||
let screening = AppSettings::get_screening(cx);
|
let screening = AppSettings::get_screening(cx);
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
let nip4e = AppSettings::get_nip4e(cx);
|
let auth_mode = AppSettings::get_auth_mode(cx);
|
||||||
let theme_mode = AppSettings::get_theme_mode(cx);
|
let theme_mode = AppSettings::get_theme_mode(cx);
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -91,6 +94,52 @@ impl Render for Preferences {
|
|||||||
.on_click(move |_, _window, cx| {
|
.on_click(move |_, _window, cx| {
|
||||||
AppSettings::update_hide_avatar(!hide_avatar, cx);
|
AppSettings::update_hide_avatar(!hide_avatar, cx);
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_3()
|
||||||
|
.justify_between()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from("Relay authentication")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(AUTH)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("auth")
|
||||||
|
.label(auth_mode.to_string())
|
||||||
|
.ghost_alt()
|
||||||
|
.small()
|
||||||
|
.dropdown_menu(|this, _window, _cx| {
|
||||||
|
this.min_w(px(256.))
|
||||||
|
.item(
|
||||||
|
PopupMenuItem::new("Auto authentication").on_click(
|
||||||
|
|_ev, _window, cx| {
|
||||||
|
AppSettings::update_auth_mode(
|
||||||
|
AuthMode::Auto,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.item(PopupMenuItem::new("Ask every time").on_click(
|
||||||
|
|_ev, _window, cx| {
|
||||||
|
AppSettings::update_auth_mode(
|
||||||
|
AuthMode::Manual,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -158,21 +207,6 @@ impl Render for Preferences {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
|
||||||
GroupBox::new()
|
|
||||||
.id("experiments")
|
|
||||||
.title("Experiments")
|
|
||||||
.fill()
|
|
||||||
.child(
|
|
||||||
Switch::new("nip4e")
|
|
||||||
.label("Decoupling Encryption Key")
|
|
||||||
.description(NIP4E)
|
|
||||||
.checked(nip4e)
|
|
||||||
.on_click(move |_, _window, cx| {
|
|
||||||
AppSettings::update_nip4e(!nip4e, cx);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
GroupBox::new()
|
GroupBox::new()
|
||||||
.id("media")
|
.id("media")
|
||||||
@@ -184,7 +218,7 @@ impl Render for Preferences {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(Input::new(&self.file_input).text_xs().small())
|
.child(TextInput::new(&self.file_input).text_xs().small())
|
||||||
.child(
|
.child(
|
||||||
Button::new("update-file-server")
|
Button::new("update-file-server")
|
||||||
.icon(IconName::Check)
|
.icon(IconName::Check)
|
||||||
@@ -10,6 +10,11 @@ use gpui_platform::application;
|
|||||||
use state::{APP_ID, CLIENT_NAME};
|
use state::{APP_ID, CLIENT_NAME};
|
||||||
use ui::Root;
|
use ui::Root;
|
||||||
|
|
||||||
|
mod dialogs;
|
||||||
|
mod panels;
|
||||||
|
mod sidebar;
|
||||||
|
mod workspace;
|
||||||
|
|
||||||
actions!(coop, [Quit]);
|
actions!(coop, [Quit]);
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -43,7 +48,7 @@ fn main() {
|
|||||||
}]);
|
}]);
|
||||||
|
|
||||||
// Set up the window bounds
|
// Set up the window bounds
|
||||||
let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx);
|
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
|
||||||
|
|
||||||
// Set up the window options
|
// Set up the window options
|
||||||
let opts = WindowOptions {
|
let opts = WindowOptions {
|
||||||
@@ -62,6 +67,13 @@ fn main() {
|
|||||||
|
|
||||||
// Open a window with default options
|
// Open a window with default options
|
||||||
cx.open_window(opts, |window, cx| {
|
cx.open_window(opts, |window, cx| {
|
||||||
|
// Bring the app to the foreground
|
||||||
|
cx.activate(true);
|
||||||
|
|
||||||
|
cx.new(|cx| {
|
||||||
|
// Initialize the tokio runtime
|
||||||
|
gpui_tokio::init(cx);
|
||||||
|
|
||||||
// Initialize components
|
// Initialize components
|
||||||
ui::init(cx);
|
ui::init(cx);
|
||||||
|
|
||||||
@@ -77,6 +89,9 @@ fn main() {
|
|||||||
// Initialize person registry
|
// Initialize person registry
|
||||||
person::init(window, cx);
|
person::init(window, cx);
|
||||||
|
|
||||||
|
// Initialize relay auth registry
|
||||||
|
relay_auth::init(window, cx);
|
||||||
|
|
||||||
// Initialize device signer
|
// Initialize device signer
|
||||||
//
|
//
|
||||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
@@ -88,13 +103,11 @@ fn main() {
|
|||||||
// Initialize auto update
|
// Initialize auto update
|
||||||
auto_update::init(window, cx);
|
auto_update::init(window, cx);
|
||||||
|
|
||||||
// Root view
|
// Root Entity
|
||||||
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
|
Root::new(workspace::init(window, cx).into(), window, cx)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.expect("Failed to open window. Please restart the application.");
|
.expect("Failed to open window. Please restart the application.");
|
||||||
|
|
||||||
// Bring the app to the foreground
|
|
||||||
cx.activate(true);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
|
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::USER_KEYRING;
|
use state::KEYRING;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{Input, InputState};
|
use ui::input::{InputState, TextInput};
|
||||||
use ui::{IconName, Sizable, StyledExt, divider, v_flex};
|
use ui::{IconName, Sizable, StyledExt, divider, v_flex};
|
||||||
|
|
||||||
const MSG: &str = "Store your account keys in a safe location. \
|
const MSG: &str = "Store your account keys in a safe location. \
|
||||||
@@ -39,8 +40,8 @@ pub struct BackupPanel {
|
|||||||
|
|
||||||
impl BackupPanel {
|
impl BackupPanel {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let npub_input = cx.new(|cx| InputState::new(window, cx));
|
let npub_input = cx.new(|cx| InputState::new(window, cx).disabled(true));
|
||||||
let nsec_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
let nsec_input = cx.new(|cx| InputState::new(window, cx).disabled(true).masked(true));
|
||||||
|
|
||||||
// Run at the end of current cycle
|
// Run at the end of current cycle
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
@@ -58,7 +59,7 @@ impl BackupPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let keyring = cx.read_credentials(USER_KEYRING);
|
let keyring = cx.read_credentials(KEYRING);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
if let Some((_, secret)) = keyring.await? {
|
if let Some((_, secret)) = keyring.await? {
|
||||||
@@ -154,7 +155,12 @@ impl Render for BackupPanel {
|
|||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("Public Key:")),
|
.child(SharedString::from("Public Key:")),
|
||||||
)
|
)
|
||||||
.child(Input::new(&self.npub_input).small().disabled(true)),
|
.child(
|
||||||
|
TextInput::new(&self.npub_input)
|
||||||
|
.small()
|
||||||
|
.bordered(false)
|
||||||
|
.disabled(true),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -167,7 +173,12 @@ impl Render for BackupPanel {
|
|||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("Secret Key:")),
|
.child(SharedString::from("Secret Key:")),
|
||||||
)
|
)
|
||||||
.child(Input::new(&self.nsec_input).small().disabled(true)),
|
.child(
|
||||||
|
TextInput::new(&self.nsec_input)
|
||||||
|
.small()
|
||||||
|
.bordered(false)
|
||||||
|
.disabled(true),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("copy")
|
Button::new("copy")
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||||
Task, TextAlign, Window, div, rems,
|
Task, TextAlign, Window, div, rems,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -16,8 +16,7 @@ use theme::ActiveTheme;
|
|||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::scroll::ScrollableElement;
|
|
||||||
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
|
||||||
@@ -83,12 +82,11 @@ impl ContactListPanel {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
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?;
|
let contact_list = client.database().contacts_public_keys(public_key).await?;
|
||||||
|
|
||||||
Ok(contact_list)
|
Ok(contact_list)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,7 +156,6 @@ impl ContactListPanel {
|
|||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
// Get contacts
|
// Get contacts
|
||||||
let contacts: Vec<Contact> = self
|
let contacts: Vec<Contact> = self
|
||||||
@@ -172,9 +169,8 @@ impl ContactListPanel {
|
|||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
// Construct contact list event builder
|
// Construct contact list event builder
|
||||||
let event = ContactListBuilder::new(contacts)
|
let builder = EventBuilder::contact_list(contacts);
|
||||||
.finalize_async(&signer)
|
let event = client.sign_event_builder(builder).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Set contact list
|
// Set contact list
|
||||||
client.send_event(&event).to_nip65().await?;
|
client.send_event(&event).to_nip65().await?;
|
||||||
@@ -221,7 +217,8 @@ impl ContactListPanel {
|
|||||||
.px_2()
|
.px_2()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
.bg(cx.theme().secondary_background)
|
||||||
|
.text_color(cx.theme().secondary_foreground)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -283,12 +280,7 @@ impl Focusable for ContactListPanel {
|
|||||||
|
|
||||||
impl Render for ContactListPanel {
|
impl Render for ContactListPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex().p_3().gap_3().w_full().child(
|
||||||
.p_3()
|
|
||||||
.gap_3()
|
|
||||||
.w_full()
|
|
||||||
.overflow_y_scrollbar()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
@@ -299,7 +291,7 @@ impl Render for ContactListPanel {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child("New contact:"),
|
.child(SharedString::from("New contact:")),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -308,7 +300,12 @@ impl Render for ContactListPanel {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(Input::new(&self.input).small().cleanable(true))
|
.child(
|
||||||
|
TextInput::new(&self.input)
|
||||||
|
.small()
|
||||||
|
.bordered(false)
|
||||||
|
.cleanable(),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("add")
|
Button::new("add")
|
||||||
.icon(IconName::Plus)
|
.icon(IconName::Plus)
|
||||||
@@ -348,6 +345,7 @@ impl Render for ContactListPanel {
|
|||||||
.icon(IconName::CheckCircle)
|
.icon(IconName::CheckCircle)
|
||||||
.label("Update")
|
.label("Update")
|
||||||
.primary()
|
.primary()
|
||||||
|
.small()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.loading(self.updating)
|
.loading(self.updating)
|
||||||
.disabled(self.updating)
|
.disabled(self.updating)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
use anyhow::Error;
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div, svg,
|
IntoElement, ParentElement, Render, SharedString, Styled, Window, div, svg,
|
||||||
};
|
};
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
@@ -10,7 +9,7 @@ use ui::dock::{DockPlacement, Panel, PanelEvent};
|
|||||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||||
|
|
||||||
use crate::panels::profile;
|
use crate::panels::profile;
|
||||||
use crate::{Command, Workspace};
|
use crate::workspace::Workspace;
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
||||||
cx.new(|cx| GreeterPanel::new(window, cx))
|
cx.new(|cx| GreeterPanel::new(window, cx))
|
||||||
@@ -19,7 +18,6 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
|||||||
pub struct GreeterPanel {
|
pub struct GreeterPanel {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GreeterPanel {
|
impl GreeterPanel {
|
||||||
@@ -27,15 +25,15 @@ impl GreeterPanel {
|
|||||||
Self {
|
Self {
|
||||||
name: "Onboarding".into(),
|
name: "Onboarding".into(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
tasks: vec![],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
if let Some(public_key) = nostr.read(cx).current_user() {
|
if let Some(public_key) = signer.public_key() {
|
||||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
cx.spawn_in(window, async move |_this, cx| {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
Workspace::add_panel(
|
Workspace::add_panel(
|
||||||
profile::init(public_key, window, cx),
|
profile::init(public_key, window, cx),
|
||||||
@@ -45,9 +43,8 @@ impl GreeterPanel {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
|
})
|
||||||
Ok(())
|
.detach();
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,20 +143,17 @@ impl Render for GreeterPanel {
|
|||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.justify_start()
|
.justify_start()
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.add_profile_panel(window, cx)
|
this.add_profile_panel(window, cx)
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("theme")
|
Button::new("invite")
|
||||||
.icon(Icon::new(IconName::Moon))
|
.icon(Icon::new(IconName::Invite))
|
||||||
.label("Change theme")
|
.label("Invite friends")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.justify_start()
|
.justify_start(),
|
||||||
.on_click(cx.listener(move |_, _, _, cx| {
|
|
||||||
cx.dispatch_action(&Command::ToggleTheme);
|
|
||||||
})),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||||
Task, TextAlign, Window, div, rems,
|
Task, TextAlign, Window, div, rems,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex};
|
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex};
|
||||||
|
|
||||||
const MSG: &str = "Messaging Relays are relays that hosted all your messages. \
|
const MSG: &str = "Messaging Relays are relays that hosted all your messages. \
|
||||||
@@ -83,18 +83,17 @@ impl MessagingRelayPanel {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::InboxRelays)
|
.kind(Kind::InboxRelays)
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
Ok(nip17::extract_relay_list(&event).collect())
|
Ok(nip17::extract_owned_relay_list(event).collect())
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Not found."))
|
Err(anyhow!("Not found."))
|
||||||
}
|
}
|
||||||
@@ -171,13 +170,12 @@ impl MessagingRelayPanel {
|
|||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
// Construct event tags
|
// Construct event tags
|
||||||
let tags: Vec<Tag> = self
|
let tags: Vec<Tag> = self
|
||||||
.relays
|
.relays
|
||||||
.iter()
|
.iter()
|
||||||
.map(|relay| Nip17Tag::Relay(relay.to_owned()).to_tag())
|
.map(|relay| Tag::relay(relay.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Set updating state
|
// Set updating state
|
||||||
@@ -185,10 +183,8 @@ impl MessagingRelayPanel {
|
|||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
// Construct nip17 event builder
|
// Construct nip17 event builder
|
||||||
let event = EventBuilder::new(Kind::InboxRelays, "")
|
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||||
.tags(tags)
|
let event = client.sign_event_builder(builder).await?;
|
||||||
.finalize_async(&signer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Set messaging relays
|
// Set messaging relays
|
||||||
client.send_event(&event).to_nip65().await?;
|
client.send_event(&event).to_nip65().await?;
|
||||||
@@ -320,7 +316,12 @@ impl Render for MessagingRelayPanel {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(Input::new(&self.input).small().cleanable(true))
|
.child(
|
||||||
|
TextInput::new(&self.input)
|
||||||
|
.small()
|
||||||
|
.bordered(false)
|
||||||
|
.cleanable(),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("add")
|
Button::new("add")
|
||||||
.icon(IconName::Plus)
|
.icon(IconName::Plus)
|
||||||
@@ -360,6 +361,7 @@ impl Render for MessagingRelayPanel {
|
|||||||
.icon(IconName::CheckCircle)
|
.icon(IconName::CheckCircle)
|
||||||
.label("Update")
|
.label("Update")
|
||||||
.primary()
|
.primary()
|
||||||
|
.small()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.loading(self.updating)
|
.loading(self.updating)
|
||||||
.disabled(self.updating)
|
.disabled(self.updating)
|
||||||
@@ -4,3 +4,4 @@ pub mod greeter;
|
|||||||
pub mod messaging_relays;
|
pub mod messaging_relays;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod relay_list;
|
pub mod relay_list;
|
||||||
|
pub mod trash;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context as AnyhowContext, Error};
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -6,7 +7,6 @@ use gpui::{
|
|||||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||||
Window, div,
|
Window, div,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry, shorten_pubkey};
|
use person::{Person, PersonRegistry, shorten_pubkey};
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
@@ -15,7 +15,7 @@ use theme::ActiveTheme;
|
|||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{Input, InputState};
|
use ui::input::{InputState, TextInput};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ impl ProfilePanel {
|
|||||||
// Use multi-line input for bio
|
// Use multi-line input for bio
|
||||||
let bio_input = cx.new(|cx| {
|
let bio_input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
.multi_line(true)
|
.multi_line()
|
||||||
.auto_grow(3, 8)
|
.auto_grow(3, 8)
|
||||||
.placeholder("A short introduce about you.")
|
.placeholder("A short introduce about you.")
|
||||||
});
|
});
|
||||||
@@ -132,7 +132,7 @@ impl ProfilePanel {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
if status {
|
if status {
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
// Reset the copied state after a delay
|
// Reset the copied state after a delay
|
||||||
@@ -143,9 +143,8 @@ impl ProfilePanel {
|
|||||||
.ok();
|
.ok();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
|
})
|
||||||
Ok(())
|
.detach();
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,12 +207,12 @@ impl ProfilePanel {
|
|||||||
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
|
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
let metadata = metadata.clone();
|
let metadata = metadata.clone();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
// Build and sign the metadata event
|
// Build and sign the metadata event
|
||||||
let event = metadata.finalize_async(&signer).await?;
|
let builder = EventBuilder::metadata(&metadata);
|
||||||
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
// Send event to user's relays
|
// Send event to user's relays
|
||||||
client.send_event(&event).await?;
|
client.send_event(&event).await?;
|
||||||
@@ -353,7 +352,7 @@ impl Render for ProfilePanel {
|
|||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("What should people call you?")),
|
.child(SharedString::from("What should people call you?")),
|
||||||
)
|
)
|
||||||
.child(Input::new(&self.name_input).small()),
|
.child(TextInput::new(&self.name_input).bordered(false).small()),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -364,7 +363,7 @@ impl Render for ProfilePanel {
|
|||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("A short introduction about you:")),
|
.child(SharedString::from("A short introduction about you:")),
|
||||||
)
|
)
|
||||||
.child(Input::new(&self.bio_input).small()),
|
.child(TextInput::new(&self.bio_input).bordered(false).small()),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -375,7 +374,7 @@ impl Render for ProfilePanel {
|
|||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("Website:")),
|
.child(SharedString::from("Website:")),
|
||||||
)
|
)
|
||||||
.child(Input::new(&self.website_input).small()),
|
.child(TextInput::new(&self.website_input).bordered(false).small()),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -419,6 +418,7 @@ impl Render for ProfilePanel {
|
|||||||
.icon(IconName::CheckCircle)
|
.icon(IconName::CheckCircle)
|
||||||
.label("Update")
|
.label("Update")
|
||||||
.primary()
|
.primary()
|
||||||
|
.small()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.loading(self.updating)
|
.loading(self.updating)
|
||||||
.disabled(self.updating)
|
.disabled(self.updating)
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||||
Task, TextAlign, Window, div, px, rems,
|
Task, TextAlign, Window, div, px, rems,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -15,7 +15,7 @@ use state::NostrRegistry;
|
|||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::menu::DropdownMenu;
|
use ui::menu::DropdownMenu;
|
||||||
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex};
|
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex};
|
||||||
|
|
||||||
@@ -100,19 +100,18 @@ impl RelayListPanel {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let task: Task<Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error>> = cx
|
let task: Task<Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error>> = cx
|
||||||
.background_spawn(async move {
|
.background_spawn(async move {
|
||||||
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::RelayList)
|
.kind(Kind::RelayList)
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
Ok(nip65::extract_relay_list(&event).collect())
|
Ok(nip65::extract_owned_relay_list(event).collect())
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Not found."))
|
Err(anyhow!("Not found."))
|
||||||
}
|
}
|
||||||
@@ -207,7 +206,6 @@ impl RelayListPanel {
|
|||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
// Get all relays
|
// Get all relays
|
||||||
let relays = self.relays.clone();
|
let relays = self.relays.clone();
|
||||||
@@ -216,9 +214,8 @@ impl RelayListPanel {
|
|||||||
self.set_updating(true, cx);
|
self.set_updating(true, cx);
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let event = nip65::RelayList::new(relays)
|
let builder = EventBuilder::relay_list(relays);
|
||||||
.finalize_async(&signer)
|
let event = client.sign_event_builder(builder).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Set relay list for current user
|
// Set relay list for current user
|
||||||
client.send_event(&event).await?;
|
client.send_event(&event).await?;
|
||||||
@@ -371,7 +368,12 @@ impl Render for RelayListPanel {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(Input::new(&self.input).small().cleanable(true))
|
.child(
|
||||||
|
TextInput::new(&self.input)
|
||||||
|
.small()
|
||||||
|
.bordered(false)
|
||||||
|
.cleanable(),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("metadata")
|
Button::new("metadata")
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
@@ -429,6 +431,7 @@ impl Render for RelayListPanel {
|
|||||||
.icon(IconName::CheckCircle)
|
.icon(IconName::CheckCircle)
|
||||||
.label("Update")
|
.label("Update")
|
||||||
.primary()
|
.primary()
|
||||||
|
.small()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.loading(self.updating)
|
.loading(self.updating)
|
||||||
.disabled(self.updating)
|
.disabled(self.updating)
|
||||||
152
crates/coop/src/panels/trash.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
use chat::ChatRegistry;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ParentElement, Render,
|
||||||
|
SharedString, Styled, Window, div, list, px, relative,
|
||||||
|
};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::dock::{Panel, PanelEvent};
|
||||||
|
use ui::scroll::Scrollbar;
|
||||||
|
use ui::{Icon, IconName, Sizable, h_flex, v_flex};
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<TrashPanel> {
|
||||||
|
cx.new(|cx| TrashPanel::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TrashPanel {
|
||||||
|
name: SharedString,
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
|
/// List state for messages
|
||||||
|
list_state: ListState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrashPanel {
|
||||||
|
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
||||||
|
let chat = ChatRegistry::global(cx);
|
||||||
|
let count = chat.read(cx).count_trash_messages(cx);
|
||||||
|
let list_state = ListState::new(count, ListAlignment::Bottom, px(1024.));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
name: "Trash".into(),
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
list_state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy(&self, ix: usize, cx: &App) {
|
||||||
|
let chat = ChatRegistry::global(cx);
|
||||||
|
let trashes = chat.read(cx).trashes();
|
||||||
|
|
||||||
|
if let Some(message) = trashes.read(cx).iter().nth(ix) {
|
||||||
|
let item = ClipboardItem::new_string(message.raw_event.to_string());
|
||||||
|
cx.write_to_clipboard(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_list_item(
|
||||||
|
&mut self,
|
||||||
|
ix: usize,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let chat = ChatRegistry::global(cx);
|
||||||
|
let trashes = chat.read(cx).trashes();
|
||||||
|
|
||||||
|
if let Some(message) = trashes.read(cx).iter().nth(ix) {
|
||||||
|
v_flex()
|
||||||
|
.id(ix)
|
||||||
|
.p_2()
|
||||||
|
.w_full()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.p_2()
|
||||||
|
.w_full()
|
||||||
|
.gap_1()
|
||||||
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.bg(cx.theme().surface_background)
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_danger)
|
||||||
|
.child(message.reason.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.h_10()
|
||||||
|
.w_full()
|
||||||
|
.px_2()
|
||||||
|
.justify_between()
|
||||||
|
.bg(cx.theme().elevated_surface_background)
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.truncate()
|
||||||
|
.text_ellipsis()
|
||||||
|
.text_xs()
|
||||||
|
.line_height(relative(1.))
|
||||||
|
.child(message.raw_event.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new(format!("copy-{ix}"))
|
||||||
|
.icon(IconName::Copy)
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
|
this.copy(ix, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
} else {
|
||||||
|
div().id(ix).into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for TrashPanel {
|
||||||
|
fn panel_id(&self) -> SharedString {
|
||||||
|
self.name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self, _cx: &App) -> AnyElement {
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.child(Icon::new(IconName::Warning).small())
|
||||||
|
.child("Errors")
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for TrashPanel {}
|
||||||
|
|
||||||
|
impl Focusable for TrashPanel {
|
||||||
|
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for TrashPanel {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
v_flex().size_full().relative().child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.relative()
|
||||||
|
.child(
|
||||||
|
list(
|
||||||
|
self.list_state.clone(),
|
||||||
|
cx.processor(move |this, ix, window, cx| {
|
||||||
|
this.render_list_item(ix, window, cx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.size_full(),
|
||||||
|
)
|
||||||
|
.child(Scrollbar::vertical(&self.list_state)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,26 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||||
use common::{DebouncedDelay, TimestampExt, coop_cache};
|
use common::{DebouncedDelay, TimestampExt};
|
||||||
use entry::RoomEntry;
|
use entry::RoomEntry;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription, Task,
|
||||||
Window, div, uniform_list,
|
UniformListScrollHandle, Window, div, uniform_list,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry};
|
use state::{FIND_DELAY, NostrRegistry};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::indicator::Indicator;
|
use ui::indicator::Indicator;
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
@@ -29,11 +29,19 @@ mod entry;
|
|||||||
|
|
||||||
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
||||||
|
cx.new(|cx| Sidebar::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
/// Sidebar.
|
/// Sidebar.
|
||||||
pub struct Sidebar {
|
pub struct Sidebar {
|
||||||
|
name: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
scroll_handle: UniformListScrollHandle,
|
scroll_handle: UniformListScrollHandle,
|
||||||
|
|
||||||
|
/// Image cache
|
||||||
|
image_cache: Entity<RetainAllImageCache>,
|
||||||
|
|
||||||
/// Find input state
|
/// Find input state
|
||||||
find_input: Entity<InputState>,
|
find_input: Entity<InputState>,
|
||||||
|
|
||||||
@@ -75,7 +83,7 @@ pub struct Sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Sidebar {
|
impl Sidebar {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let filter = cx.new(|_| RoomKind::Ongoing);
|
let filter = cx.new(|_| RoomKind::Ongoing);
|
||||||
let contact_list = cx.new(|_| None);
|
let contact_list = cx.new(|_| None);
|
||||||
@@ -130,8 +138,10 @@ impl Sidebar {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
name: "Sidebar".into(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
scroll_handle: UniformListScrollHandle::new(),
|
scroll_handle: UniformListScrollHandle::new(),
|
||||||
|
image_cache: RetainAllImageCache::new(cx),
|
||||||
find_input,
|
find_input,
|
||||||
find_debouncer: DebouncedDelay::new(),
|
find_debouncer: DebouncedDelay::new(),
|
||||||
find_results,
|
find_results,
|
||||||
@@ -153,12 +163,11 @@ impl Sidebar {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
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 contacts = client.database().contacts_public_keys(public_key).await?;
|
let contacts = client.database().contacts_public_keys(public_key).await?;
|
||||||
|
|
||||||
Ok(contacts)
|
Ok(contacts)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,6 +256,7 @@ impl Sidebar {
|
|||||||
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
// Disable the input to prevent duplicate requests
|
// Disable the input to prevent duplicate requests
|
||||||
self.find_input.update(cx, |this, cx| {
|
self.find_input.update(cx, |this, cx| {
|
||||||
|
this.set_disabled(status, cx);
|
||||||
this.set_loading(status, cx);
|
this.set_loading(status, cx);
|
||||||
});
|
});
|
||||||
// Set the search status
|
// Set the search status
|
||||||
@@ -314,14 +324,14 @@ impl Sidebar {
|
|||||||
let async_chat = chat.downgrade();
|
let async_chat = chat.downgrade();
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
let signer = nostr.read(cx).signer();
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get all selected public keys
|
// Get all selected public keys
|
||||||
let receivers = self.get_selected(cx);
|
let receivers = self.get_selected(cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
// Create a new room and emit it
|
// Create a new room and emit it
|
||||||
async_chat.update_in(cx, |this, _window, cx| {
|
async_chat.update_in(cx, |this, _window, cx| {
|
||||||
let room = cx.new(|_| {
|
let room = cx.new(|_| {
|
||||||
@@ -329,7 +339,7 @@ impl Sidebar {
|
|||||||
.organize(&public_key)
|
.organize(&public_key)
|
||||||
.kind(RoomKind::Ongoing)
|
.kind(RoomKind::Ongoing)
|
||||||
});
|
});
|
||||||
this.emit_room(&room, _window, cx);
|
this.emit_room(&room, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Reset the find panel
|
// Reset the find panel
|
||||||
@@ -373,9 +383,9 @@ impl Sidebar {
|
|||||||
let room = item.read(cx);
|
let room = item.read(cx);
|
||||||
let room_clone = item.clone();
|
let room_clone = item.clone();
|
||||||
let public_key = room.display_member(cx).public_key();
|
let public_key = room.display_member(cx).public_key();
|
||||||
let handler = cx.listener(move |_this, _ev, window, cx| {
|
let handler = cx.listener(move |_this, _ev, _window, cx| {
|
||||||
ChatRegistry::global(cx).update(cx, |s, cx| {
|
ChatRegistry::global(cx).update(cx, |s, cx| {
|
||||||
s.emit_room(&room_clone, window, cx);
|
s.emit_room(&room_clone, cx);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -456,7 +466,7 @@ impl Sidebar {
|
|||||||
});
|
});
|
||||||
|
|
||||||
RoomEntry::new(range.start + ix)
|
RoomEntry::new(range.start + ix)
|
||||||
.name(profile.name().trim())
|
.name(profile.name())
|
||||||
.avatar(profile.avatar())
|
.avatar(profile.avatar())
|
||||||
.on_click(handler)
|
.on_click(handler)
|
||||||
.selected(selected)
|
.selected(selected)
|
||||||
@@ -468,7 +478,7 @@ impl Sidebar {
|
|||||||
|
|
||||||
impl Panel for Sidebar {
|
impl Panel for Sidebar {
|
||||||
fn panel_id(&self) -> SharedString {
|
fn panel_id(&self) -> SharedString {
|
||||||
"Sidebar".into()
|
self.name.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,11 +492,8 @@ impl Focusable for Sidebar {
|
|||||||
|
|
||||||
impl Render for Sidebar {
|
impl Render for Sidebar {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let logged_in = nostr.read(cx).current_user().is_some();
|
let loading = chat.read(cx).loading();
|
||||||
let loading = chat.read(cx).loading() && logged_in;
|
|
||||||
|
|
||||||
let total_rooms = chat.read(cx).count(self.filter.read(cx), cx);
|
let total_rooms = chat.read(cx).count(self.filter.read(cx), cx);
|
||||||
|
|
||||||
// Whether the find panel should be shown
|
// Whether the find panel should be shown
|
||||||
@@ -500,15 +507,21 @@ impl Render for Sidebar {
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(coop_cache("sidebar", IMAGE_CACHE_SIZE))
|
.image_cache(self.image_cache.clone())
|
||||||
.size_full()
|
.size_full()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(
|
.child(
|
||||||
h_flex().px_2().py_1().child(
|
h_flex()
|
||||||
Input::new(&self.find_input)
|
.h(TABBAR_HEIGHT)
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.bg(cx.theme().tab_background)
|
||||||
|
.child(
|
||||||
|
TextInput::new(&self.find_input)
|
||||||
|
.appearance(false)
|
||||||
|
.bordered(false)
|
||||||
.small()
|
.small()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.disabled(loading)
|
|
||||||
.when(!self.find_input.read(cx).loading, |this| {
|
.when(!self.find_input.read(cx).loading, |this| {
|
||||||
this.suffix(
|
this.suffix(
|
||||||
Button::new("find-icon")
|
Button::new("find-icon")
|
||||||
@@ -530,6 +543,7 @@ impl Render for Sidebar {
|
|||||||
Button::new("search-results")
|
Button::new("search-results")
|
||||||
.icon(IconName::Search)
|
.icon(IconName::Search)
|
||||||
.tooltip("All search results")
|
.tooltip("All search results")
|
||||||
|
.small()
|
||||||
.ghost_alt()
|
.ghost_alt()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
@@ -545,8 +559,9 @@ impl Render for Sidebar {
|
|||||||
this.icon(IconName::Inbox)
|
this.icon(IconName::Inbox)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.when(!show_find_panel, |this| this.label("Inbox").small())
|
.when(!show_find_panel, |this| this.label("Inbox"))
|
||||||
.tooltip("All ongoing conversations")
|
.tooltip("All ongoing conversations")
|
||||||
|
.small()
|
||||||
.ghost_alt()
|
.ghost_alt()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
@@ -566,8 +581,9 @@ impl Render for Sidebar {
|
|||||||
this.icon(IconName::Fistbump)
|
this.icon(IconName::Fistbump)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.when(!show_find_panel, |this| this.label("Requests").small())
|
.when(!show_find_panel, |this| this.label("Requests"))
|
||||||
.tooltip("Incoming new conversations")
|
.tooltip("Incoming new conversations")
|
||||||
|
.small()
|
||||||
.ghost_alt()
|
.ghost_alt()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
@@ -596,13 +612,17 @@ impl Render for Sidebar {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.text_center()
|
.text_center()
|
||||||
.child(div().text_sm().font_semibold().child("No conversations"))
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_sm()
|
||||||
.text_color(cx.theme().text_muted)
|
.font_semibold()
|
||||||
.child("Start a conversation with someone to get started."),
|
.child(SharedString::from("No conversations")),
|
||||||
|
)
|
||||||
|
.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
||||||
|
SharedString::from(
|
||||||
|
"Start a conversation with someone to get started.",
|
||||||
),
|
),
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -627,7 +647,7 @@ impl Render for Sidebar {
|
|||||||
.font_semibold()
|
.font_semibold()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(Icon::new(IconName::ChevronDown))
|
.child(Icon::new(IconName::ChevronDown))
|
||||||
.child("Results"),
|
.child(SharedString::from("Results")),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
uniform_list(
|
uniform_list(
|
||||||
@@ -654,7 +674,7 @@ impl Render for Sidebar {
|
|||||||
.font_semibold()
|
.font_semibold()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(Icon::new(IconName::ChevronDown).small())
|
.child(Icon::new(IconName::ChevronDown).small())
|
||||||
.child("Contacts"),
|
.child(SharedString::from("Suggestions")),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
uniform_list(
|
uniform_list(
|
||||||
@@ -691,17 +711,17 @@ impl Render for Sidebar {
|
|||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
.absolute()
|
.absolute()
|
||||||
.bottom_2()
|
.bottom_0()
|
||||||
.left_0()
|
.left_0()
|
||||||
.h_9()
|
.h_9()
|
||||||
.w_full()
|
.w_full()
|
||||||
.px_4()
|
.px_2()
|
||||||
.child(
|
.child(
|
||||||
Button::new("create")
|
Button::new("create")
|
||||||
.label(button_label)
|
.label(button_label)
|
||||||
.primary()
|
.primary()
|
||||||
.rounded()
|
.small()
|
||||||
.shadow_md()
|
.shadow_lg()
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.create_room(window, cx);
|
this.create_room(window, cx);
|
||||||
})),
|
})),
|
||||||
@@ -724,13 +744,15 @@ impl Render for Sidebar {
|
|||||||
.h_9()
|
.h_9()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.bg(cx.theme().background.opacity(0.85))
|
.bg(cx.theme().background.opacity(0.85))
|
||||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
.border_color(cx.theme().border_disabled)
|
||||||
|
.border_1()
|
||||||
|
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(Indicator::new().small().color(cx.theme().icon_accent))
|
.child(Indicator::new().small().color(cx.theme().icon_accent))
|
||||||
.child("Getting messages..."),
|
.child(SharedString::from("Getting messages...")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1,49 +1,53 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use ::settings::AppSettings;
|
use ::settings::AppSettings;
|
||||||
use anyhow::Error;
|
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
use common::{CoopImageCache, download_dir};
|
use common::download_dir;
|
||||||
use device::{DeviceEvent, DeviceRegistry};
|
use device::{DeviceEvent, DeviceRegistry};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
Render, SharedString, Styled, Subscription, Task, Window, div, image_cache, px,
|
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div, px,
|
||||||
|
relative,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{PersonRegistry, shorten_pubkey};
|
use person::{PersonRegistry, shorten_pubkey};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent};
|
use state::{NostrRegistry, StateEvent};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||||
|
use title_bar::TitleBar;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{ClosePanel, DockArea, DockItem, DockPlacement, PanelView};
|
use ui::dock::{ClosePanel, DockArea, DockItem, DockPlacement, PanelView};
|
||||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||||
use ui::notification::{Notification, NotificationKind};
|
use ui::notification::{Notification, NotificationKind};
|
||||||
use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_flex};
|
use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
use crate::dialogs::import::ImportIdentity;
|
|
||||||
use crate::dialogs::restore::RestoreEncryption;
|
use crate::dialogs::restore::RestoreEncryption;
|
||||||
use crate::dialogs::settings;
|
use crate::dialogs::{accounts, settings};
|
||||||
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list};
|
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list, trash};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar;
|
||||||
|
|
||||||
mod dialogs;
|
const PREPARE_MSG: &str = "Coop is preparing a new identity for you. This may take a moment...";
|
||||||
mod panels;
|
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
|
||||||
mod sidebar;
|
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> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
||||||
cx.new(|cx| Workspace::new(window, cx))
|
cx.new(|cx| Workspace::new(window, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DeviceNotifcation;
|
struct DeviceNotifcation;
|
||||||
struct MsgRelayNotification;
|
struct SignerNotifcation;
|
||||||
|
struct RelayNotifcation;
|
||||||
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
#[action(namespace = workspace, no_json)]
|
#[action(namespace = workspace, no_json)]
|
||||||
enum Command {
|
enum Command {
|
||||||
ToggleTheme,
|
ToggleTheme,
|
||||||
|
ToggleAccount,
|
||||||
|
|
||||||
RefreshMessagingRelays,
|
RefreshMessagingRelays,
|
||||||
BackupEncryption,
|
BackupEncryption,
|
||||||
@@ -60,18 +64,14 @@ enum Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
sidebar: Entity<Sidebar>,
|
/// App's Title Bar
|
||||||
|
titlebar: Entity<TitleBar>,
|
||||||
|
|
||||||
/// App's Dock Area
|
/// App's Dock Area
|
||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
|
|
||||||
/// App's Image Cache
|
|
||||||
image_cache: Entity<CoopImageCache>,
|
|
||||||
|
|
||||||
/// Async tasks
|
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 6]>,
|
_subscriptions: SmallVec<[Subscription; 5]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
@@ -80,9 +80,8 @@ impl Workspace {
|
|||||||
let device = DeviceRegistry::global(cx);
|
let device = DeviceRegistry::global(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
let titlebar = cx.new(|_| TitleBar::new());
|
||||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||||
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
@@ -94,14 +93,42 @@ impl Workspace {
|
|||||||
);
|
);
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe to the nostr events
|
// Subscribe to the signer events
|
||||||
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
|
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
|
||||||
match event {
|
match event {
|
||||||
StateEvent::SignerChanged => {
|
StateEvent::Creating => {
|
||||||
window.close_all_modals(cx);
|
let note = Notification::new()
|
||||||
|
.id::<SignerNotifcation>()
|
||||||
|
.title("Preparing a new identity")
|
||||||
|
.message(PREPARE_MSG)
|
||||||
|
.autohide(false)
|
||||||
|
.with_kind(NotificationKind::Info);
|
||||||
|
|
||||||
|
window.push_notification(note, cx);
|
||||||
}
|
}
|
||||||
StateEvent::NoSigner => {
|
StateEvent::Connecting => {
|
||||||
this.import_identity(window, cx);
|
let note = Notification::new()
|
||||||
|
.id::<RelayNotifcation>()
|
||||||
|
.message("Connecting to the bootstrap relays...")
|
||||||
|
.with_kind(NotificationKind::Info);
|
||||||
|
|
||||||
|
window.push_notification(note, cx);
|
||||||
|
}
|
||||||
|
StateEvent::Connected => {
|
||||||
|
let note = Notification::new()
|
||||||
|
.id::<RelayNotifcation>()
|
||||||
|
.message("Connected to the bootstrap relays")
|
||||||
|
.with_kind(NotificationKind::Success);
|
||||||
|
|
||||||
|
window.push_notification(note, cx);
|
||||||
|
}
|
||||||
|
StateEvent::SignerSet => {
|
||||||
|
this.set_center_layout(window, cx);
|
||||||
|
// Clear the signer notification
|
||||||
|
window.clear_notification::<SignerNotifcation>(cx);
|
||||||
|
}
|
||||||
|
StateEvent::Show => {
|
||||||
|
this.account_selector(window, cx);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
@@ -114,7 +141,7 @@ impl Workspace {
|
|||||||
match event {
|
match event {
|
||||||
DeviceEvent::Requesting => {
|
DeviceEvent::Requesting => {
|
||||||
const MSG: &str =
|
const MSG: &str =
|
||||||
"Please open other client and approve the request for encryption key.";
|
"Coop has sent a request for an encryption key. Please open the other client then approve the request.";
|
||||||
|
|
||||||
let note = Notification::new()
|
let note = Notification::new()
|
||||||
.id::<DeviceNotifcation>()
|
.id::<DeviceNotifcation>()
|
||||||
@@ -125,25 +152,12 @@ impl Workspace {
|
|||||||
|
|
||||||
window.push_notification(note, cx);
|
window.push_notification(note, cx);
|
||||||
}
|
}
|
||||||
DeviceEvent::NotSet => {
|
DeviceEvent::Creating => {
|
||||||
const MSG: &str =
|
|
||||||
"User're not setup encryption key yet. Do you want to create one?";
|
|
||||||
|
|
||||||
let note = Notification::new()
|
let note = Notification::new()
|
||||||
.id::<DeviceNotifcation>()
|
.id::<DeviceNotifcation>()
|
||||||
.message(MSG)
|
.autohide(false)
|
||||||
.with_kind(NotificationKind::Info)
|
.message("Creating encryption key")
|
||||||
.action(|_this, _window, _cx| {
|
.with_kind(NotificationKind::Info);
|
||||||
Button::new("retry").label("Retry").on_click(
|
|
||||||
move |_this, window, cx| {
|
|
||||||
let device = DeviceRegistry::global(cx);
|
|
||||||
device.update(cx, |this, cx| {
|
|
||||||
this.set_announcement(Keys::generate(), cx);
|
|
||||||
});
|
|
||||||
window.clear_notification::<DeviceNotifcation>(cx);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
window.push_notification(note, cx);
|
window.push_notification(note, cx);
|
||||||
}
|
}
|
||||||
@@ -166,27 +180,6 @@ impl Workspace {
|
|||||||
// Observe all events emitted by the chat registry
|
// Observe all events emitted by the chat registry
|
||||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
||||||
match ev {
|
match ev {
|
||||||
ChatEvent::InboxRelayNotFound => {
|
|
||||||
const MSG: &str = "Messaging Relays not found. Cannot receive messages.";
|
|
||||||
|
|
||||||
window.push_notification(
|
|
||||||
Notification::warning(MSG)
|
|
||||||
.id::<MsgRelayNotification>()
|
|
||||||
.autohide(false)
|
|
||||||
.action(|_this, _window, _cx| {
|
|
||||||
Button::new("retry").label("Retry").on_click(
|
|
||||||
move |_this, window, cx| {
|
|
||||||
let chat = ChatRegistry::global(cx);
|
|
||||||
chat.update(cx, |this, cx| {
|
|
||||||
this.get_metadata(cx);
|
|
||||||
});
|
|
||||||
window.clear_notification::<MsgRelayNotification>(cx);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ChatEvent::OpenRoom(id) => {
|
ChatEvent::OpenRoom(id) => {
|
||||||
if let Some(room) = chat.read(cx).room(id, cx) {
|
if let Some(room) = chat.read(cx).room(id, cx) {
|
||||||
this.dock.update(cx, |this, cx| {
|
this.dock.update(cx, |this, cx| {
|
||||||
@@ -219,22 +212,25 @@ impl Workspace {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
cx.defer_in(window, |this, window, cx| {
|
subscriptions.push(
|
||||||
let dock = this.dock.downgrade();
|
// Observe the chat registry
|
||||||
let greeter = Arc::new(greeter::init(window, cx));
|
cx.observe(&chat, move |this, chat, cx| {
|
||||||
let tabs = DockItem::tabs(vec![greeter], None, &dock, window, cx);
|
let ids = this.panel_ids(cx);
|
||||||
let center = DockItem::split(Axis::Vertical, vec![tabs], &dock, window, cx);
|
|
||||||
|
|
||||||
this.dock.update(cx, |this, cx| {
|
chat.update(cx, |this, cx| {
|
||||||
this.set_center(center, window, cx);
|
this.refresh_rooms(&ids, cx);
|
||||||
});
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set the layout at the end of cycle
|
||||||
|
cx.defer_in(window, |this, window, cx| {
|
||||||
|
this.set_layout(window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
sidebar,
|
titlebar,
|
||||||
dock,
|
dock,
|
||||||
image_cache,
|
|
||||||
tasks: vec![],
|
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,6 +251,40 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get all panel ids
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the dock layout
|
||||||
|
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||||
|
|
||||||
|
// 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 greeter = Arc::new(greeter::init(window, cx));
|
||||||
|
let tabs = DockItem::tabs(vec![greeter], 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
|
/// Handle command events
|
||||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
match command {
|
match command {
|
||||||
@@ -271,12 +301,13 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
Command::ShowProfile => {
|
Command::ShowProfile => {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
if let Some(public_key) = nostr.read(cx).current_user() {
|
if let Some(public_key) = signer.public_key() {
|
||||||
self.dock.update(cx, |this, cx| {
|
self.dock.update(cx, |this, cx| {
|
||||||
this.add_panel(
|
this.add_panel(
|
||||||
Arc::new(profile::init(public_key, window, cx)),
|
Arc::new(profile::init(public_key, window, cx)),
|
||||||
DockPlacement::Left,
|
DockPlacement::Right,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -287,7 +318,7 @@ impl Workspace {
|
|||||||
self.dock.update(cx, |this, cx| {
|
self.dock.update(cx, |this, cx| {
|
||||||
this.add_panel(
|
this.add_panel(
|
||||||
Arc::new(contact_list::init(window, cx)),
|
Arc::new(contact_list::init(window, cx)),
|
||||||
DockPlacement::Left,
|
DockPlacement::Right,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -297,7 +328,7 @@ impl Workspace {
|
|||||||
self.dock.update(cx, |this, cx| {
|
self.dock.update(cx, |this, cx| {
|
||||||
this.add_panel(
|
this.add_panel(
|
||||||
Arc::new(backup::init(window, cx)),
|
Arc::new(backup::init(window, cx)),
|
||||||
DockPlacement::Left,
|
DockPlacement::Right,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -307,7 +338,7 @@ impl Workspace {
|
|||||||
self.dock.update(cx, |this, cx| {
|
self.dock.update(cx, |this, cx| {
|
||||||
this.add_panel(
|
this.add_panel(
|
||||||
Arc::new(messaging_relays::init(window, cx)),
|
Arc::new(messaging_relays::init(window, cx)),
|
||||||
DockPlacement::Left,
|
DockPlacement::Right,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -317,7 +348,7 @@ impl Workspace {
|
|||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
// Trigger a refresh of the chat registry
|
// Trigger a refresh of the chat registry
|
||||||
chat.update(cx, |this, cx| {
|
chat.update(cx, |this, cx| {
|
||||||
this.reload(cx);
|
this.refresh(window, cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Command::ShowRelayList => {
|
Command::ShowRelayList => {
|
||||||
@@ -342,11 +373,14 @@ impl Workspace {
|
|||||||
Command::ToggleTheme => {
|
Command::ToggleTheme => {
|
||||||
self.theme_selector(window, cx);
|
self.theme_selector(window, cx);
|
||||||
}
|
}
|
||||||
|
Command::ToggleAccount => {
|
||||||
|
self.account_selector(window, cx);
|
||||||
|
}
|
||||||
Command::BackupEncryption => {
|
Command::BackupEncryption => {
|
||||||
let device = DeviceRegistry::global(cx).downgrade();
|
let device = DeviceRegistry::global(cx).downgrade();
|
||||||
let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
|
let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
cx.spawn_in(window, async move |_this, cx| {
|
||||||
// Get the output path from the save dialog
|
// Get the output path from the save dialog
|
||||||
let output_path = match save_dialog.await {
|
let output_path = match save_dialog.await {
|
||||||
Ok(Ok(Some(path))) => path,
|
Ok(Ok(Some(path))) => path,
|
||||||
@@ -373,8 +407,9 @@ impl Workspace {
|
|||||||
cx.open_with_system(output_path.as_path());
|
cx.open_with_system(output_path.as_path());
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok::<_, anyhow::Error>(())
|
||||||
}));
|
})
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
Command::ImportEncryption => {
|
Command::ImportEncryption => {
|
||||||
self.import_encryption(window, cx);
|
self.import_encryption(window, cx);
|
||||||
@@ -383,12 +418,6 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
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.";
|
|
||||||
|
|
||||||
let device = DeviceRegistry::global(cx);
|
let device = DeviceRegistry::global(cx);
|
||||||
let ent = device.downgrade();
|
let ent = device.downgrade();
|
||||||
|
|
||||||
@@ -423,23 +452,24 @@ impl Workspace {
|
|||||||
|
|
||||||
fn import_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn import_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let restore = cx.new(|cx| RestoreEncryption::new(window, cx));
|
let restore = cx.new(|cx| RestoreEncryption::new(window, cx));
|
||||||
|
|
||||||
window.open_modal(cx, move |this, _window, _cx| {
|
window.open_modal(cx, move |this, _window, _cx| {
|
||||||
this.width(px(420.))
|
this.width(px(520.))
|
||||||
.title("Restore Encryption")
|
.title("Restore Encryption")
|
||||||
.child(restore.clone())
|
.child(restore.clone())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_identity(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn account_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
let accounts = accounts::init(window, cx);
|
||||||
|
|
||||||
window.open_modal(cx, move |this, _window, _cx| {
|
window.open_modal(cx, move |this, _window, _cx| {
|
||||||
this.width(px(450.))
|
this.width(px(520.))
|
||||||
|
.title("Continue with")
|
||||||
.show_close(false)
|
.show_close(false)
|
||||||
.overlay_closable(false)
|
|
||||||
.keyboard(false)
|
.keyboard(false)
|
||||||
.title("Onboarding")
|
.overlay_closable(false)
|
||||||
.child(import.clone())
|
.child(accounts.clone())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,7 +555,8 @@ impl Workspace {
|
|||||||
|
|
||||||
fn titlebar_left(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn titlebar_left(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let current_user = nostr.read(cx).current_user();
|
let signer = nostr.read(cx).signer();
|
||||||
|
let current_user = signer.public_key();
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
@@ -535,7 +566,7 @@ impl Workspace {
|
|||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("Import your identity to continue")),
|
.child(SharedString::from("Choose an account to continue...")),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when_some(current_user.as_ref(), |this, public_key| {
|
.when_some(current_user.as_ref(), |this, public_key| {
|
||||||
@@ -586,6 +617,11 @@ impl Workspace {
|
|||||||
Box::new(Command::ToggleTheme),
|
Box::new(Command::ToggleTheme),
|
||||||
)
|
)
|
||||||
.separator()
|
.separator()
|
||||||
|
.menu_with_icon(
|
||||||
|
"Accounts",
|
||||||
|
IconName::Group,
|
||||||
|
Box::new(Command::ToggleAccount),
|
||||||
|
)
|
||||||
.menu_with_icon(
|
.menu_with_icon(
|
||||||
"Settings",
|
"Settings",
|
||||||
IconName::Settings,
|
IconName::Settings,
|
||||||
@@ -598,10 +634,16 @@ impl Workspace {
|
|||||||
|
|
||||||
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
let initializing = chat.read(cx).initializing;
|
||||||
let nostr = NostrRegistry::global(cx);
|
let trash_messages = chat.read(cx).count_trash_messages(cx);
|
||||||
|
|
||||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
let device = DeviceRegistry::global(cx);
|
||||||
|
let device_initializing = device.read(cx).initializing;
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
let Some(public_key) = signer.public_key() else {
|
||||||
return div();
|
return div();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -612,13 +654,50 @@ impl Workspace {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.when(nip4e_enabled, |this| {
|
.when(trash_messages > 0, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.id("trash-messages")
|
||||||
|
.h_6()
|
||||||
|
.px_1()
|
||||||
|
.gap_1()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||||
|
.child(
|
||||||
|
Icon::new(IconName::Warning)
|
||||||
|
.small()
|
||||||
|
.text_color(cx.theme().text_danger),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.line_height(relative(1.))
|
||||||
|
.child(format!("{trash_messages}")),
|
||||||
|
)
|
||||||
|
.on_click(move |_ev, window, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
// Add the trash panel to the center workspace
|
||||||
|
Self::add_panel(
|
||||||
|
trash::init(window, cx),
|
||||||
|
DockPlacement::Center,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
Button::new("key")
|
Button::new("key")
|
||||||
.icon(IconName::UserKey)
|
.icon(IconName::UserKey)
|
||||||
.tooltip("Decoupled encryption key")
|
.tooltip("Decoupled encryption key")
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
|
.loading(device_initializing)
|
||||||
|
.when(device_initializing, |this| {
|
||||||
|
this.label("Dekey")
|
||||||
|
.xsmall()
|
||||||
|
.tooltip("Loading decoupled encryption key...")
|
||||||
|
})
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
this.min_w(px(260.))
|
this.min_w(px(260.))
|
||||||
.label("Encryption Key")
|
.label("Encryption Key")
|
||||||
@@ -637,8 +716,7 @@ impl Workspace {
|
|||||||
)
|
)
|
||||||
.child(name.clone())
|
.child(name.clone())
|
||||||
}))
|
}))
|
||||||
.item(
|
.item(PopupMenuItem::element(move |_window, cx| {
|
||||||
PopupMenuItem::element(move |_window, cx| {
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
@@ -648,8 +726,7 @@ impl Workspace {
|
|||||||
.text_color(cx.theme().icon_muted),
|
.text_color(cx.theme().icon_muted),
|
||||||
)
|
)
|
||||||
.child(SharedString::from(pkey.clone()))
|
.child(SharedString::from(pkey.clone()))
|
||||||
}),
|
}))
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.separator()
|
.separator()
|
||||||
.menu_with_icon(
|
.menu_with_icon(
|
||||||
@@ -675,12 +752,17 @@ impl Workspace {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("inbox")
|
Button::new("inbox")
|
||||||
.icon(IconName::Inbox)
|
.icon(IconName::Inbox)
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
|
.loading(initializing)
|
||||||
|
.when(initializing, |this| {
|
||||||
|
this.label("Inbox")
|
||||||
|
.xsmall()
|
||||||
|
.tooltip("Getting inbox messages...")
|
||||||
|
})
|
||||||
.dropdown_menu(move |this, _window, cx| {
|
.dropdown_menu(move |this, _window, cx| {
|
||||||
let urls: Vec<(SharedString, SharedString)> = profile
|
let urls: Vec<(SharedString, SharedString)> = profile
|
||||||
.messaging_relays()
|
.messaging_relays()
|
||||||
@@ -704,7 +786,17 @@ impl Workspace {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.child(url.clone())
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.size_1p5()
|
||||||
|
.rounded_full()
|
||||||
|
.bg(cx.theme().icon_accent),
|
||||||
|
)
|
||||||
|
.child(url.clone()),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
@@ -716,6 +808,11 @@ impl Workspace {
|
|||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
menu.separator()
|
menu.separator()
|
||||||
|
.menu_with_icon(
|
||||||
|
"Reload",
|
||||||
|
IconName::Refresh,
|
||||||
|
Box::new(Command::RefreshMessagingRelays),
|
||||||
|
)
|
||||||
.menu_with_icon(
|
.menu_with_icon(
|
||||||
"Manage gossip relays",
|
"Manage gossip relays",
|
||||||
IconName::Relay,
|
IconName::Relay,
|
||||||
@@ -723,15 +820,9 @@ impl Workspace {
|
|||||||
)
|
)
|
||||||
.menu_with_icon(
|
.menu_with_icon(
|
||||||
"Manage messaging relays",
|
"Manage messaging relays",
|
||||||
IconName::Relay,
|
IconName::Settings,
|
||||||
Box::new(Command::ShowMessaging),
|
Box::new(Command::ShowMessaging),
|
||||||
)
|
)
|
||||||
.separator()
|
|
||||||
.menu_with_icon(
|
|
||||||
"Reload",
|
|
||||||
IconName::Refresh,
|
|
||||||
Box::new(Command::RefreshMessagingRelays),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -742,38 +833,28 @@ impl Render for Workspace {
|
|||||||
let modal_layer = Root::render_modal_layer(window, cx);
|
let modal_layer = Root::render_modal_layer(window, cx);
|
||||||
let notification_layer = Root::render_notification_layer(window, cx);
|
let notification_layer = Root::render_notification_layer(window, cx);
|
||||||
|
|
||||||
|
// Titlebar elements
|
||||||
|
let left = self.titlebar_left(cx).into_any_element();
|
||||||
|
let right = self.titlebar_right(cx).into_any_element();
|
||||||
|
|
||||||
|
// Update title bar children
|
||||||
|
self.titlebar.update(cx, |this, _cx| {
|
||||||
|
this.set_children(vec![left, right]);
|
||||||
|
});
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.id("workspace")
|
.id(SharedString::from("workspace"))
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
image_cache(self.image_cache.clone())
|
v_flex()
|
||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
.size_full()
|
||||||
// Title Bar
|
// Title Bar
|
||||||
.child(
|
.child(self.titlebar.clone())
|
||||||
TitleBar::new()
|
// Dock
|
||||||
.child(self.titlebar_left(cx))
|
|
||||||
.child(self.titlebar_right(cx)),
|
|
||||||
)
|
|
||||||
// Main
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.size_full()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.h_full()
|
|
||||||
.w(SIDEBAR_WIDTH)
|
|
||||||
.child(self.sidebar.clone()),
|
|
||||||
)
|
|
||||||
.child(self.dock.clone()),
|
.child(self.dock.clone()),
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
// Notifications
|
// Notifications
|
||||||
.children(notification_layer)
|
.children(notification_layer)
|
||||||
43
crates/coop_web/Cargo.toml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
[package]
|
||||||
|
name = "coop_web"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
assets = { path = "../assets" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
theme = { path = "../theme" }
|
||||||
|
common = { path = "../common" }
|
||||||
|
state = { path = "../state" }
|
||||||
|
device = { path = "../device" }
|
||||||
|
chat = { path = "../chat" }
|
||||||
|
chat_ui = { path = "../chat_ui" }
|
||||||
|
settings = { path = "../settings" }
|
||||||
|
person = { path = "../person" }
|
||||||
|
relay_auth = { path = "../relay_auth" }
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
gpui_web.workspace = true
|
||||||
|
gpui_platform.workspace = true
|
||||||
|
gpui_tokio.workspace = true
|
||||||
|
|
||||||
|
nostr-connect.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
oneshot.workspace = true
|
||||||
|
webbrowser.workspace = true
|
||||||
|
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||||
|
console_error_panic_hook = "0.1"
|
||||||
|
tracing-wasm = "0.2"
|
||||||
|
console_log = "1.0"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
26
crates/coop_web/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use gpui::*;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn run() -> Result<(), JsValue> {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
|
||||||
|
// Initialize logging to browser console
|
||||||
|
console_log::init_with_level(log::Level::Info).expect("Failed to initialize logger");
|
||||||
|
|
||||||
|
// Also initialize tracing for WASM
|
||||||
|
tracing_wasm::set_as_global_default();
|
||||||
|
|
||||||
|
#[cfg(target_family = "wasm")]
|
||||||
|
gpui_platform::web_init();
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
let app = gpui_platform::application();
|
||||||
|
|
||||||
|
#[cfg(target_family = "wasm")]
|
||||||
|
let app = gpui_platform::single_threaded_web();
|
||||||
|
|
||||||
|
app.run(|_cx| {});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -72,6 +72,6 @@
|
|||||||
<p>Loading Coop...</p>
|
<p>Loading Coop...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -10,15 +10,15 @@ state = { path = "../state" }
|
|||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
ui = { path = "../ui" }
|
ui = { path = "../ui" }
|
||||||
theme = { path = "../theme" }
|
theme = { path = "../theme" }
|
||||||
settings = { path = "../settings" }
|
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
instant.workspace = true
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
serde_json.workspace = true
|
||||||
smol.workspace = true
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ use std::cell::Cell;
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::time::Duration;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use instant::Duration;
|
|
||||||
|
|
||||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -13,9 +11,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
use state::{Announcement, NostrRegistry, StateEvent, TIMEOUT, app_name};
|
||||||
use smallvec::{SmallVec, smallvec};
|
|
||||||
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
|
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::Button;
|
use ui::button::Button;
|
||||||
@@ -23,6 +19,8 @@ use ui::notification::{Notification, NotificationKind};
|
|||||||
use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
const IDENTIFIER: &str = "coop:device";
|
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) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
||||||
@@ -37,10 +35,10 @@ impl Global for GlobalDeviceRegistry {}
|
|||||||
pub enum DeviceEvent {
|
pub enum DeviceEvent {
|
||||||
/// A new encryption signer has been set
|
/// A new encryption signer has been set
|
||||||
Set,
|
Set,
|
||||||
/// User have not setup encryption key
|
|
||||||
NotSet,
|
|
||||||
/// The device is requesting an encryption key
|
/// The device is requesting an encryption key
|
||||||
Requesting,
|
Requesting,
|
||||||
|
/// The device is creating a new encryption key
|
||||||
|
Creating,
|
||||||
/// An error occurred
|
/// An error occurred
|
||||||
Error(SharedString),
|
Error(SharedString),
|
||||||
}
|
}
|
||||||
@@ -59,20 +57,17 @@ impl DeviceEvent {
|
|||||||
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct DeviceRegistry {
|
pub struct DeviceRegistry {
|
||||||
|
/// Whether the registry is currently initializing
|
||||||
|
pub initializing: bool,
|
||||||
|
|
||||||
/// Whether there is a pending request for encryption key approval
|
/// Whether there is a pending request for encryption key approval
|
||||||
pub pending_request: bool,
|
pub pending_request: bool,
|
||||||
|
|
||||||
/// Whether an announcement has been made for this device
|
|
||||||
pub announcement_existed: Arc<AtomicBool>,
|
|
||||||
|
|
||||||
/// Signer
|
|
||||||
signer: Entity<Option<UniversalSigner>>,
|
|
||||||
|
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
/// Event subscription
|
/// Event subscription
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
_subscription: Option<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<DeviceEvent> for DeviceRegistry {}
|
impl EventEmitter<DeviceEvent> for DeviceRegistry {}
|
||||||
@@ -91,54 +86,35 @@ impl DeviceRegistry {
|
|||||||
/// Create a new device registry instance
|
/// Create a new device registry instance
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let settings = AppSettings::global(cx);
|
|
||||||
|
|
||||||
let signer = cx.new(|_| None);
|
|
||||||
let mut subscriptions = smallvec![];
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Subscribe to nostr state events
|
// Subscribe to nostr state events
|
||||||
cx.observe(&settings, move |this, settings, cx| {
|
let subscription = cx.subscribe_in(&nostr, window, |this, _e, event, _window, cx| {
|
||||||
if settings.read(cx).is_nip4e_enabled(cx) {
|
if event == &StateEvent::SignerSet {
|
||||||
|
this.set_initializing(true, cx);
|
||||||
this.get_announcement(cx);
|
this.get_announcement(cx);
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Observe the user signer
|
|
||||||
cx.subscribe(&nostr, move |this, _nostr, event, cx| {
|
|
||||||
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
|
|
||||||
this.get_announcement(cx);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.handle_notifications(window, cx);
|
this.handle_notifications(window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
signer,
|
initializing: true,
|
||||||
pending_request: false,
|
pending_request: false,
|
||||||
announcement_existed: Arc::new(AtomicBool::new(false)),
|
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscription: Some(subscription),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
let announcement_existed = self.announcement_existed.clone();
|
|
||||||
let (tx, rx) = flume::bounded::<Event>(100);
|
let (tx, rx) = flume::bounded::<Event>(100);
|
||||||
|
|
||||||
self.tasks.push(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
let mut processed_events = HashSet::new();
|
let mut processed_events = HashSet::new();
|
||||||
let current_user = signer.get_public_key_async().await.ok();
|
|
||||||
|
|
||||||
while let Some(notification) = notifications.next().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
if let ClientNotification::Message { message, .. } = notification
|
if let ClientNotification::Message { message, .. } = notification
|
||||||
@@ -150,15 +126,15 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::Custom(10044) if current_user == Some(event.pubkey) => {
|
Kind::Custom(4454) => {
|
||||||
announcement_existed.store(true, Ordering::Relaxed);
|
if verify_author(&client, event.as_ref()).await {
|
||||||
tx.send_async(event.into_owned()).await?;
|
tx.send_async(event.into_owned()).await?;
|
||||||
}
|
}
|
||||||
Kind::Custom(4454) if current_user == Some(event.pubkey) => {
|
}
|
||||||
|
Kind::Custom(4455) => {
|
||||||
|
if verify_author(&client, event.as_ref()).await {
|
||||||
tx.send_async(event.into_owned()).await?;
|
tx.send_async(event.into_owned()).await?;
|
||||||
}
|
}
|
||||||
Kind::Custom(4455) if current_user == Some(event.pubkey) => {
|
|
||||||
tx.send_async(event.into_owned()).await?;
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -171,11 +147,6 @@ impl DeviceRegistry {
|
|||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
while let Ok(event) = rx.recv_async().await {
|
while let Ok(event) = rx.recv_async().await {
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::Custom(10044) => {
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
|
||||||
this.set_encryption(&event, cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
// New request event from other device
|
// New request event from other device
|
||||||
Kind::Custom(4454) => {
|
Kind::Custom(4454) => {
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
@@ -195,41 +166,48 @@ impl DeviceRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set whether the registry is currently initializing
|
||||||
|
fn set_initializing(&mut self, initializing: bool, cx: &mut Context<Self>) {
|
||||||
|
self.initializing = initializing;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Set whether there is a pending request for encryption key approval
|
/// Set whether there is a pending request for encryption key approval
|
||||||
fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
|
fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
|
||||||
self.pending_request = pending;
|
self.pending_request = pending;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the signer
|
|
||||||
pub fn signer(&self, cx: &App) -> Option<UniversalSigner> {
|
|
||||||
self.signer.read(cx).clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the decoupled encryption key for the current user
|
/// Set the decoupled encryption key for the current user
|
||||||
fn set_signer(&mut self, new_signer: Keys, cx: &mut Context<Self>) {
|
fn set_signer<S>(&mut self, new: S, cx: &mut Context<Self>)
|
||||||
self.signer.update(cx, |this, cx| {
|
where
|
||||||
*this = Some(UniversalSigner::new(new_signer));
|
S: NostrSigner + 'static,
|
||||||
cx.notify();
|
{
|
||||||
});
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
signer.set_encryption_signer(new).await;
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_initializing(false, cx);
|
||||||
cx.emit(DeviceEvent::Set);
|
cx.emit(DeviceEvent::Set);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backup the encryption's secret key to a file
|
/// Backup the encryption's secret key to a file
|
||||||
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
|
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let keys = get_keys(&client, &signer).await?;
|
let keys = get_keys(&client).await?;
|
||||||
let content = keys.secret_key().to_bech32()?;
|
let content = keys.secret_key().to_bech32()?;
|
||||||
|
|
||||||
if cfg!(target_arch = "wasm32") {
|
|
||||||
return Err(anyhow!("Not supported"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
smol::fs::write(path, &content).await?;
|
smol::fs::write(path, &content).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -240,41 +218,47 @@ impl DeviceRegistry {
|
|||||||
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
self.tasks.push(cx.background_spawn(async move {
|
let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
|
||||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let current_user = signer.get_public_key_async().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
// Construct the filter for the device announcement event
|
// Construct the filter for the device announcement event
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::Custom(10044))
|
.kind(Kind::Custom(10044))
|
||||||
.author(current_user)
|
.author(public_key)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
client
|
// Stream events from user's write relays
|
||||||
.subscribe(filter)
|
let mut stream = client
|
||||||
.close_on(opts)
|
.stream_events(filter)
|
||||||
.with_id(SubscriptionId::new("nip4e"))
|
.timeout(Duration::from_secs(TIMEOUT))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
while let Some((_url, res)) = stream.next().await {
|
||||||
}));
|
if let Ok(event) = res {
|
||||||
|
return Ok(event);
|
||||||
let announcement_existed = self.announcement_existed.clone();
|
}
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
||||||
// Wait for 5 seconds
|
|
||||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
|
||||||
|
|
||||||
// Then check if the msg relays have been found
|
|
||||||
if announcement_existed.load(Ordering::Acquire) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update(cx, |_this, cx| {
|
Err(anyhow!("Announcement not found"))
|
||||||
cx.emit(DeviceEvent::NotSet);
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(event) => {
|
||||||
|
// Set encryption key from the announcement event
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_encryption(&event, cx);
|
||||||
})?;
|
})?;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// User has no announcement, create a new one
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_announcement(Keys::generate(), cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
@@ -284,6 +268,9 @@ impl DeviceRegistry {
|
|||||||
pub fn set_announcement(&mut self, keys: Keys, cx: &mut Context<Self>) {
|
pub fn set_announcement(&mut self, keys: Keys, cx: &mut Context<Self>) {
|
||||||
let task = self.create_encryption(keys, cx);
|
let task = self.create_encryption(keys, cx);
|
||||||
|
|
||||||
|
// Notify that we're creating a new encryption key
|
||||||
|
cx.emit(DeviceEvent::Creating);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(keys) => {
|
Ok(keys) => {
|
||||||
@@ -306,20 +293,19 @@ impl DeviceRegistry {
|
|||||||
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
|
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
let secret = keys.secret_key().to_secret_hex();
|
let secret = keys.secret_key().to_secret_hex();
|
||||||
let n = keys.public_key();
|
let n = keys.public_key();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
// Construct an announcement event
|
// Construct an announcement event
|
||||||
let event = EventBuilder::new(Kind::Custom(10044), "")
|
let builder = EventBuilder::new(Kind::Custom(10044), "").tags(vec![
|
||||||
.tags(vec![
|
Tag::custom(TagKind::custom("n"), vec![n]),
|
||||||
Tag::custom("n", vec![n]),
|
Tag::client(app_name()),
|
||||||
Tag::custom("client", vec![CLIENT_NAME]),
|
]);
|
||||||
])
|
|
||||||
.finalize_async(&signer)
|
// Sign the event with user's signer
|
||||||
.await?;
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
// Publish announcement
|
// Publish announcement
|
||||||
client
|
client
|
||||||
@@ -329,7 +315,7 @@ impl DeviceRegistry {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Save device keys to the database
|
// Save device keys to the database
|
||||||
set_keys(&client, &signer, &secret).await?;
|
set_keys(&client, &secret).await?;
|
||||||
|
|
||||||
Ok(keys)
|
Ok(keys)
|
||||||
})
|
})
|
||||||
@@ -339,14 +325,13 @@ impl DeviceRegistry {
|
|||||||
fn set_encryption(&mut self, event: &Event, cx: &mut Context<Self>) {
|
fn set_encryption(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
let announcement = Announcement::from(event);
|
let announcement = Announcement::from(event);
|
||||||
let device_pubkey = announcement.public_key();
|
let device_pubkey = announcement.public_key();
|
||||||
|
|
||||||
// Get encryption key from the database and compare with the announcement
|
// Get encryption key from the database and compare with the announcement
|
||||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||||
let keys = get_keys(&client, &signer).await?;
|
let keys = get_keys(&client).await?;
|
||||||
|
|
||||||
// Compare the public key from the announcement with the one from the database
|
// Compare the public key from the announcement with the one from the database
|
||||||
if keys.public_key() != device_pubkey {
|
if keys.public_key() != device_pubkey {
|
||||||
@@ -378,7 +363,7 @@ impl DeviceRegistry {
|
|||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
self.tasks.push(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
let id = SubscriptionId::new("dekey-requests");
|
let id = SubscriptionId::new("dekey-requests");
|
||||||
|
|
||||||
// Construct a filter for encryption key requests
|
// Construct a filter for encryption key requests
|
||||||
@@ -400,12 +385,11 @@ impl DeviceRegistry {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
let app_keys_task = get_or_init_app_keys(cx);
|
let app_keys = nostr.read(cx).keys();
|
||||||
|
let app_pubkey = app_keys.public_key();
|
||||||
|
|
||||||
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
|
||||||
let app_keys = app_keys_task.await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
let app_pubkey = app_keys.public_key();
|
|
||||||
let public_key = signer.get_public_key_async().await?;
|
|
||||||
|
|
||||||
// Construct a filter to get the latest approval event
|
// Construct a filter to get the latest approval event
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
@@ -420,13 +404,13 @@ impl DeviceRegistry {
|
|||||||
// No approval event found, construct a request event
|
// No approval event found, construct a request event
|
||||||
None => {
|
None => {
|
||||||
// Construct an event for device key request
|
// Construct an event for device key request
|
||||||
let event = EventBuilder::new(Kind::Custom(4454), "")
|
let builder = EventBuilder::new(Kind::Custom(4454), "").tags(vec![
|
||||||
.tags(vec![
|
Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
|
||||||
Tag::custom("P", vec![app_pubkey]),
|
Tag::client(app_name()),
|
||||||
Tag::custom("client", vec![CLIENT_NAME]),
|
]);
|
||||||
])
|
|
||||||
.finalize_async(&signer)
|
// Sign the event with user's signer
|
||||||
.await?;
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
// Send the event to write relays
|
// Send the event to write relays
|
||||||
client.send_event(&event).to_nip65().await?;
|
client.send_event(&event).to_nip65().await?;
|
||||||
@@ -445,7 +429,10 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_initializing(false, cx);
|
||||||
this.wait_for_approval(cx);
|
this.wait_for_approval(cx);
|
||||||
|
|
||||||
|
cx.emit(DeviceEvent::Requesting);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -464,10 +451,8 @@ impl DeviceRegistry {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
cx.emit(DeviceEvent::Requesting);
|
|
||||||
|
|
||||||
self.tasks.push(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
// Construct a filter for device key requests
|
// Construct a filter for device key requests
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
@@ -484,20 +469,19 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
/// Parse the approval event to get encryption key then set it
|
/// Parse the approval event to get encryption key then set it
|
||||||
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||||
let app_keys_task = get_or_init_app_keys(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let app_keys = nostr.read(cx).keys();
|
||||||
|
|
||||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||||
let app_keys = app_keys_task.await?;
|
|
||||||
let master = event
|
let master = event
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.find(TagKind::custom("P"))
|
||||||
.find(|tag| tag.kind() == "P")
|
|
||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
.and_then(|content| PublicKey::parse(content).ok())
|
.and_then(|content| PublicKey::parse(content).ok())
|
||||||
.context("Invalid event's tags")?;
|
.context("Invalid event's tags")?;
|
||||||
|
|
||||||
let payload = event.content.as_str();
|
let payload = event.content.as_str();
|
||||||
let decrypted = app_keys.nip44_decrypt_async(&master, payload).await?;
|
let decrypted = app_keys.nip44_decrypt(&master, payload).await?;
|
||||||
|
|
||||||
let secret = SecretKey::from_hex(&decrypted)?;
|
let secret = SecretKey::from_hex(&decrypted)?;
|
||||||
let keys = Keys::new(secret);
|
let keys = Keys::new(secret);
|
||||||
@@ -526,7 +510,6 @@ impl DeviceRegistry {
|
|||||||
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
// Get user's write relays
|
// Get user's write relays
|
||||||
let event = event.clone();
|
let event = event.clone();
|
||||||
@@ -534,32 +517,31 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
// Get device keys
|
// Get device keys
|
||||||
let keys = get_keys(&client, &signer).await?;
|
let keys = get_keys(&client).await?;
|
||||||
let secret = keys.secret_key().to_secret_hex();
|
let secret = keys.secret_key().to_secret_hex();
|
||||||
|
|
||||||
// Extract the target public key from the event tags
|
// Extract the target public key from the event tags
|
||||||
let target = event
|
let target = event
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.find(TagKind::custom("P"))
|
||||||
.find(|tag| tag.kind() == "P")
|
|
||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
.and_then(|content| PublicKey::parse(content).ok())
|
.and_then(|content| PublicKey::parse(content).ok())
|
||||||
.context("Target is not a valid public key")?;
|
.context("Target is not a valid public key")?;
|
||||||
|
|
||||||
// Encrypt the device keys with the user's signer
|
// Encrypt the device keys with the user's signer
|
||||||
let payload = keys.nip44_encrypt_async(&target, &secret).await?;
|
let payload = keys.nip44_encrypt(&target, &secret).await?;
|
||||||
|
|
||||||
// Construct the response event
|
// Construct the response event
|
||||||
//
|
//
|
||||||
// P tag: the current device's public key
|
// P tag: the current device's public key
|
||||||
// p tag: the requester's public key
|
// p tag: the requester's public key
|
||||||
let event = EventBuilder::new(Kind::Custom(4455), payload)
|
let builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
|
||||||
.tags(vec![
|
Tag::custom(TagKind::custom("P"), vec![keys.public_key().to_hex()]),
|
||||||
Tag::custom("P", vec![keys.public_key().to_hex()]),
|
|
||||||
Tag::public_key(target),
|
Tag::public_key(target),
|
||||||
])
|
]);
|
||||||
.finalize_async(&signer)
|
|
||||||
.await?;
|
// Sign the builder
|
||||||
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
// Send the response event to the user's relay list
|
// Send the response event to the user's relay list
|
||||||
client.send_event(&event).to_nip65().await?;
|
client.send_event(&event).to_nip65().await?;
|
||||||
@@ -567,7 +549,7 @@ impl DeviceRegistry {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
cx.spawn_in(window, async move |_this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
@@ -585,9 +567,8 @@ impl DeviceRegistry {
|
|||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
})
|
||||||
Ok(())
|
.detach();
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle encryption request
|
/// Handle encryption request
|
||||||
@@ -605,9 +586,6 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
/// Build a notification for the encryption request.
|
/// Build a notification for the encryption request.
|
||||||
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
|
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
|
||||||
const MSG: &str = "You've requested an encryption key from another device. \
|
|
||||||
Approve to allow Coop to share with it.";
|
|
||||||
|
|
||||||
let request = Announcement::from(&event);
|
let request = Announcement::from(&event);
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let profile = persons.read(cx).get(&request.public_key(), cx);
|
let profile = persons.read(cx).get(&request.public_key(), cx);
|
||||||
@@ -710,45 +688,29 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
struct DeviceNotification;
|
struct DeviceNotification;
|
||||||
|
|
||||||
/// Get or create new app keys (async, returns a task)
|
/// Verify the author of an event
|
||||||
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
|
async fn verify_author(client: &Client, event: &Event) -> bool {
|
||||||
let read = cx.read_credentials(CLIENT_NAME);
|
if let Some(signer) = client.signer()
|
||||||
|
&& let Ok(public_key) = signer.get_public_key().await
|
||||||
cx.spawn(async move |cx| {
|
|
||||||
if let Ok(Some((_, secret))) = read.await
|
|
||||||
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
|
|
||||||
{
|
{
|
||||||
return Ok(keys);
|
return public_key == event.pubkey;
|
||||||
}
|
}
|
||||||
|
false
|
||||||
// No stored keys found or invalid — generate new ones
|
|
||||||
let keys = Keys::generate();
|
|
||||||
let user = keys.public_key().to_hex();
|
|
||||||
let secret = keys.secret_key().to_secret_bytes();
|
|
||||||
|
|
||||||
cx.update(|cx| {
|
|
||||||
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
if let Err(e) = write.await {
|
|
||||||
log::error!("Keyring not available or panic: {e}")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(keys)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypt and store device keys in the local database.
|
/// Encrypt and store device keys in the local database.
|
||||||
async fn set_keys(client: &Client, signer: &UniversalSigner, secret: &str) -> Result<(), Error> {
|
async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> {
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
// Encrypt the value
|
||||||
|
let content = signer.nip44_encrypt(&public_key, secret).await?;
|
||||||
|
|
||||||
// Construct the application data event
|
// Construct the application data event
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||||
.tag(Tag::identifier(IDENTIFIER))
|
.tag(Tag::identifier(IDENTIFIER))
|
||||||
.finalize_async(signer)
|
.build(public_key)
|
||||||
|
.sign(&Keys::generate())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Save the event to the database
|
// Save the event to the database
|
||||||
@@ -758,8 +720,9 @@ async fn set_keys(client: &Client, signer: &UniversalSigner, secret: &str) -> Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get device keys from the local database.
|
/// Get device keys from the local database.
|
||||||
async fn get_keys(client: &Client, signer: &UniversalSigner) -> Result<Keys, Error> {
|
async fn get_keys(client: &Client) -> Result<Keys, Error> {
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
@@ -767,10 +730,7 @@ async fn get_keys(client: &Client, signer: &UniversalSigner) -> Result<Keys, Err
|
|||||||
.author(public_key);
|
.author(public_key);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.first() {
|
if let Some(event) = client.database().query(filter).await?.first() {
|
||||||
let content = signer
|
let content = signer.nip44_decrypt(&public_key, &event.content).await?;
|
||||||
.nip44_decrypt_async(&public_key, &event.content)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let secret = SecretKey::parse(&content)?;
|
let secret = SecretKey::parse(&content)?;
|
||||||
let keys = Keys::new(secret);
|
let keys = Keys::new(secret);
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ state = { path = "../state" }
|
|||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
instant.workspace = true
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::RwLock;
|
use std::rc::Rc;
|
||||||
use instant::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::EventExt;
|
use common::EventExt;
|
||||||
@@ -23,9 +24,9 @@ impl Global for GlobalPersonRegistry {}
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum Dispatch {
|
enum Dispatch {
|
||||||
Person(Person),
|
Person(Box<Person>),
|
||||||
Announcement(Event),
|
Announcement(Box<Event>),
|
||||||
Relays(Event),
|
Relays(Box<Event>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Person Registry
|
/// Person Registry
|
||||||
@@ -35,7 +36,7 @@ pub struct PersonRegistry {
|
|||||||
persons: HashMap<PublicKey, Entity<Person>>,
|
persons: HashMap<PublicKey, Entity<Person>>,
|
||||||
|
|
||||||
/// Set of public keys that have been seen
|
/// Set of public keys that have been seen
|
||||||
seen: RwLock<HashSet<PublicKey>>,
|
seens: Rc<RefCell<HashSet<PublicKey>>>,
|
||||||
|
|
||||||
/// Sender for requesting metadata
|
/// Sender for requesting metadata
|
||||||
sender: flume::Sender<PublicKey>,
|
sender: flume::Sender<PublicKey>,
|
||||||
@@ -62,26 +63,40 @@ impl PersonRegistry {
|
|||||||
|
|
||||||
// Channel for communication between nostr and gpui
|
// Channel for communication between nostr and gpui
|
||||||
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
||||||
let (metadata_tx, metadata_rx) = flume::unbounded::<PublicKey>();
|
let (mta_tx, mta_rx) = flume::unbounded::<PublicKey>();
|
||||||
|
|
||||||
let mut tasks = smallvec![];
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
let client2 = client.clone();
|
tasks.push(
|
||||||
tasks.push(cx.background_spawn(async move {
|
// Handle nostr notifications
|
||||||
Self::handle_notifications(&client2, &tx).await;
|
cx.background_spawn({
|
||||||
}));
|
let client = client.clone();
|
||||||
|
|
||||||
let client3 = client.clone();
|
async move {
|
||||||
tasks.push(cx.background_spawn(async move {
|
Self::handle_notifications(&client, &tx).await;
|
||||||
Self::handle_requests(&client3, &metadata_rx).await;
|
}
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
tasks.push(cx.spawn(async move |this, cx| {
|
tasks.push(
|
||||||
|
// Handle metadata requests
|
||||||
|
cx.background_spawn({
|
||||||
|
let client = client.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
Self::handle_requests(&client, &mta_rx).await;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Update GPUI state
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
while let Ok(event) = rx.recv_async().await {
|
while let Ok(event) = rx.recv_async().await {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
match event {
|
match event {
|
||||||
Dispatch::Person(person) => {
|
Dispatch::Person(person) => {
|
||||||
this.insert(person, cx);
|
this.insert(*person, cx);
|
||||||
}
|
}
|
||||||
Dispatch::Announcement(event) => {
|
Dispatch::Announcement(event) => {
|
||||||
this.set_announcement(&event, cx);
|
this.set_announcement(&event, cx);
|
||||||
@@ -93,7 +108,8 @@ impl PersonRegistry {
|
|||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Load all user profiles from the database
|
// Load all user profiles from the database
|
||||||
cx.defer_in(window, |this, _window, cx| {
|
cx.defer_in(window, |this, _window, cx| {
|
||||||
@@ -102,8 +118,8 @@ impl PersonRegistry {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
persons: HashMap::new(),
|
persons: HashMap::new(),
|
||||||
seen: RwLock::new(HashSet::new()),
|
seens: Rc::new(RefCell::new(HashSet::new())),
|
||||||
sender: metadata_tx,
|
sender: mta_tx,
|
||||||
tasks,
|
tasks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,25 +145,24 @@ impl PersonRegistry {
|
|||||||
Kind::Metadata => {
|
Kind::Metadata => {
|
||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
let person = Person::new(event.pubkey, metadata);
|
let person = Person::new(event.pubkey, metadata);
|
||||||
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
let val = Box::new(person);
|
||||||
log::warn!("PersonRegistry channel closed, dropping metadata event");
|
// Send
|
||||||
}
|
tx.send_async(Dispatch::Person(val)).await.ok();
|
||||||
}
|
}
|
||||||
Kind::ContactList => {
|
Kind::ContactList => {
|
||||||
let public_keys = event.extract_public_keys();
|
let public_keys = event.extract_public_keys();
|
||||||
if let Err(e) = get_metadata(client, public_keys).await {
|
// Get metadata for all public keys
|
||||||
log::warn!("Failed to get metadata for contact list: {e}");
|
get_metadata(client, public_keys).await.ok();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Kind::InboxRelays => {
|
Kind::InboxRelays => {
|
||||||
tx.send_async(Dispatch::Relays(event.into_owned()))
|
let val = Box::new(event.into_owned());
|
||||||
.await
|
// Send
|
||||||
.ok();
|
tx.send_async(Dispatch::Relays(val)).await.ok();
|
||||||
}
|
}
|
||||||
Kind::Custom(10044) => {
|
Kind::Custom(10044) => {
|
||||||
tx.send_async(Dispatch::Announcement(event.into_owned()))
|
let val = Box::new(event.into_owned());
|
||||||
.await
|
// Send
|
||||||
.ok();
|
tx.send_async(Dispatch::Announcement(val)).await.ok();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -167,17 +182,13 @@ impl PersonRegistry {
|
|||||||
Ok(Some(public_key)) => {
|
Ok(Some(public_key)) => {
|
||||||
batch.insert(public_key);
|
batch.insert(public_key);
|
||||||
// Process the batch if it's full
|
// Process the batch if it's full
|
||||||
if batch.len() >= 20
|
if batch.len() >= 20 {
|
||||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||||
{
|
|
||||||
log::warn!("Failed to get metadata batch: {e}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if !batch.is_empty()
|
if !batch.is_empty() {
|
||||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||||
{
|
|
||||||
log::warn!("Failed to get metadata batch: {e}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +217,7 @@ impl PersonRegistry {
|
|||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
if let Ok(persons) = task.await {
|
if let Ok(persons) = task.await {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.bulk_insert(persons, cx);
|
this.bulk_inserts(persons, cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -231,7 +242,7 @@ impl PersonRegistry {
|
|||||||
|
|
||||||
/// Set messaging relays for a person
|
/// Set messaging relays for a person
|
||||||
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
|
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
|
||||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).collect();
|
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect();
|
||||||
|
|
||||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||||
person.update(cx, |person, cx| {
|
person.update(cx, |person, cx| {
|
||||||
@@ -245,7 +256,7 @@ impl PersonRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Insert batch of persons
|
/// Insert batch of persons
|
||||||
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||||
for person in persons.into_iter() {
|
for person in persons.into_iter() {
|
||||||
let public_key = person.public_key();
|
let public_key = person.public_key();
|
||||||
self.persons
|
self.persons
|
||||||
@@ -279,14 +290,15 @@ impl PersonRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let public_key = *public_key;
|
let public_key = *public_key;
|
||||||
|
let mut seen = self.seens.borrow_mut();
|
||||||
|
|
||||||
if self.seen.write().unwrap().insert(public_key) {
|
if seen.insert(public_key) {
|
||||||
let sender = self.sender.clone();
|
let sender = self.sender.clone();
|
||||||
|
|
||||||
// Spawn background task to request metadata
|
// Spawn background task to request metadata
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
if let Err(e) = sender.send_async(public_key).await {
|
if let Err(e) = sender.send_async(public_key).await {
|
||||||
log::warn!("Failed to send public key for metadata request: {e}");
|
log::warn!("Failed to send public key for metadata request: {}", e);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|||||||
@@ -126,13 +126,13 @@ impl Person {
|
|||||||
if let Some(display_name) = self.metadata().display_name.as_ref()
|
if let Some(display_name) = self.metadata().display_name.as_ref()
|
||||||
&& !display_name.is_empty()
|
&& !display_name.is_empty()
|
||||||
{
|
{
|
||||||
return SharedString::from(display_name.trim());
|
return SharedString::from(display_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(name) = self.metadata().name.as_ref()
|
if let Some(name) = self.metadata().name.as_ref()
|
||||||
&& !name.is_empty()
|
&& !name.is_empty()
|
||||||
{
|
{
|
||||||
return SharedString::from(name.trim());
|
return SharedString::from(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
SharedString::from(shorten_pubkey(self.public_key(), 4))
|
SharedString::from(shorten_pubkey(self.public_key(), 4))
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "workspace"
|
name = "relay_auth"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ui = { path = "../ui" }
|
|
||||||
theme = { path = "../theme" }
|
|
||||||
common = { path = "../common" }
|
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
device = { path = "../device" }
|
|
||||||
chat = { path = "../chat" }
|
|
||||||
chat_ui = { path = "../chat_ui" }
|
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
person = { path = "../person" }
|
common = { path = "../common" }
|
||||||
|
theme = { path = "../theme" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
instant.workspace = true
|
|
||||||
nostr-connect.workspace = true
|
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
serde.workspace = true
|
|
||||||
log.workspace = true
|
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
flume.workspace = true
|
||||||
|
log.workspace = true
|
||||||
377
crates/relay_auth/src/lib.rs
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::hash::Hash;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
|
||||||
|
Task, Window, div, relative,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use settings::{AppSettings, AuthMode};
|
||||||
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use state::NostrRegistry;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::button::Button;
|
||||||
|
use ui::notification::{Notification, NotificationKind};
|
||||||
|
use ui::{Disableable, WindowExtension, v_flex};
|
||||||
|
|
||||||
|
const AUTH_MESSAGE: &str =
|
||||||
|
"Approve the authentication request to allow Coop to continue sending or receiving events.";
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
|
RelayAuth::set_global(cx.new(|cx| RelayAuth::new(window, cx)), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication request
|
||||||
|
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
struct AuthRequest {
|
||||||
|
url: RelayUrl,
|
||||||
|
challenge: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthRequest {
|
||||||
|
pub fn new<S>(challenge: S, url: RelayUrl) -> Self
|
||||||
|
where
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
challenge: challenge.into(),
|
||||||
|
url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn url(&self) -> &RelayUrl {
|
||||||
|
&self.url
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn challenge(&self) -> &str {
|
||||||
|
&self.challenge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
enum Signal {
|
||||||
|
Auth(Arc<AuthRequest>),
|
||||||
|
Pending((EventId, RelayUrl)),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalRelayAuth(Entity<RelayAuth>);
|
||||||
|
|
||||||
|
impl Global for GlobalRelayAuth {}
|
||||||
|
|
||||||
|
// Relay authentication
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RelayAuth {
|
||||||
|
/// Pending events waiting for resend after authentication
|
||||||
|
pending_events: HashSet<(EventId, RelayUrl)>,
|
||||||
|
|
||||||
|
/// Tasks for asynchronous operations
|
||||||
|
_tasks: SmallVec<[Task<()>; 2]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelayAuth {
|
||||||
|
/// Retrieve the global relay auth state
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalRelayAuth>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global relay auth instance
|
||||||
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalRelayAuth(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new relay auth instance
|
||||||
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> 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);
|
||||||
|
|
||||||
|
tasks.push(cx.background_spawn(async move {
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
|
||||||
|
|
||||||
|
while let Some(notification) = notifications.next().await {
|
||||||
|
if let ClientNotification::Message { relay_url, message } = notification {
|
||||||
|
match *message {
|
||||||
|
RelayMessage::Auth { challenge } => {
|
||||||
|
if challenges.insert(challenge.clone()) {
|
||||||
|
let request = Arc::new(AuthRequest::new(challenge, relay_url));
|
||||||
|
let signal = Signal::Auth(request);
|
||||||
|
|
||||||
|
tx.send_async(signal).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelayMessage::Ok {
|
||||||
|
event_id, message, ..
|
||||||
|
} => {
|
||||||
|
let msg = MachineReadablePrefix::parse(&message);
|
||||||
|
|
||||||
|
// Handle authentication messages
|
||||||
|
if let Some(MachineReadablePrefix::AuthRequired) = msg {
|
||||||
|
let signal = Signal::Pending((event_id, relay_url));
|
||||||
|
tx.send_async(signal).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
while let Ok(signal) = rx.recv_async().await {
|
||||||
|
match signal {
|
||||||
|
Signal::Auth(req) => {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.handle_auth(&req, window, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Signal::Pending((event_id, relay_url)) => {
|
||||||
|
this.update_in(cx, |this, _window, cx| {
|
||||||
|
this.insert_pending_event(event_id, relay_url, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
pending_events: HashSet::default(),
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a pending event waiting for resend after authentication
|
||||||
|
fn insert_pending_event(&mut self, id: EventId, relay: RelayUrl, cx: &mut Context<Self>) {
|
||||||
|
self.pending_events.insert((id, relay));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all pending events for a specific relay,
|
||||||
|
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
|
||||||
|
self.pending_events
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, pending_relay)| pending_relay == relay)
|
||||||
|
.map(|(id, _relay)| id)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all pending events for a specific relay,
|
||||||
|
fn clear_pending_events(&mut self, relay: &RelayUrl, cx: &mut Context<Self>) {
|
||||||
|
self.pending_events
|
||||||
|
.retain(|(_, pending_relay)| pending_relay != relay);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle authentication request
|
||||||
|
fn handle_auth(&mut self, req: &Arc<AuthRequest>, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let settings = AppSettings::global(cx);
|
||||||
|
let trusted_relay = settings.read(cx).trusted_relay(req.url(), cx);
|
||||||
|
let mode = AppSettings::get_auth_mode(cx);
|
||||||
|
|
||||||
|
if trusted_relay && mode == AuthMode::Auto {
|
||||||
|
// Automatically authenticate if the relay is authenticated before
|
||||||
|
self.response(req, window, cx);
|
||||||
|
} else {
|
||||||
|
// Otherwise open the auth request popup
|
||||||
|
self.ask_for_approval(req, window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send auth response and wait for confirmation
|
||||||
|
fn auth(&self, req: &Arc<AuthRequest>, cx: &App) -> Task<Result<(), Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let req = req.clone();
|
||||||
|
|
||||||
|
// Get all pending events for the relay
|
||||||
|
let pending_events = self.get_pending_events(req.url(), cx);
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
// Construct event
|
||||||
|
let builder = EventBuilder::auth(req.challenge(), req.url().clone());
|
||||||
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
|
// Get the event ID
|
||||||
|
let id = event.id;
|
||||||
|
|
||||||
|
// Get the relay
|
||||||
|
let relay = client.relay(req.url()).await?.context("Relay not found")?;
|
||||||
|
|
||||||
|
// Subscribe to notifications
|
||||||
|
let mut notifications = relay.notifications();
|
||||||
|
|
||||||
|
// Send the AUTH message
|
||||||
|
relay
|
||||||
|
.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
log::info!("Sending AUTH event");
|
||||||
|
|
||||||
|
while let Some(notification) = notifications.next().await {
|
||||||
|
match notification {
|
||||||
|
RelayNotification::Message { message } => {
|
||||||
|
if let RelayMessage::Ok { event_id, .. } = *message {
|
||||||
|
if id != event_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all subscriptions
|
||||||
|
let subscriptions = relay.subscriptions().await;
|
||||||
|
|
||||||
|
// Re-subscribe to previous subscriptions
|
||||||
|
for (id, filters) in subscriptions.into_iter() {
|
||||||
|
if !filters.is_empty() {
|
||||||
|
relay.send_msg(ClientMessage::req(id, filters)).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-send pending events
|
||||||
|
for id in pending_events {
|
||||||
|
if let Some(event) = client.database().event_by_id(&id).await? {
|
||||||
|
relay.send_event(&event).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelayNotification::AuthenticationFailed => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!("Authentication failed"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Respond to an authentication request.
|
||||||
|
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
||||||
|
let settings = AppSettings::global(cx);
|
||||||
|
let req = req.clone();
|
||||||
|
let challenge = SharedString::from(req.challenge().to_string());
|
||||||
|
|
||||||
|
// Create a task for authentication
|
||||||
|
let task = self.auth(&req, cx);
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let result = task.await;
|
||||||
|
let url = req.url();
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
window.clear_notification_by_id::<AuthNotification>(challenge, cx);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
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(
|
||||||
|
Notification::success(format!(
|
||||||
|
"Relay {} has been authenticated",
|
||||||
|
url.domain().unwrap_or_default()
|
||||||
|
)),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(e.to_string()).autohide(false),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a popup to approve the authentication request.
|
||||||
|
fn ask_for_approval(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
||||||
|
let notification = self.notification(req, 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 authentication request.
|
||||||
|
fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification {
|
||||||
|
let req = req.clone();
|
||||||
|
let challenge = SharedString::from(req.challenge.clone());
|
||||||
|
let url = SharedString::from(req.url().to_string());
|
||||||
|
let entity = cx.entity().downgrade();
|
||||||
|
let loading = Rc::new(Cell::new(false));
|
||||||
|
|
||||||
|
Notification::new()
|
||||||
|
.type_id::<AuthNotification>(challenge)
|
||||||
|
.autohide(false)
|
||||||
|
.with_kind(NotificationKind::Info)
|
||||||
|
.title("Authentication Required")
|
||||||
|
.content(move |_this, _window, cx| {
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.line_height(relative(1.25))
|
||||||
|
.child(SharedString::from(AUTH_MESSAGE)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.py_1()
|
||||||
|
.px_1p5()
|
||||||
|
.rounded_sm()
|
||||||
|
.text_xs()
|
||||||
|
.bg(cx.theme().elevated_surface_background)
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
.child(url.clone()),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.action(move |_this, _window, _cx| {
|
||||||
|
let view = entity.clone();
|
||||||
|
let req = req.clone();
|
||||||
|
|
||||||
|
Button::new("approve")
|
||||||
|
.label("Approve")
|
||||||
|
.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.response(&req, window, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthNotification;
|
||||||
@@ -10,6 +10,7 @@ common = { path = "../common" }
|
|||||||
|
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
@@ -17,6 +18,3 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
||||||
paste = "1.0.15"
|
paste = "1.0.15"
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
|
||||||
smol.workspace = true
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fmt::Display;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
@@ -18,16 +20,14 @@ macro_rules! setting_accessors {
|
|||||||
$(
|
$(
|
||||||
paste::paste! {
|
paste::paste! {
|
||||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
Self::global(cx).read(cx).values.$field.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
||||||
Self::global(cx).update(cx, |this, cx| {
|
Self::global(cx).update(cx, |this, cx| {
|
||||||
this.inner.update(cx, |inner, cx| {
|
this.values.$field = value;
|
||||||
inner.$field = value;
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)*
|
)*
|
||||||
@@ -40,18 +40,36 @@ setting_accessors! {
|
|||||||
pub theme_mode: ThemeMode,
|
pub theme_mode: ThemeMode,
|
||||||
pub hide_avatar: bool,
|
pub hide_avatar: bool,
|
||||||
pub screening: bool,
|
pub screening: bool,
|
||||||
pub nip4e: bool,
|
pub auth_mode: AuthMode,
|
||||||
pub trusted_relays: Vec<String>,
|
pub trusted_relays: HashSet<RelayUrl>,
|
||||||
|
pub room_configs: HashMap<u64, RoomConfig>,
|
||||||
pub file_server: Url,
|
pub file_server: Url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authentication mode
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum AuthMode {
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for AuthMode {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
AuthMode::Auto => write!(f, "Auto"),
|
||||||
|
AuthMode::Manual => write!(f, "Ask every time"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Signer kind
|
/// Signer kind
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum SignerKind {
|
pub enum SignerKind {
|
||||||
Auto,
|
|
||||||
Encryption,
|
|
||||||
#[default]
|
#[default]
|
||||||
|
Auto,
|
||||||
User,
|
User,
|
||||||
|
Encryption,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SignerKind {
|
impl SignerKind {
|
||||||
@@ -79,7 +97,7 @@ impl RoomConfig {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
backup: true,
|
backup: true,
|
||||||
signer_kind: SignerKind::default(),
|
signer_kind: SignerKind::Auto,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +137,14 @@ pub struct Settings {
|
|||||||
/// Enable screening for unknown chat requests
|
/// Enable screening for unknown chat requests
|
||||||
pub screening: bool,
|
pub screening: bool,
|
||||||
|
|
||||||
/// Enable decoupling encryption key
|
/// Authentication mode
|
||||||
pub nip4e: bool,
|
pub auth_mode: AuthMode,
|
||||||
|
|
||||||
/// Trusted relays; Coop will automatically authenticate with these relays
|
/// Trusted relays; Coop will automatically authenticate with these relays
|
||||||
pub trusted_relays: Vec<String>,
|
pub trusted_relays: HashSet<RelayUrl>,
|
||||||
|
|
||||||
|
/// Configuration for each chat room
|
||||||
|
pub room_configs: HashMap<u64, RoomConfig>,
|
||||||
|
|
||||||
/// Server for blossom media attachments
|
/// Server for blossom media attachments
|
||||||
pub file_server: Url,
|
pub file_server: Url,
|
||||||
@@ -136,8 +157,9 @@ impl Default for Settings {
|
|||||||
theme_mode: ThemeMode::default(),
|
theme_mode: ThemeMode::default(),
|
||||||
hide_avatar: false,
|
hide_avatar: false,
|
||||||
screening: true,
|
screening: true,
|
||||||
nip4e: false,
|
auth_mode: AuthMode::default(),
|
||||||
trusted_relays: vec![],
|
trusted_relays: HashSet::default(),
|
||||||
|
room_configs: HashMap::default(),
|
||||||
file_server: Url::parse("https://blossom.band/").unwrap(),
|
file_server: Url::parse("https://blossom.band/").unwrap(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +178,7 @@ impl Global for GlobalAppSettings {}
|
|||||||
/// Application settings
|
/// Application settings
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
/// Settings
|
/// Settings
|
||||||
inner: Entity<Settings>,
|
values: Settings,
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
@@ -174,12 +196,11 @@ impl AppSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let inner = cx.new(|_| Settings::default());
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe and automatically save settings on changes
|
// Observe and automatically save settings on changes
|
||||||
cx.observe(&inner, |this, _inner, cx| {
|
cx.observe_self(|this, cx| {
|
||||||
this.save(cx);
|
this.save(cx);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -190,30 +211,27 @@ impl AppSettings {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
inner,
|
values: Settings::default(),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update settings
|
/// Update settings
|
||||||
fn set_settings(&mut self, settings: Settings, cx: &mut Context<Self>) {
|
fn set_settings(&mut self, settings: Settings, cx: &mut Context<Self>) {
|
||||||
self.inner.update(cx, |this, cx| {
|
self.values = settings;
|
||||||
*this = settings;
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load settings
|
/// Load settings
|
||||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
let path = config_dir().join(".settings");
|
let path = config_dir().join(".settings");
|
||||||
|
|
||||||
if let Ok(content) = smol::fs::read_to_string(&path).await {
|
if let Ok(content) = smol::fs::read_to_string(&path).await {
|
||||||
return Ok(serde_json::from_str(&content)?);
|
Ok(serde_json::from_str(&content)?)
|
||||||
}
|
} else {
|
||||||
}
|
|
||||||
Err(anyhow!("Not found"))
|
Err(anyhow!("Not found"))
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
@@ -231,15 +249,19 @@ impl AppSettings {
|
|||||||
|
|
||||||
/// Save settings
|
/// Save settings
|
||||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||||
let settings = self.inner.read(cx);
|
let settings = self.values.clone();
|
||||||
if let Ok(content) = serde_json::to_string(&settings) {
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
cx.background_spawn(async move {
|
|
||||||
let path = config_dir().join(".settings");
|
let path = config_dir().join(".settings");
|
||||||
smol::fs::write(&path, content).await.ok();
|
let content = serde_json::to_string(&settings)?;
|
||||||
})
|
|
||||||
.detach();
|
// Write settings to file
|
||||||
}
|
smol::fs::write(&path, content).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set theme
|
/// Set theme
|
||||||
@@ -248,10 +270,8 @@ impl AppSettings {
|
|||||||
T: Into<String>,
|
T: Into<String>,
|
||||||
{
|
{
|
||||||
// Update settings
|
// Update settings
|
||||||
self.inner.update(cx, |this, cx| {
|
self.values.theme = Some(theme.into());
|
||||||
this.theme = Some(theme.into());
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
|
||||||
|
|
||||||
// Apply the new theme
|
// Apply the new theme
|
||||||
self.apply_theme(window, cx);
|
self.apply_theme(window, cx);
|
||||||
@@ -259,17 +279,16 @@ impl AppSettings {
|
|||||||
|
|
||||||
/// Reset theme
|
/// Reset theme
|
||||||
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.inner.update(cx, |this, cx| {
|
self.values.theme = None;
|
||||||
this.theme = None;
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
|
||||||
self.apply_theme(window, cx);
|
self.apply_theme(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply theme
|
/// Apply theme
|
||||||
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if let Some(name) = self.inner.read(cx).theme.as_ref() {
|
if let Some(name) = self.values.theme.as_ref() {
|
||||||
let mode = self.inner.read(cx).theme_mode;
|
let mode = self.values.theme_mode;
|
||||||
|
|
||||||
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
|
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
|
||||||
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
|
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
|
||||||
@@ -282,32 +301,26 @@ impl AppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if decoupling encryption key is enabled
|
|
||||||
pub fn is_nip4e_enabled(&self, cx: &App) -> bool {
|
|
||||||
self.inner.read(cx).nip4e
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the given relay is already authenticated
|
/// Check if the given relay is already authenticated
|
||||||
pub fn trusted_relay(&self, url: &RelayUrl, cx: &App) -> bool {
|
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
|
||||||
self.inner
|
self.values.trusted_relays.iter().any(|relay| {
|
||||||
.read(cx)
|
relay.as_str_without_trailing_slash() == url.as_str_without_trailing_slash()
|
||||||
.trusted_relays
|
})
|
||||||
.iter()
|
|
||||||
.any(|relay| relay == url.as_str_without_trailing_slash())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a relay to the trusted list
|
/// Add a relay to the trusted list
|
||||||
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||||
self.inner.update(cx, |this, cx| {
|
self.values.trusted_relays.insert(url.clone());
|
||||||
if !this
|
|
||||||
.trusted_relays
|
|
||||||
.iter()
|
|
||||||
.any(|relay| relay == url.as_str_without_trailing_slash())
|
|
||||||
{
|
|
||||||
this.trusted_relays
|
|
||||||
.push(url.as_str_without_trailing_slash().to_string());
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
/// Add a room configuration
|
||||||
|
pub fn add_room_config(&mut self, id: u64, config: RoomConfig, cx: &mut Context<Self>) {
|
||||||
|
self.values
|
||||||
|
.room_configs
|
||||||
|
.entry(id)
|
||||||
|
.and_modify(|this| *this = config)
|
||||||
|
.or_default();
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,26 +9,22 @@ common = { path = "../common" }
|
|||||||
|
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
nostr-lmdb.workspace = true
|
||||||
nostr-gossip-memory.workspace = true
|
nostr-gossip-memory.workspace = true
|
||||||
nostr-blossom.workspace = true
|
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
|
nostr-blossom.workspace = true
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
instant.workspace = true
|
gpui_tokio.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
futures.workspace = true
|
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
webbrowser.workspace = true
|
webbrowser.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
||||||
mime_guess = "2.0.4"
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
|
||||||
nostr-memory.workspace = true
|
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
|
||||||
nostr-lmdb.workspace = true
|
|
||||||
smol.workspace = true
|
|
||||||
gpui_tokio.workspace = true
|
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
|
petname = "2.0.2"
|
||||||
|
whoami = "1.6.1"
|
||||||
|
mime_guess = "2.0.4"
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{anyhow, Error};
|
||||||
use gpui::AsyncApp;
|
use gpui::AsyncApp;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use mime_guess::from_path;
|
use mime_guess::from_path;
|
||||||
use nostr_blossom::prelude::*;
|
use nostr_blossom::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||||
let data = smol::fs::read(path).await?;
|
let data = smol::fs::read(path).await?;
|
||||||
@@ -27,8 +25,3 @@ pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Er
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
|
||||||
Err(anyhow!("File upload not supported on web"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
/// Client name (Application name)
|
/// Client name (Application name)
|
||||||
pub const CLIENT_NAME: &str = "Coop";
|
pub const CLIENT_NAME: &str = "Coop";
|
||||||
|
|
||||||
@@ -8,15 +10,11 @@ pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5w
|
|||||||
pub const APP_ID: &str = "su.reya.coop";
|
pub const APP_ID: &str = "su.reya.coop";
|
||||||
|
|
||||||
/// Keyring name
|
/// Keyring name
|
||||||
pub const MASTER_KEYRING: &str = "Coop Master Key";
|
pub const KEYRING: &str = "Coop Safe Storage";
|
||||||
pub const USER_KEYRING: &str = "Coop User Credential";
|
|
||||||
|
|
||||||
/// Default timeout for subscription
|
/// Default timeout for subscription
|
||||||
pub const TIMEOUT: u64 = 2;
|
pub const TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
/// Default image cache size
|
|
||||||
pub const IMAGE_CACHE_SIZE: usize = 20;
|
|
||||||
|
|
||||||
/// Default delay for searching
|
/// Default delay for searching
|
||||||
pub const FIND_DELAY: u64 = 600;
|
pub const FIND_DELAY: u64 = 600;
|
||||||
|
|
||||||
@@ -39,15 +37,26 @@ pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
|
|||||||
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||||
|
|
||||||
/// Default search relays
|
/// Default search relays
|
||||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
|
||||||
|
|
||||||
/// Default search relays
|
/// Default search relays
|
||||||
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||||
|
|
||||||
/// Default bootstrap relays
|
/// Default bootstrap relays
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||||
"wss://relay.ditto.pub",
|
"wss://relay.damus.io",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://relay.nostr.net",
|
"wss://user.kindpag.es",
|
||||||
"wss://profiles.nostr1.com",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
static APP_NAME: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Get the app name
|
||||||
|
pub fn app_name() -> &'static String {
|
||||||
|
APP_NAME.get_or_init(|| {
|
||||||
|
let devicename = whoami::devicename();
|
||||||
|
let platform = whoami::platform();
|
||||||
|
|
||||||
|
format!("{CLIENT_NAME} on {platform} ({devicename})")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,15 +16,14 @@ impl From<&Event> for Announcement {
|
|||||||
let public_key = val
|
let public_key = val
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
.find(|tag| tag.kind() == "n")
|
.find(|tag| tag.kind().as_str() == "n")
|
||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
.and_then(|c| PublicKey::parse(c).ok())
|
.and_then(|c| PublicKey::parse(c).ok())
|
||||||
.unwrap_or(val.pubkey);
|
.unwrap_or(val.pubkey);
|
||||||
|
|
||||||
let client_name = val
|
let client_name = val
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.find(TagKind::Client)
|
||||||
.find(|tag| tag.kind() == "client")
|
|
||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
.map(|c| c.to_string());
|
.map(|c| c.to_string());
|
||||||
|
|
||||||
@@ -1,40 +1,37 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Task, Window};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use nostr_gossip_memory::prelude::*;
|
use nostr_gossip_memory::prelude::*;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use nostr_lmdb::prelude::*;
|
use nostr_lmdb::prelude::*;
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use nostr_memory::prelude::*;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
mod blossom;
|
mod blossom;
|
||||||
mod constants;
|
mod constants;
|
||||||
|
mod device;
|
||||||
mod nip05;
|
mod nip05;
|
||||||
mod nip4e;
|
|
||||||
mod signer;
|
mod signer;
|
||||||
|
|
||||||
pub use blossom::*;
|
pub use blossom::*;
|
||||||
pub use constants::*;
|
pub use constants::*;
|
||||||
pub use nip4e::*;
|
pub use device::*;
|
||||||
pub use nip05::*;
|
pub use nip05::*;
|
||||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
pub use signer::*;
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
// rustls uses the `aws_lc_rs` provider by default
|
// rustls uses the `aws_lc_rs` provider by default
|
||||||
// This only errors if the default provider has already
|
// This only errors if the default provider has already
|
||||||
// been installed. We can ignore this `Result`.
|
// been installed. We can ignore this `Result`.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
rustls::crypto::aws_lc_rs::default_provider()
|
rustls::crypto::aws_lc_rs::default_provider()
|
||||||
.install_default()
|
.install_default()
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
// Initialize the tokio runtime
|
// Initialize the tokio runtime
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
gpui_tokio::init(cx);
|
gpui_tokio::init(cx);
|
||||||
|
|
||||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
|
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
|
||||||
@@ -47,27 +44,27 @@ impl Global for GlobalNostrRegistry {}
|
|||||||
/// Signer event.
|
/// Signer event.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum StateEvent {
|
pub enum StateEvent {
|
||||||
/// The state is busy
|
/// Connecting to the bootstrapping relay
|
||||||
Busy,
|
Connecting,
|
||||||
/// User has no signer
|
/// Connected to the bootstrapping relay
|
||||||
NoSigner,
|
Connected,
|
||||||
/// The signer has changed
|
/// Creating the signer
|
||||||
SignerChanged,
|
Creating,
|
||||||
|
/// Show the identity dialog
|
||||||
|
Show,
|
||||||
|
/// A new signer has been set
|
||||||
|
SignerSet,
|
||||||
/// An error occurred
|
/// An error occurred
|
||||||
Error(String),
|
Error(SharedString),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StateEvent {
|
impl StateEvent {
|
||||||
pub fn error<T>(error: T) -> Self
|
pub fn error<T>(error: T) -> Self
|
||||||
where
|
where
|
||||||
T: Into<String>,
|
T: Into<SharedString>,
|
||||||
{
|
{
|
||||||
Self::Error(error.into())
|
Self::Error(error.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn signer_changed(&self) -> bool {
|
|
||||||
matches!(self, StateEvent::SignerChanged)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nostr Registry
|
/// Nostr Registry
|
||||||
@@ -76,11 +73,19 @@ pub struct NostrRegistry {
|
|||||||
/// Nostr client
|
/// Nostr client
|
||||||
client: Client,
|
client: Client,
|
||||||
|
|
||||||
/// Universal signer
|
/// Nostr signer
|
||||||
signer: UniversalSigner,
|
signer: Arc<CoopSigner>,
|
||||||
|
|
||||||
/// Current user's public key
|
/// All local stored identities
|
||||||
current_user: Option<PublicKey>,
|
npubs: Entity<Vec<PublicKey>>,
|
||||||
|
|
||||||
|
/// Keys directory
|
||||||
|
key_dir: PathBuf,
|
||||||
|
|
||||||
|
/// Master app keys used for various operations.
|
||||||
|
///
|
||||||
|
/// Example: Nostr Connect and NIP-4e operations
|
||||||
|
app_keys: Keys,
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
/// Tasks for asynchronous operations
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
@@ -101,47 +106,58 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
/// Create a new nostr instance
|
/// Create a new nostr instance
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let signer = UniversalSigner::new(Keys::generate());
|
let key_dir = config_dir().join("keys");
|
||||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
let app_keys = get_or_init_app_keys(cx).unwrap_or(Keys::generate());
|
||||||
|
|
||||||
|
// Construct the nostr signer
|
||||||
|
let signer = Arc::new(CoopSigner::new(app_keys.clone()));
|
||||||
|
|
||||||
|
// Get all local stored npubs
|
||||||
|
let npubs = cx.new(|_| match Self::discover(&key_dir) {
|
||||||
|
Ok(npubs) => npubs,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to discover npubs: {e}");
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Construct the nostr lmdb instance
|
// Construct the nostr lmdb instance
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
let lmdb = cx.foreground_executor().block_on(async move {
|
||||||
let database = cx.foreground_executor().block_on(async move {
|
|
||||||
NostrLmdb::open(config_dir().join("nostr"))
|
NostrLmdb::open(config_dir().join("nostr"))
|
||||||
.await
|
.await
|
||||||
.expect("Failed to initialize database")
|
.expect("Failed to initialize database")
|
||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
let database = MemoryDatabase::unbounded();
|
|
||||||
|
|
||||||
// Construct the nostr client
|
// Construct the nostr client
|
||||||
let client = ClientBuilder::default()
|
let client = ClientBuilder::default()
|
||||||
.database(database)
|
.signer(signer.clone())
|
||||||
.authenticator(authenticator)
|
.database(lmdb)
|
||||||
.gossip(NostrGossipMemory::unbounded())
|
.gossip(NostrGossipMemory::unbounded())
|
||||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
.automatic_authentication(false)
|
||||||
.connect_timeout(Duration::from_secs(10))
|
.connect_timeout(Duration::from_secs(10))
|
||||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||||
timeout: Duration::from_secs(600),
|
timeout: Duration::from_secs(600),
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Connect to bootstrap relays after the window is ready
|
// Run at the end of current cycle
|
||||||
cx.defer_in(window, |this, _window, cx| {
|
cx.defer_in(window, |this, _window, cx| {
|
||||||
this.connect_bootstrap_relays(cx);
|
this.connect(cx);
|
||||||
|
// Create an identity if none exists
|
||||||
if cfg!(target_arch = "wasm32") {
|
if this.npubs.read(cx).is_empty() {
|
||||||
cx.emit(StateEvent::NoSigner);
|
this.create_identity(cx);
|
||||||
} else {
|
} else {
|
||||||
this.get_user_credential(cx);
|
// Show the identity dialog
|
||||||
|
cx.emit(StateEvent::Show);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
client,
|
client,
|
||||||
signer,
|
signer,
|
||||||
current_user: None,
|
npubs,
|
||||||
|
key_dir,
|
||||||
|
app_keys,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,51 +167,61 @@ impl NostrRegistry {
|
|||||||
self.client.clone()
|
self.client.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current signer
|
/// Get the nostr signer
|
||||||
pub fn signer(&self) -> UniversalSigner {
|
pub fn signer(&self) -> Arc<CoopSigner> {
|
||||||
self.signer.clone()
|
self.signer.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current user's public key
|
/// Get the npubs entity
|
||||||
pub fn current_user(&self) -> Option<PublicKey> {
|
pub fn npubs(&self) -> Entity<Vec<PublicKey>> {
|
||||||
self.current_user
|
self.npubs.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the signer
|
/// Get the app keys
|
||||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
pub fn keys(&self) -> Keys {
|
||||||
where
|
self.app_keys.clone()
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
|
||||||
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
|
||||||
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
|
|
||||||
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
let task = cx.spawn(async move |this, cx| {
|
|
||||||
match new_signer.get_public_key_async().await {
|
|
||||||
Ok(public_key) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.signer.swap_inner(new_signer);
|
|
||||||
this.current_user = Some(public_key);
|
|
||||||
cx.emit(StateEvent::SignerChanged);
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
this.update(cx, |_this, cx| {
|
|
||||||
cx.emit(StateEvent::error(e.to_string()));
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(())
|
/// Discover all npubs in the keys directory
|
||||||
});
|
fn discover(dir: &PathBuf) -> Result<Vec<PublicKey>, Error> {
|
||||||
self.tasks.push(task);
|
// Ensure keys directory exists
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
|
||||||
|
let files = std::fs::read_dir(dir)?;
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut npubs: Vec<PublicKey> = Vec::new();
|
||||||
|
|
||||||
|
for file in files.flatten() {
|
||||||
|
let metadata = file.metadata()?;
|
||||||
|
let modified_time = metadata.modified()?;
|
||||||
|
let name = file.file_name().into_string().unwrap().replace(".npub", "");
|
||||||
|
entries.push((modified_time, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by modification time (most recent first)
|
||||||
|
entries.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
|
||||||
|
for (_, name) in entries {
|
||||||
|
let public_key = PublicKey::parse(&name)?;
|
||||||
|
npubs.push(public_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(npubs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to the bootstrapping relays
|
/// Connect to the bootstrapping relays
|
||||||
fn connect_bootstrap_relays(&mut self, cx: &mut Context<Self>) {
|
fn connect(&mut self, cx: &mut Context<Self>) {
|
||||||
let client = self.client();
|
let client = self.client();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
|
// Add search relay to the relay pool
|
||||||
|
for url in SEARCH_RELAYS.into_iter() {
|
||||||
|
client
|
||||||
|
.add_relay(url)
|
||||||
|
.capabilities(RelayCapabilities::READ)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
// Add indexer relay to the relay pool
|
// Add indexer relay to the relay pool
|
||||||
for url in INDEXER_RELAYS.into_iter() {
|
for url in INDEXER_RELAYS.into_iter() {
|
||||||
client
|
client
|
||||||
@@ -210,101 +236,345 @@ impl NostrRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Connect to all added relays
|
// Connect to all added relays
|
||||||
client.connect().await;
|
client
|
||||||
|
.connect()
|
||||||
|
.and_wait(Duration::from_secs(TIMEOUT))
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emit connecting event
|
||||||
|
cx.emit(StateEvent::Connecting);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
if let Err(e) = task.await {
|
if let Err(e) = task.await {
|
||||||
this.update(cx, |_this, cx| {
|
this.update(cx, |_this, cx| {
|
||||||
cx.emit(StateEvent::error(e.to_string()));
|
cx.emit(StateEvent::error(e.to_string()));
|
||||||
})?;
|
})?;
|
||||||
}
|
} else {
|
||||||
Ok(())
|
this.update(cx, |_this, cx| {
|
||||||
}));
|
cx.emit(StateEvent::Connected);
|
||||||
}
|
|
||||||
|
|
||||||
/// Check the user's credential and set the signer if valid
|
|
||||||
fn get_user_credential(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
|
||||||
let master_keyring = self.get_master_key(cx);
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
||||||
match user_keyring.await {
|
|
||||||
Ok(Some((_username, secret))) => {
|
|
||||||
let content = String::from_utf8(secret)?;
|
|
||||||
|
|
||||||
if content.starts_with("nsec1") {
|
|
||||||
let secret_key = SecretKey::parse(&content)?;
|
|
||||||
let keys = Keys::new(secret_key);
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_signer(keys, cx);
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
})?;
|
||||||
} else if content.starts_with("bunker://") {
|
|
||||||
let keys = master_keyring.await;
|
|
||||||
let timeout = Duration::from_secs(30);
|
|
||||||
let uri = NostrConnectUri::parse(content)?;
|
|
||||||
|
|
||||||
// Construct the nostr connect signer
|
|
||||||
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_signer(signer, cx);
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
this.update(cx, |_, cx| {
|
|
||||||
cx.emit(StateEvent::NoSigner);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the master key that used for Nostr Connect
|
/// Get the secret for a given npub.
|
||||||
pub fn get_master_key(&self, cx: &App) -> Task<Keys> {
|
pub fn get_secret(
|
||||||
if cfg!(target_arch = "wasm32") {
|
&self,
|
||||||
return cx.background_spawn(async move { Keys::generate() });
|
public_key: PublicKey,
|
||||||
|
cx: &App,
|
||||||
|
) -> Task<Result<Arc<dyn NostrSigner>, Error>> {
|
||||||
|
let npub = public_key.to_bech32().unwrap();
|
||||||
|
let key_path = self.key_dir.join(format!("{}.npub", npub));
|
||||||
|
let app_keys = self.app_keys.clone();
|
||||||
|
|
||||||
|
if let Ok(payload) = std::fs::read_to_string(key_path) {
|
||||||
|
if !payload.is_empty() {
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let decrypted = app_keys.nip44_decrypt(&public_key, &payload).await?;
|
||||||
|
let secret = SecretKey::parse(&decrypted)?;
|
||||||
|
let keys = Keys::new(secret);
|
||||||
|
|
||||||
|
Ok(keys.into_nostr_signer())
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
self.get_secret_keyring(&npub, cx)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.get_secret_keyring(&npub, cx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = cx.read_credentials(MASTER_KEYRING);
|
/// Get the secret for a given npub in the OS credentials store.
|
||||||
|
#[deprecated = "Use get_secret instead"]
|
||||||
|
fn get_secret_keyring(
|
||||||
|
&self,
|
||||||
|
user: &str,
|
||||||
|
cx: &App,
|
||||||
|
) -> Task<Result<Arc<dyn NostrSigner>, Error>> {
|
||||||
|
let read = cx.read_credentials(user);
|
||||||
|
let app_keys = self.app_keys.clone();
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
cx.background_spawn(async move {
|
||||||
let (keys, new_key) = match task.await {
|
let (_, secret) = read
|
||||||
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
|
.await
|
||||||
Ok(secret_key) => (Keys::new(secret_key), false),
|
.map_err(|_| anyhow!("Failed to get signer. Please re-import the secret key"))?
|
||||||
_ => (Keys::generate(), true),
|
.ok_or_else(|| anyhow!("Failed to get signer. Please re-import the secret key"))?;
|
||||||
},
|
|
||||||
_ => (Keys::generate(), true),
|
|
||||||
};
|
|
||||||
|
|
||||||
if new_key {
|
// Try to parse as a direct secret key first
|
||||||
let keys_clone = keys.clone();
|
if let Ok(secret_key) = SecretKey::from_slice(&secret) {
|
||||||
let username = keys_clone.public_key().to_hex();
|
return Ok(Keys::new(secret_key).into_nostr_signer());
|
||||||
let password = keys_clone.secret_key().to_secret_bytes();
|
|
||||||
|
|
||||||
cx.update(|cx| {
|
|
||||||
let task = cx.write_credentials(MASTER_KEYRING, &username, &password);
|
|
||||||
cx.background_spawn(async move { task.await.ok() }).detach();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keys
|
// 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())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a new npub to the keys directory
|
||||||
|
fn write_secret(
|
||||||
|
&self,
|
||||||
|
public_key: PublicKey,
|
||||||
|
secret: String,
|
||||||
|
cx: &App,
|
||||||
|
) -> Task<Result<(), Error>> {
|
||||||
|
let npub = public_key.to_bech32().unwrap();
|
||||||
|
let key_path = self.key_dir.join(format!("{}.npub", npub));
|
||||||
|
let app_keys = self.app_keys.clone();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
// If the secret starts with "bunker://" (nostr connect), use it directly; otherwise, encrypt it
|
||||||
|
let content = if secret.starts_with("bunker://") {
|
||||||
|
secret
|
||||||
|
} else {
|
||||||
|
app_keys.nip44_encrypt(&public_key, &secret).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write the encrypted secret to the keys directory
|
||||||
|
smol::fs::write(key_path, &content).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a secret
|
||||||
|
pub fn remove_secret(&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");
|
||||||
|
let key_path = keys_dir.join(format!("{}.npub", npub));
|
||||||
|
|
||||||
|
// Remove the secret file from the keys directory
|
||||||
|
std::fs::remove_file(key_path).ok();
|
||||||
|
|
||||||
|
self.npubs.update(cx, |this, cx| {
|
||||||
|
this.retain(|k| k != &public_key);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new identity
|
||||||
|
pub fn create_identity(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let client = self.client();
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let async_keys = keys.clone();
|
||||||
|
|
||||||
|
// Emit creating event
|
||||||
|
cx.emit(StateEvent::Creating);
|
||||||
|
|
||||||
|
// Create the write secret task
|
||||||
|
let write_secret =
|
||||||
|
self.write_secret(keys.public_key(), keys.secret_key().to_secret_hex(), cx);
|
||||||
|
|
||||||
|
// Run async tasks in background
|
||||||
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
|
let signer = async_keys.into_nostr_signer();
|
||||||
|
|
||||||
|
// Construct relay list event
|
||||||
|
let relay_list = default_relay_list();
|
||||||
|
let event = EventBuilder::relay_list(relay_list).sign(&signer).await?;
|
||||||
|
|
||||||
|
// Publish relay list
|
||||||
|
client
|
||||||
|
.send_event(&event)
|
||||||
|
.to(BOOTSTRAP_RELAYS)
|
||||||
|
.ack_policy(AckPolicy::none())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
let metadata = Metadata::new().display_name(&name).picture(avatar);
|
||||||
|
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
|
||||||
|
|
||||||
|
// Publish metadata event
|
||||||
|
client
|
||||||
|
.send_event(&event)
|
||||||
|
.to_nip65()
|
||||||
|
.ack_policy(AckPolicy::none())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Construct the default contact list
|
||||||
|
let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())];
|
||||||
|
let event = EventBuilder::contact_list(contacts).sign(&signer).await?;
|
||||||
|
|
||||||
|
// Publish contact list event
|
||||||
|
client
|
||||||
|
.send_event(&event)
|
||||||
|
.to_nip65()
|
||||||
|
.ack_policy(AckPolicy::none())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Construct the default messaging relay list
|
||||||
|
let relays = default_messaging_relays();
|
||||||
|
let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?;
|
||||||
|
|
||||||
|
// Publish messaging relay list event
|
||||||
|
client.send_event(&event).to_nip65().await?;
|
||||||
|
|
||||||
|
// Write user's credentials to the system keyring
|
||||||
|
write_secret.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(_) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(StateEvent::error(e.to_string()));
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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?;
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit signer changed event
|
||||||
|
cx.emit(StateEvent::SignerSet);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(StateEvent::error(e.to_string()));
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 write_secret =
|
||||||
|
self.write_secret(keys.public_key(), keys.secret_key().to_secret_hex(), cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
match write_secret.await {
|
||||||
|
Ok(_) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(StateEvent::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)) => {
|
||||||
|
// Create the write secret task
|
||||||
|
let write_secret = this.read_with(cx, |this, cx| {
|
||||||
|
this.write_secret(public_key, uri.to_string(), cx)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match write_secret.await {
|
||||||
|
Ok(_) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_signer(nip46, cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(StateEvent::error(e.to_string()));
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(StateEvent::error(e.to_string()));
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the public key of a NIP-05 address
|
/// Get the public key of a NIP-05 address
|
||||||
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
pub fn get_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
||||||
let client = self.client();
|
let client = self.client();
|
||||||
let http_client = cx.http_client();
|
let http_client = cx.http_client();
|
||||||
|
|
||||||
@@ -341,7 +611,7 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
// Get the address task if the query is a valid NIP-05 address
|
// Get the address task if the query is a valid NIP-05 address
|
||||||
let address_task = if let Ok(addr) = Nip05Address::parse(&query) {
|
let address_task = if let Ok(addr) = Nip05Address::parse(&query) {
|
||||||
Some(self.query_address(addr, cx))
|
Some(self.get_address(addr, cx))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -357,18 +627,6 @@ impl NostrRegistry {
|
|||||||
return Ok(results);
|
return Ok(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add search relay to the relay pool
|
|
||||||
for url in SEARCH_RELAYS.into_iter() {
|
|
||||||
if client.relay(url).await.is_ok() {
|
|
||||||
client
|
|
||||||
.add_relay(url)
|
|
||||||
.capabilities(RelayCapabilities::READ)
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
return Err(anyhow!("Failed to add search relay: {}", url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return early if the query is a valid public key
|
// Return early if the query is a valid public key
|
||||||
if let Ok(public_key) = PublicKey::parse(&query) {
|
if let Ok(public_key) = PublicKey::parse(&query) {
|
||||||
results.push(public_key);
|
results.push(public_key);
|
||||||
@@ -412,17 +670,14 @@ impl NostrRegistry {
|
|||||||
pub fn wot_search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
|
pub fn wot_search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
|
||||||
let client = self.client();
|
let client = self.client();
|
||||||
let query = query.to_string();
|
let query = query.to_string();
|
||||||
let signer = self.signer.clone();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
// Construct a vertex request event
|
// Construct a vertex request event
|
||||||
let event = EventBuilder::new(Kind::Custom(5315), "")
|
let builder = EventBuilder::new(Kind::Custom(5315), "").tags(vec![
|
||||||
.tags(vec![
|
Tag::custom(TagKind::custom("param"), vec!["search", &query]),
|
||||||
Tag::custom("param", vec!["search", &query]),
|
Tag::custom(TagKind::custom("param"), vec!["limit", "10"]),
|
||||||
Tag::custom("param", vec!["limit", "10"]),
|
]);
|
||||||
])
|
let event = client.sign_event_builder(builder).await?;
|
||||||
.finalize_async(&signer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Send the event to vertex relays
|
// Send the event to vertex relays
|
||||||
let output = client.send_event(&event).to(WOT_RELAYS).await?;
|
let output = client.send_event(&event).to(WOT_RELAYS).await?;
|
||||||
@@ -472,3 +727,78 @@ impl NostrRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get or create new app keys
|
||||||
|
fn get_or_init_app_keys(cx: &App) -> Result<Keys, Error> {
|
||||||
|
let read = cx.read_credentials(CLIENT_NAME);
|
||||||
|
let stored_keys: Option<Keys> = cx.foreground_executor().block_on(async move {
|
||||||
|
if let Ok(Some((_, secret))) = read.await {
|
||||||
|
SecretKey::from_slice(&secret).map(Keys::new).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(keys) = stored_keys {
|
||||||
|
Ok(keys)
|
||||||
|
} else {
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let user = keys.public_key().to_hex();
|
||||||
|
let secret = keys.secret_key().to_secret_bytes();
|
||||||
|
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
|
||||||
|
|
||||||
|
cx.foreground_executor().block_on(async move {
|
||||||
|
if let Err(e) = write.await {
|
||||||
|
log::error!("Keyring not available or panic: {e}")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
RelayUrl::parse("wss://relay.nostr.net").unwrap(),
|
||||||
|
Some(RelayMetadata::Write),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RelayUrl::parse("wss://relay.primal.net").unwrap(),
|
||||||
|
Some(RelayMetadata::Write),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RelayUrl::parse("wss://relay.damus.io").unwrap(),
|
||||||
|
Some(RelayMetadata::Read),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RelayUrl::parse("wss://nos.lol").unwrap(),
|
||||||
|
Some(RelayMetadata::Read),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RelayUrl::parse("wss://nostr.superfriends.online").unwrap(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_messaging_relays() -> Vec<RelayUrl> {
|
||||||
|
vec![
|
||||||
|
RelayUrl::parse("wss://nos.lol").unwrap(),
|
||||||
|
RelayUrl::parse("wss://nip17.com").unwrap(),
|
||||||
|
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CoopAuthUrlHandler;
|
||||||
|
|
||||||
|
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||||
|
#[allow(mismatched_lifetime_syntaxes)]
|
||||||
|
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
webbrowser::open(auth_url.as_str())?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use futures::io::AsyncReadExt;
|
|
||||||
use gpui::http_client::{AsyncBody, HttpClient};
|
use gpui::http_client::{AsyncBody, HttpClient};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use smol::io::AsyncReadExt;
|
||||||
|
|
||||||
#[allow(async_fn_in_trait)]
|
#[allow(async_fn_in_trait)]
|
||||||
pub trait NostrAddress {
|
pub trait NostrAddress {
|
||||||
|
|||||||
@@ -1,206 +1,134 @@
|
|||||||
use std::error::Error;
|
use std::borrow::Cow;
|
||||||
use std::fmt;
|
use std::result::Result;
|
||||||
use std::future::Future;
|
use std::sync::Arc;
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::{Arc, RwLock};
|
|
||||||
|
|
||||||
use nostr_connect::client::AuthUrlHandler;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use smol::lock::RwLock;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
pub struct CoopSigner {
|
||||||
|
/// User's signer
|
||||||
|
signer: RwLock<Arc<dyn NostrSigner>>,
|
||||||
|
|
||||||
impl fmt::Display for UniversalSignerError {
|
/// User's signer public key
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
signer_pkey: RwLock<Option<PublicKey>>,
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
/// Specific signer for encryption purposes
|
||||||
|
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Error for UniversalSignerError {
|
impl CoopSigner {
|
||||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
|
||||||
Some(&*self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UniversalSignerError {
|
|
||||||
pub fn new<E>(err: E) -> Self
|
|
||||||
where
|
|
||||||
E: Error + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
UniversalSignerError(Box::new(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct UniversalSigner {
|
|
||||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UniversalSigner {
|
|
||||||
pub fn new<T>(signer: T) -> Self
|
pub fn new<T>(signer: T) -> Self
|
||||||
where
|
where
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
T: IntoNostrSigner,
|
||||||
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
|
||||||
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
|
||||||
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
|
||||||
{
|
{
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(RwLock::new(Arc::new(InnerSignerImpl(signer)))),
|
signer: RwLock::new(signer.into_nostr_signer()),
|
||||||
|
signer_pkey: RwLock::new(None),
|
||||||
|
encryption_signer: RwLock::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Swap the inner signer in-place. All clones see the new signer.
|
/// Get the current signer.
|
||||||
pub fn swap_inner<T>(&self, new_signer: T)
|
pub async fn get(&self) -> Arc<dyn NostrSigner> {
|
||||||
|
self.signer.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the encryption signer.
|
||||||
|
pub async fn get_encryption_signer(&self) -> Option<Arc<dyn NostrSigner>> {
|
||||||
|
self.encryption_signer.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get public key
|
||||||
|
///
|
||||||
|
/// Ensure to call this method after the signer has been initialized.
|
||||||
|
/// Otherwise, it will panic.
|
||||||
|
pub fn public_key(&self) -> Option<PublicKey> {
|
||||||
|
*self.signer_pkey.read_blocking()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch the current signer to a new signer.
|
||||||
|
pub async fn switch<T>(&self, new: T)
|
||||||
where
|
where
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
T: IntoNostrSigner,
|
||||||
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
|
||||||
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
|
||||||
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
|
||||||
{
|
{
|
||||||
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
|
let new_signer = new.into_nostr_signer();
|
||||||
|
let public_key = new_signer.get_public_key().await.ok();
|
||||||
|
let mut signer = self.signer.write().await;
|
||||||
|
let mut signer_pkey = self.signer_pkey.write().await;
|
||||||
|
let mut encryption_signer = self.encryption_signer.write().await;
|
||||||
|
|
||||||
|
// Switch to the new signer
|
||||||
|
*signer = new_signer;
|
||||||
|
|
||||||
|
// Update the public key
|
||||||
|
*signer_pkey = public_key;
|
||||||
|
|
||||||
|
// Reset the encryption signer
|
||||||
|
*encryption_signer = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the encryption signer.
|
||||||
|
pub async fn set_encryption_signer<T>(&self, new: T)
|
||||||
|
where
|
||||||
|
T: IntoNostrSigner,
|
||||||
|
{
|
||||||
|
let mut encryption_signer = self.encryption_signer.write().await;
|
||||||
|
*encryption_signer = Some(new.into_nostr_signer());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
impl NostrSigner for CoopSigner {
|
||||||
fn get_public_key_async(
|
#[allow(mismatched_lifetime_syntaxes)]
|
||||||
&self,
|
fn backend(&self) -> SignerBackend {
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
SignerBackend::Custom(Cow::Borrowed("custom"))
|
||||||
fn sign_event_async(
|
}
|
||||||
&self,
|
|
||||||
|
fn get_public_key<'a>(&'a self) -> BoxedFuture<'a, Result<PublicKey, SignerError>> {
|
||||||
|
Box::pin(async move { self.get().await.get_public_key().await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_event<'a>(
|
||||||
|
&'a self,
|
||||||
unsigned: UnsignedEvent,
|
unsigned: UnsignedEvent,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
) -> BoxedFuture<'a, Result<Event, SignerError>> {
|
||||||
fn nip44_encrypt_async<'a>(
|
Box::pin(async move { self.get().await.sign_event(unsigned).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip04_encrypt<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
||||||
fn nip44_decrypt_async<'a>(
|
Box::pin(async move { self.get().await.nip04_encrypt(public_key, content).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip04_decrypt<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
encrypted_content: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct InnerSignerImpl<T>(T);
|
|
||||||
|
|
||||||
impl<T> InnerSigner for InnerSignerImpl<T>
|
|
||||||
where
|
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
|
|
||||||
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
|
||||||
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
|
||||||
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
fn get_public_key_async(
|
|
||||||
&self,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
self.get()
|
||||||
|
.await
|
||||||
|
.nip04_decrypt(public_key, encrypted_content)
|
||||||
.await
|
.await
|
||||||
.map_err(UniversalSignerError::new)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sign_event_async(
|
fn nip44_encrypt<'a>(
|
||||||
&self,
|
|
||||||
unsigned: UnsignedEvent,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
|
||||||
.await
|
|
||||||
.map_err(UniversalSignerError::new)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nip44_encrypt_async<'a>(
|
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move { self.get().await.nip44_encrypt(public_key, content).await })
|
||||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
|
||||||
.await
|
|
||||||
.map_err(UniversalSignerError::new)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nip44_decrypt_async<'a>(
|
fn nip44_decrypt<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
payload: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move { self.get().await.nip44_decrypt(public_key, payload).await })
|
||||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
|
||||||
.await
|
|
||||||
.map_err(UniversalSignerError::new)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UniversalSigner {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn with_inner<R>(&self, f: impl FnOnce(&dyn InnerSigner) -> R) -> R {
|
|
||||||
let guard = self.inner.read().expect("RwLock poisoned");
|
|
||||||
f(&**guard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncGetPublicKey for UniversalSigner {
|
|
||||||
type Error = UniversalSignerError;
|
|
||||||
|
|
||||||
fn get_public_key_async(
|
|
||||||
&self,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
|
|
||||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
|
||||||
Box::pin(async move { inner.get_public_key_async().await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncSignEvent for UniversalSigner {
|
|
||||||
type Error = UniversalSignerError;
|
|
||||||
|
|
||||||
fn sign_event_async(
|
|
||||||
&self,
|
|
||||||
unsigned: UnsignedEvent,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
|
|
||||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
|
||||||
Box::pin(async move { inner.sign_event_async(unsigned).await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncNip44 for UniversalSigner {
|
|
||||||
type Error = UniversalSignerError;
|
|
||||||
|
|
||||||
fn nip44_encrypt_async<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
content: &'a str,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
|
||||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
|
||||||
Box::pin(async move { inner.nip44_encrypt_async(public_key, content).await })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nip44_decrypt_async<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
payload: &'a str,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
|
||||||
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
|
||||||
Box::pin(async move { inner.nip44_decrypt_async(public_key, payload).await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CoopAuthUrlHandler;
|
|
||||||
|
|
||||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
|
||||||
fn on_auth_url(
|
|
||||||
&self,
|
|
||||||
auth_url: Url,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
webbrowser::open(auth_url.as_str()).unwrap();
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ impl ThemeColors {
|
|||||||
elevated_surface_background: neutral().light().step_3(),
|
elevated_surface_background: neutral().light().step_3(),
|
||||||
panel_background: neutral().light().step_1(),
|
panel_background: neutral().light().step_1(),
|
||||||
overlay: neutral().light_alpha().step_3(),
|
overlay: neutral().light_alpha().step_3(),
|
||||||
title_bar: neutral().light().step_2(),
|
title_bar: neutral().light().step_3(),
|
||||||
title_bar_inactive: neutral().light().step_1(),
|
title_bar_inactive: neutral().light().step_1(),
|
||||||
window_border: hsl(240.0, 5.9, 78.0),
|
window_border: hsl(240.0, 5.9, 78.0),
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ impl ThemeColors {
|
|||||||
elevated_surface_background: neutral().dark().step_3(),
|
elevated_surface_background: neutral().dark().step_3(),
|
||||||
panel_background: neutral().dark().step_1(),
|
panel_background: neutral().dark().step_1(),
|
||||||
overlay: neutral().dark_alpha().step_3(),
|
overlay: neutral().dark_alpha().step_3(),
|
||||||
title_bar: neutral().dark().step_2(),
|
title_bar: neutral().dark().step_3(),
|
||||||
title_bar_inactive: neutral().dark().step_1(),
|
title_bar_inactive: neutral().dark().step_1(),
|
||||||
window_border: hsl(240.0, 3.7, 28.0),
|
window_border: hsl(240.0, 3.7, 28.0),
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::fmt::{self, Debug, Display, Formatter};
|
use std::fmt::{self, Debug, Display, Formatter};
|
||||||
|
|
||||||
use gpui::{AbsoluteLength, Axis, Length, Pixels};
|
use gpui::{AbsoluteLength, Axis, Corner, Length, Pixels};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A enum for defining the placement of the element.
|
/// A enum for defining the placement of the element.
|
||||||
@@ -49,6 +49,141 @@ impl Placement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The anchor position of an element.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum Anchor {
|
||||||
|
#[default]
|
||||||
|
#[serde(rename = "top-left")]
|
||||||
|
TopLeft,
|
||||||
|
#[serde(rename = "top-center")]
|
||||||
|
TopCenter,
|
||||||
|
#[serde(rename = "top-right")]
|
||||||
|
TopRight,
|
||||||
|
#[serde(rename = "bottom-left")]
|
||||||
|
BottomLeft,
|
||||||
|
#[serde(rename = "bottom-center")]
|
||||||
|
BottomCenter,
|
||||||
|
#[serde(rename = "bottom-right")]
|
||||||
|
BottomRight,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Anchor {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Anchor::TopLeft => write!(f, "TopLeft"),
|
||||||
|
Anchor::TopCenter => write!(f, "TopCenter"),
|
||||||
|
Anchor::TopRight => write!(f, "TopRight"),
|
||||||
|
Anchor::BottomLeft => write!(f, "BottomLeft"),
|
||||||
|
Anchor::BottomCenter => write!(f, "BottomCenter"),
|
||||||
|
Anchor::BottomRight => write!(f, "BottomRight"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Anchor {
|
||||||
|
/// Returns true if the anchor is at the top.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_top(&self) -> bool {
|
||||||
|
matches!(self, Self::TopLeft | Self::TopCenter | Self::TopRight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the anchor is at the bottom.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_bottom(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::BottomLeft | Self::BottomCenter | Self::BottomRight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the anchor is at the left.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_left(&self) -> bool {
|
||||||
|
matches!(self, Self::TopLeft | Self::BottomLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the anchor is at the right.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_right(&self) -> bool {
|
||||||
|
matches!(self, Self::TopRight | Self::BottomRight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the anchor is at the center.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_center(&self) -> bool {
|
||||||
|
matches!(self, Self::TopCenter | Self::BottomCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swaps the vertical position of the anchor.
|
||||||
|
pub fn swap_vertical(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Anchor::TopLeft => Anchor::BottomLeft,
|
||||||
|
Anchor::TopCenter => Anchor::BottomCenter,
|
||||||
|
Anchor::TopRight => Anchor::BottomRight,
|
||||||
|
Anchor::BottomLeft => Anchor::TopLeft,
|
||||||
|
Anchor::BottomCenter => Anchor::TopCenter,
|
||||||
|
Anchor::BottomRight => Anchor::TopRight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swaps the horizontal position of the anchor.
|
||||||
|
pub fn swap_horizontal(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Anchor::TopLeft => Anchor::TopRight,
|
||||||
|
Anchor::TopCenter => Anchor::TopCenter,
|
||||||
|
Anchor::TopRight => Anchor::TopLeft,
|
||||||
|
Anchor::BottomLeft => Anchor::BottomRight,
|
||||||
|
Anchor::BottomCenter => Anchor::BottomCenter,
|
||||||
|
Anchor::BottomRight => Anchor::BottomLeft,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn other_side_corner_along(&self, axis: Axis) -> Anchor {
|
||||||
|
match axis {
|
||||||
|
Axis::Vertical => match self {
|
||||||
|
Self::TopLeft => Self::BottomLeft,
|
||||||
|
Self::TopCenter => Self::BottomCenter,
|
||||||
|
Self::TopRight => Self::BottomRight,
|
||||||
|
Self::BottomLeft => Self::TopLeft,
|
||||||
|
Self::BottomCenter => Self::TopCenter,
|
||||||
|
Self::BottomRight => Self::TopRight,
|
||||||
|
},
|
||||||
|
Axis::Horizontal => match self {
|
||||||
|
Self::TopLeft => Self::TopRight,
|
||||||
|
Self::TopCenter => Self::TopCenter,
|
||||||
|
Self::TopRight => Self::TopLeft,
|
||||||
|
Self::BottomLeft => Self::BottomRight,
|
||||||
|
Self::BottomCenter => Self::BottomCenter,
|
||||||
|
Self::BottomRight => Self::BottomLeft,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Corner> for Anchor {
|
||||||
|
fn from(corner: Corner) -> Self {
|
||||||
|
match corner {
|
||||||
|
Corner::TopLeft => Anchor::TopLeft,
|
||||||
|
Corner::TopRight => Anchor::TopRight,
|
||||||
|
Corner::BottomLeft => Anchor::BottomLeft,
|
||||||
|
Corner::BottomRight => Anchor::BottomRight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Anchor> for Corner {
|
||||||
|
fn from(anchor: Anchor) -> Self {
|
||||||
|
match anchor {
|
||||||
|
Anchor::TopLeft => Corner::TopLeft,
|
||||||
|
Anchor::TopRight => Corner::TopRight,
|
||||||
|
Anchor::BottomLeft => Corner::BottomLeft,
|
||||||
|
Anchor::BottomRight => Corner::BottomRight,
|
||||||
|
Anchor::TopCenter => Corner::TopLeft,
|
||||||
|
Anchor::BottomCenter => Corner::BottomLeft,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A enum for defining the side of the element.
|
/// A enum for defining the side of the element.
|
||||||
///
|
///
|
||||||
/// See also: [`Placement`] if you need to define the 4 edges.
|
/// See also: [`Placement`] if you need to define the 4 edges.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
|
|||||||
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
|
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
|
||||||
|
|
||||||
/// Defines workspace tabbar height
|
/// Defines workspace tabbar height
|
||||||
pub const TABBAR_HEIGHT: Pixels = px(44.0);
|
pub const TABBAR_HEIGHT: Pixels = px(28.0);
|
||||||
|
|
||||||
/// Defines default sidebar width
|
/// Defines default sidebar width
|
||||||
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
||||||
@@ -192,6 +192,7 @@ impl From<ThemeFamily> for Theme {
|
|||||||
let mode = ThemeMode::default();
|
let mode = ThemeMode::default();
|
||||||
|
|
||||||
// Define the font family based on the platform.
|
// Define the font family based on the platform.
|
||||||
|
// TODO: Use native fonts on Linux too.
|
||||||
let font_family = match platform {
|
let font_family = match platform {
|
||||||
PlatformKind::Linux => "Inter",
|
PlatformKind::Linux => "Inter",
|
||||||
_ => ".SystemUIFont",
|
_ => ".SystemUIFont",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use gpui::{Anchor, Pixels, px};
|
use gpui::{Pixels, px};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{Edges, TITLEBAR_HEIGHT};
|
use crate::{Anchor, Edges, TITLEBAR_HEIGHT};
|
||||||
|
|
||||||
/// The settings for notifications.
|
/// The settings for notifications.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct NotificationSettings {
|
pub struct NotificationSettings {
|
||||||
/// The placement of the notification, default: [`Anchor::TopRight`]
|
/// The placement of the notification, default: [`Anchor::TopRight`]
|
||||||
pub placement: Anchor,
|
pub placement: Anchor,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ pub enum PlatformKind {
|
|||||||
Mac,
|
Mac,
|
||||||
Linux,
|
Linux,
|
||||||
Windows,
|
Windows,
|
||||||
Web,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlatformKind {
|
impl PlatformKind {
|
||||||
@@ -12,21 +11,22 @@ impl PlatformKind {
|
|||||||
Self::Linux
|
Self::Linux
|
||||||
} else if cfg!(target_os = "windows") {
|
} else if cfg!(target_os = "windows") {
|
||||||
Self::Windows
|
Self::Windows
|
||||||
} else if cfg!(target_os = "macos") {
|
|
||||||
Self::Mac
|
|
||||||
} else {
|
} else {
|
||||||
Self::Web
|
Self::Mac
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn is_linux(&self) -> bool {
|
pub fn is_linux(&self) -> bool {
|
||||||
matches!(self, Self::Linux)
|
matches!(self, Self::Linux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn is_windows(&self) -> bool {
|
pub fn is_windows(&self) -> bool {
|
||||||
matches!(self, Self::Windows)
|
matches!(self, Self::Windows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn is_mac(&self) -> bool {
|
pub fn is_mac(&self) -> bool {
|
||||||
matches!(self, Self::Mac)
|
matches!(self, Self::Mac)
|
||||||
}
|
}
|
||||||
|
|||||||
23
crates/title_bar/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "title_bar"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
theme = { path = "../theme" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
gpui.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows = { version = "0.61", features = ["Wdk_System_SystemServices"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
linicon = "2.3.0"
|
||||||
172
crates/title_bar/src/lib.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use gpui::MouseButton;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement,
|
||||||
|
Pixels, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, px,
|
||||||
|
};
|
||||||
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, PlatformKind};
|
||||||
|
use ui::h_flex;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use crate::platforms::linux::LinuxWindowControls;
|
||||||
|
use crate::platforms::mac::TRAFFIC_LIGHT_PADDING;
|
||||||
|
use crate::platforms::windows::WindowsWindowControls;
|
||||||
|
|
||||||
|
mod platforms;
|
||||||
|
|
||||||
|
/// Titlebar
|
||||||
|
pub struct TitleBar {
|
||||||
|
/// Children elements of the title bar.
|
||||||
|
children: SmallVec<[AnyElement; 2]>,
|
||||||
|
|
||||||
|
/// Whether the title bar is currently being moved.
|
||||||
|
should_move: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TitleBar {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
children: smallvec![],
|
||||||
|
should_move: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
pub fn height(&self, window: &mut Window) -> Pixels {
|
||||||
|
(1.75 * window.rem_size()).max(px(34.))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn height(&self, _window: &mut Window) -> Pixels {
|
||||||
|
px(32.)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn titlebar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
|
||||||
|
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||||
|
if window.is_window_active() && !self.should_move {
|
||||||
|
cx.theme().title_bar
|
||||||
|
} else {
|
||||||
|
cx.theme().title_bar_inactive
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cx.theme().title_bar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_children<T>(&mut self, children: T)
|
||||||
|
where
|
||||||
|
T: IntoIterator<Item = AnyElement>,
|
||||||
|
{
|
||||||
|
self.children = children.into_iter().collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TitleBar {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for TitleBar {
|
||||||
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let height = self.height(window);
|
||||||
|
let color = self.titlebar_color(window, cx);
|
||||||
|
let children = std::mem::take(&mut self.children);
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let supported_controls = window.window_controls();
|
||||||
|
let decorations = window.window_decorations();
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.window_control_area(WindowControlArea::Drag)
|
||||||
|
.h(height)
|
||||||
|
.w_full()
|
||||||
|
.map(|this| {
|
||||||
|
if window.is_fullscreen() {
|
||||||
|
this.px_2()
|
||||||
|
} else if cx.theme().platform.is_mac() {
|
||||||
|
this.pr_2().pl(px(TRAFFIC_LIGHT_PADDING))
|
||||||
|
} else {
|
||||||
|
this.px_2()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|this| match decorations {
|
||||||
|
Decorations::Server => this,
|
||||||
|
Decorations::Client { tiling } => this
|
||||||
|
.when(!(tiling.top || tiling.right), |div| {
|
||||||
|
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||||
|
})
|
||||||
|
.when(!(tiling.top || tiling.left), |div| {
|
||||||
|
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.bg(color)
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.content_stretch()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.id("title-bar")
|
||||||
|
.justify_between()
|
||||||
|
.w_full()
|
||||||
|
.when(cx.theme().platform.is_mac(), |this| {
|
||||||
|
this.on_click(|event, window, _| {
|
||||||
|
if event.click_count() == 2 {
|
||||||
|
window.titlebar_double_click();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.when(cx.theme().platform.is_linux(), |this| {
|
||||||
|
this.on_click(|event, window, _| {
|
||||||
|
if event.click_count() == 2 {
|
||||||
|
window.zoom_window();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||||
|
.children(children),
|
||||||
|
)
|
||||||
|
.when(!window.is_fullscreen(), |this| match cx.theme().platform {
|
||||||
|
PlatformKind::Linux => {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if matches!(decorations, Decorations::Client { .. }) {
|
||||||
|
this.child(LinuxWindowControls::new(None))
|
||||||
|
.when(supported_controls.window_menu, |this| {
|
||||||
|
this.on_mouse_down(MouseButton::Right, move |ev, window, _| {
|
||||||
|
window.show_window_menu(ev.position)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
|
||||||
|
if this.should_move {
|
||||||
|
this.should_move = false;
|
||||||
|
window.start_window_move();
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| {
|
||||||
|
this.should_move = false;
|
||||||
|
}))
|
||||||
|
.on_mouse_up(
|
||||||
|
MouseButton::Left,
|
||||||
|
cx.listener(move |this, _ev, _window, _cx| {
|
||||||
|
this.should_move = false;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.on_mouse_down(
|
||||||
|
MouseButton::Left,
|
||||||
|
cx.listener(move |this, _ev, _window, _cx| {
|
||||||
|
this.should_move = true;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
this
|
||||||
|
}
|
||||||
|
PlatformKind::Windows => this.child(WindowsWindowControls::new(height)),
|
||||||
|
PlatformKind::Mac => this,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
227
crates/title_bar/src/platforms/linux.rs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
svg, Action, App, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
|
||||||
|
SharedString, StatefulInteractiveElement, Styled, Window,
|
||||||
|
};
|
||||||
|
use linicon::{lookup_icon, IconType};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::{h_flex, Icon, IconName, Sizable};
|
||||||
|
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct LinuxWindowControls {
|
||||||
|
close_window_action: Option<Box<dyn Action>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxWindowControls {
|
||||||
|
pub fn new(close_window_action: Option<Box<dyn Action>>) -> Self {
|
||||||
|
Self {
|
||||||
|
close_window_action,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for LinuxWindowControls {
|
||||||
|
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||||
|
let supported_controls = window.window_controls();
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.id("linux-window-controls")
|
||||||
|
.gap_2()
|
||||||
|
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||||
|
.when(supported_controls.minimize, |this| {
|
||||||
|
this.child(WindowControl::new(
|
||||||
|
LinuxControl::Minimize,
|
||||||
|
IconName::WindowMinimize,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.when(supported_controls.maximize, |this| {
|
||||||
|
this.child({
|
||||||
|
if window.is_maximized() {
|
||||||
|
WindowControl::new(LinuxControl::Restore, IconName::WindowRestore)
|
||||||
|
} else {
|
||||||
|
WindowControl::new(LinuxControl::Maximize, IconName::WindowMaximize)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
WindowControl::new(LinuxControl::Close, IconName::WindowClose)
|
||||||
|
.when_some(self.close_window_action, |this, close_action| {
|
||||||
|
this.close_action(close_action)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct WindowControl {
|
||||||
|
kind: LinuxControl,
|
||||||
|
fallback: IconName,
|
||||||
|
close_action: Option<Box<dyn Action>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowControl {
|
||||||
|
pub fn new(kind: LinuxControl, fallback: IconName) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
fallback,
|
||||||
|
close_action: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_action(mut self, action: Box<dyn Action>) -> Self {
|
||||||
|
self.close_action = Some(action);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_gnome(&self) -> bool {
|
||||||
|
matches!(detect_desktop_environment(), DesktopEnvironment::Gnome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for WindowControl {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let is_gnome = self.is_gnome();
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.id(self.kind.as_icon_name())
|
||||||
|
.group("")
|
||||||
|
.justify_center()
|
||||||
|
.items_center()
|
||||||
|
.rounded_full()
|
||||||
|
.size_6()
|
||||||
|
.when(is_gnome, |this| {
|
||||||
|
this.bg(cx.theme().ghost_element_background_alt)
|
||||||
|
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||||
|
.active(|this| this.bg(cx.theme().ghost_element_active))
|
||||||
|
})
|
||||||
|
.map(|this| {
|
||||||
|
if let Some(Some(path)) = linux_controls().get(&self.kind).cloned() {
|
||||||
|
this.child(
|
||||||
|
svg()
|
||||||
|
.external_path(SharedString::from(path))
|
||||||
|
.size_4()
|
||||||
|
.text_color(cx.theme().text),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.child(Icon::new(self.fallback).small().text_color(cx.theme().text))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_mouse_move(|_, _window, cx| cx.stop_propagation())
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
match self.kind {
|
||||||
|
LinuxControl::Minimize => window.minimize_window(),
|
||||||
|
LinuxControl::Restore => window.zoom_window(),
|
||||||
|
LinuxControl::Maximize => window.zoom_window(),
|
||||||
|
LinuxControl::Close => cx.quit(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static DE: OnceLock<DesktopEnvironment> = OnceLock::new();
|
||||||
|
static LINUX_CONTROLS: OnceLock<HashMap<LinuxControl, Option<String>>> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum DesktopEnvironment {
|
||||||
|
Gnome,
|
||||||
|
Kde,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the current desktop environment
|
||||||
|
pub fn detect_desktop_environment() -> &'static DesktopEnvironment {
|
||||||
|
DE.get_or_init(|| {
|
||||||
|
// Try to use environment variables first
|
||||||
|
if let Ok(output) = std::env::var("XDG_CURRENT_DESKTOP") {
|
||||||
|
let desktop = output.to_lowercase();
|
||||||
|
if desktop.contains("gnome") {
|
||||||
|
return DesktopEnvironment::Gnome;
|
||||||
|
} else if desktop.contains("kde") {
|
||||||
|
return DesktopEnvironment::Kde;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback detection methods
|
||||||
|
if let Ok(output) = std::env::var("DESKTOP_SESSION") {
|
||||||
|
let session = output.to_lowercase();
|
||||||
|
if session.contains("gnome") {
|
||||||
|
return DesktopEnvironment::Gnome;
|
||||||
|
} else if session.contains("kde") || session.contains("plasma") {
|
||||||
|
return DesktopEnvironment::Kde;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DesktopEnvironment::Unknown
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
||||||
|
pub enum LinuxControl {
|
||||||
|
Minimize,
|
||||||
|
Restore,
|
||||||
|
Maximize,
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxControl {
|
||||||
|
pub fn as_icon_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LinuxControl::Close => "window-close",
|
||||||
|
LinuxControl::Minimize => "window-minimize",
|
||||||
|
LinuxControl::Maximize => "window-maximize",
|
||||||
|
LinuxControl::Restore => "window-restore",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn linux_controls() -> &'static HashMap<LinuxControl, Option<String>> {
|
||||||
|
LINUX_CONTROLS.get_or_init(|| {
|
||||||
|
let mut icons = HashMap::new();
|
||||||
|
icons.insert(LinuxControl::Close, None);
|
||||||
|
icons.insert(LinuxControl::Minimize, None);
|
||||||
|
icons.insert(LinuxControl::Maximize, None);
|
||||||
|
icons.insert(LinuxControl::Restore, None);
|
||||||
|
|
||||||
|
let icon_names = [
|
||||||
|
(LinuxControl::Close, vec!["window-close", "dialog-close"]),
|
||||||
|
(
|
||||||
|
LinuxControl::Minimize,
|
||||||
|
vec!["window-minimize", "window-lower"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
LinuxControl::Maximize,
|
||||||
|
vec!["window-maximize", "window-expand"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
LinuxControl::Restore,
|
||||||
|
vec!["window-restore", "window-return"],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (control, icon_names) in icon_names {
|
||||||
|
for icon_name in icon_names {
|
||||||
|
// Try GNOME-style naming first
|
||||||
|
let mut control_icon = lookup_icon(format!("{icon_name}-symbolic"))
|
||||||
|
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
|
||||||
|
|
||||||
|
// If not found, try KDE-style naming
|
||||||
|
if control_icon.is_none() {
|
||||||
|
control_icon = lookup_icon(icon_name)
|
||||||
|
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(Ok(icon)) = control_icon {
|
||||||
|
icons
|
||||||
|
.entry(control)
|
||||||
|
.and_modify(|v| *v = Some(icon.path.to_string_lossy().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
icons
|
||||||
|
})
|
||||||
|
}
|
||||||
6
crates/title_bar/src/platforms/mac.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// Use pixels here instead of a rem-based size because the macOS traffic
|
||||||
|
/// lights are a static size, and don't scale with the rest of the UI.
|
||||||
|
///
|
||||||
|
/// Magic number: There is one extra pixel of padding on the left side due to
|
||||||
|
/// the 1px border around the window on macOS apps.
|
||||||
|
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
|
||||||
4
crates/title_bar/src/platforms/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub mod linux;
|
||||||
|
pub mod mac;
|
||||||
|
pub mod windows;
|
||||||
147
crates/title_bar/src/platforms/windows.rs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
div, px, App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels,
|
||||||
|
RenderOnce, Rgba, StatefulInteractiveElement, Styled, Window, WindowControlArea,
|
||||||
|
};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::h_flex;
|
||||||
|
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct WindowsWindowControls {
|
||||||
|
button_height: Pixels,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowsWindowControls {
|
||||||
|
pub fn new(button_height: Pixels) -> Self {
|
||||||
|
Self { button_height }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn get_font() -> &'static str {
|
||||||
|
"Segoe Fluent Icons"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn get_font() -> &'static str {
|
||||||
|
use windows::Wdk::System::SystemServices::RtlGetVersion;
|
||||||
|
|
||||||
|
let mut version = unsafe { std::mem::zeroed() };
|
||||||
|
let status = unsafe { RtlGetVersion(&mut version) };
|
||||||
|
|
||||||
|
if status.is_ok() && version.dwBuildNumber >= 22000 {
|
||||||
|
"Segoe Fluent Icons"
|
||||||
|
} else {
|
||||||
|
"Segoe MDL2 Assets"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for WindowsWindowControls {
|
||||||
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let close_button_hover_color = Rgba {
|
||||||
|
r: 232.0 / 255.0,
|
||||||
|
g: 17.0 / 255.0,
|
||||||
|
b: 32.0 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let button_hover_color = cx.theme().ghost_element_hover;
|
||||||
|
let button_active_color = cx.theme().ghost_element_active;
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id("windows-window-controls")
|
||||||
|
.font_family(Self::get_font())
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.justify_center()
|
||||||
|
.content_stretch()
|
||||||
|
.max_h(self.button_height)
|
||||||
|
.min_h(self.button_height)
|
||||||
|
.child(WindowsCaptionButton::new(
|
||||||
|
"minimize",
|
||||||
|
WindowsCaptionButtonIcon::Minimize,
|
||||||
|
button_hover_color,
|
||||||
|
button_active_color,
|
||||||
|
))
|
||||||
|
.child(WindowsCaptionButton::new(
|
||||||
|
"maximize-or-restore",
|
||||||
|
if window.is_maximized() {
|
||||||
|
WindowsCaptionButtonIcon::Restore
|
||||||
|
} else {
|
||||||
|
WindowsCaptionButtonIcon::Maximize
|
||||||
|
},
|
||||||
|
button_hover_color,
|
||||||
|
button_active_color,
|
||||||
|
))
|
||||||
|
.child(WindowsCaptionButton::new(
|
||||||
|
"close",
|
||||||
|
WindowsCaptionButtonIcon::Close,
|
||||||
|
close_button_hover_color,
|
||||||
|
button_active_color,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
||||||
|
enum WindowsCaptionButtonIcon {
|
||||||
|
Minimize,
|
||||||
|
Restore,
|
||||||
|
Maximize,
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
struct WindowsCaptionButton {
|
||||||
|
id: ElementId,
|
||||||
|
icon: WindowsCaptionButtonIcon,
|
||||||
|
hover_background_color: Hsla,
|
||||||
|
active_background_color: Hsla,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowsCaptionButton {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
icon: WindowsCaptionButtonIcon,
|
||||||
|
hover_background_color: impl Into<Hsla>,
|
||||||
|
active_background_color: impl Into<Hsla>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
icon,
|
||||||
|
hover_background_color: hover_background_color.into(),
|
||||||
|
active_background_color: active_background_color.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for WindowsCaptionButton {
|
||||||
|
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.id(self.id)
|
||||||
|
.justify_center()
|
||||||
|
.content_center()
|
||||||
|
.occlude()
|
||||||
|
.w(px(36.))
|
||||||
|
.h_full()
|
||||||
|
.text_size(px(10.0))
|
||||||
|
.hover(|style| style.bg(self.hover_background_color))
|
||||||
|
.active(|style| style.bg(self.active_background_color))
|
||||||
|
.map(|this| match self.icon {
|
||||||
|
WindowsCaptionButtonIcon::Close => {
|
||||||
|
this.window_control_area(WindowControlArea::Close)
|
||||||
|
}
|
||||||
|
WindowsCaptionButtonIcon::Maximize | WindowsCaptionButtonIcon::Restore => {
|
||||||
|
this.window_control_area(WindowControlArea::Max)
|
||||||
|
}
|
||||||
|
WindowsCaptionButtonIcon::Minimize => {
|
||||||
|
this.window_control_area(WindowControlArea::Min)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.child(match self.icon {
|
||||||
|
WindowsCaptionButtonIcon::Minimize => "\u{e921}",
|
||||||
|
WindowsCaptionButtonIcon::Restore => "\u{e923}",
|
||||||
|
WindowsCaptionButtonIcon::Maximize => "\u{e922}",
|
||||||
|
WindowsCaptionButtonIcon::Close => "\u{e8bb}",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@ common = { path = "../common" }
|
|||||||
theme = { path = "../theme" }
|
theme = { path = "../theme" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
instant.workspace = true
|
smol.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
@@ -19,10 +20,7 @@ log.workspace = true
|
|||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
uuid = "1.10"
|
uuid = "1.10"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
image = "0.25.1"
|
||||||
lsp-types = "0.97.0"
|
lsp-types = "0.97.0"
|
||||||
ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] }
|
rope = { git = "https://github.com/zed-industries/zed" }
|
||||||
sum_tree = { git = "https://github.com/zed-industries/zed" }
|
sum_tree = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
|
||||||
smol.workspace = true
|
|
||||||
tree-sitter = "0.26"
|
|
||||||
|
|||||||
191
crates/ui/LICENSE
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
Copyright 2024 Longbridge <https://longbridge.com>
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
332
crates/ui/src/anchored.rs
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
//! This is a fork of gpui's anchored element that adds support for offsetting
|
||||||
|
//! https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/elements/anchored.rs
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half,
|
||||||
|
InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
|
||||||
|
Window, point, px,
|
||||||
|
};
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
use theme::Anchor;
|
||||||
|
|
||||||
|
/// The state that the anchored element element uses to track its children.
|
||||||
|
pub struct AnchoredState {
|
||||||
|
child_layout_ids: SmallVec<[LayoutId; 4]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An anchored element that can be used to display UI that
|
||||||
|
/// will avoid overflowing the window bounds.
|
||||||
|
pub(crate) struct Anchored {
|
||||||
|
children: SmallVec<[AnyElement; 2]>,
|
||||||
|
anchor_corner: Anchor,
|
||||||
|
fit_mode: AnchoredFitMode,
|
||||||
|
anchor_position: Option<Point<Pixels>>,
|
||||||
|
position_mode: AnchoredPositionMode,
|
||||||
|
offset: Option<Point<Pixels>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// anchored gives you an element that will avoid overflowing the window bounds.
|
||||||
|
/// Its children should have no margin to avoid measurement issues.
|
||||||
|
pub(crate) fn anchored() -> Anchored {
|
||||||
|
Anchored {
|
||||||
|
children: SmallVec::new(),
|
||||||
|
anchor_corner: Anchor::TopLeft,
|
||||||
|
fit_mode: AnchoredFitMode::SwitchAnchor,
|
||||||
|
anchor_position: None,
|
||||||
|
position_mode: AnchoredPositionMode::Window,
|
||||||
|
offset: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl Anchored {
|
||||||
|
/// Sets which corner of the anchored element should be anchored to the current position.
|
||||||
|
pub fn anchor(mut self, anchor: Anchor) -> Self {
|
||||||
|
self.anchor_corner = anchor;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the position in window coordinates
|
||||||
|
/// (otherwise the location the anchored element is rendered is used)
|
||||||
|
pub fn position(mut self, anchor: Point<Pixels>) -> Self {
|
||||||
|
self.anchor_position = Some(anchor);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offset the final position by this amount.
|
||||||
|
/// Useful when you want to anchor to an element but offset from it, such as in PopoverMenu.
|
||||||
|
pub fn offset(mut self, offset: Point<Pixels>) -> Self {
|
||||||
|
self.offset = Some(offset);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the position mode for this anchored element. Local will have this
|
||||||
|
/// interpret its [`Anchored::position`] as relative to the parent element.
|
||||||
|
/// While Window will have it interpret the position as relative to the window.
|
||||||
|
pub fn position_mode(mut self, mode: AnchoredPositionMode) -> Self {
|
||||||
|
self.position_mode = mode;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snap to window edge instead of switching anchor corner when an overflow would occur.
|
||||||
|
pub fn snap_to_window(mut self) -> Self {
|
||||||
|
self.fit_mode = AnchoredFitMode::SnapToWindow;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snap to window edge and leave some margins.
|
||||||
|
pub fn snap_to_window_with_margin(mut self, edges: impl Into<Edges<Pixels>>) -> Self {
|
||||||
|
self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParentElement for Anchored {
|
||||||
|
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||||
|
self.children.extend(elements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for Anchored {
|
||||||
|
type PrepaintState = ();
|
||||||
|
type RequestLayoutState = AnchoredState;
|
||||||
|
|
||||||
|
fn id(&self) -> Option<gpui::ElementId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_layout(
|
||||||
|
&mut self,
|
||||||
|
_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&InspectorElementId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||||
|
let child_layout_ids = self
|
||||||
|
.children
|
||||||
|
.iter_mut()
|
||||||
|
.map(|child| child.request_layout(window, cx))
|
||||||
|
.collect::<SmallVec<_>>();
|
||||||
|
|
||||||
|
let anchored_style = Style {
|
||||||
|
position: Position::Absolute,
|
||||||
|
display: Display::Flex,
|
||||||
|
..Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let layout_id = window.request_layout(anchored_style, child_layout_ids.iter().copied(), cx);
|
||||||
|
|
||||||
|
(layout_id, AnchoredState { child_layout_ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepaint(
|
||||||
|
&mut self,
|
||||||
|
_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&InspectorElementId>,
|
||||||
|
bounds: Bounds<Pixels>,
|
||||||
|
request_layout: &mut Self::RequestLayoutState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
if request_layout.child_layout_ids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child_min = point(Pixels::MAX, Pixels::MAX);
|
||||||
|
let mut child_max = Point::default();
|
||||||
|
for child_layout_id in &request_layout.child_layout_ids {
|
||||||
|
let child_bounds = window.layout_bounds(*child_layout_id);
|
||||||
|
child_min = child_min.min(&child_bounds.origin);
|
||||||
|
child_max = child_max.max(&child_bounds.bottom_right());
|
||||||
|
}
|
||||||
|
let size: Size<Pixels> = (child_max - child_min).into();
|
||||||
|
|
||||||
|
let (origin, mut desired) = self.position_mode.get_position_and_bounds(
|
||||||
|
self.anchor_position,
|
||||||
|
self.anchor_corner,
|
||||||
|
size,
|
||||||
|
bounds,
|
||||||
|
self.offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
let limits = Bounds {
|
||||||
|
origin: Point::default(),
|
||||||
|
size: window.viewport_size(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.fit_mode == AnchoredFitMode::SwitchAnchor {
|
||||||
|
let mut anchor_corner = self.anchor_corner;
|
||||||
|
|
||||||
|
if desired.left() < limits.left() || desired.right() > limits.right() {
|
||||||
|
let switched = Bounds::from_corner_and_size(
|
||||||
|
anchor_corner
|
||||||
|
.other_side_corner_along(Axis::Horizontal)
|
||||||
|
.into(),
|
||||||
|
origin,
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
if !(switched.left() < limits.left() || switched.right() > limits.right()) {
|
||||||
|
anchor_corner = anchor_corner.other_side_corner_along(Axis::Horizontal);
|
||||||
|
desired = switched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if desired.top() < limits.top() || desired.bottom() > limits.bottom() {
|
||||||
|
let switched = Bounds::from_corner_and_size(
|
||||||
|
anchor_corner.other_side_corner_along(Axis::Vertical).into(),
|
||||||
|
origin,
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
if !(switched.top() < limits.top() || switched.bottom() > limits.bottom()) {
|
||||||
|
desired = switched;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_inset = window.client_inset().unwrap_or(px(0.));
|
||||||
|
let edges = match self.fit_mode {
|
||||||
|
AnchoredFitMode::SnapToWindowWithMargin(edges) => edges,
|
||||||
|
_ => Edges::default(),
|
||||||
|
}
|
||||||
|
.map(|edge| *edge + client_inset);
|
||||||
|
|
||||||
|
// Snap the horizontal edges of the anchored element to the horizontal edges of the window if
|
||||||
|
// its horizontal bounds overflow, aligning to the left if it is wider than the limits.
|
||||||
|
if desired.right() > limits.right() {
|
||||||
|
desired.origin.x -= desired.right() - limits.right() + edges.right;
|
||||||
|
}
|
||||||
|
if desired.left() < limits.left() {
|
||||||
|
desired.origin.x = limits.origin.x + edges.left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap the vertical edges of the anchored element to the vertical edges of the window if
|
||||||
|
// its vertical bounds overflow, aligning to the top if it is taller than the limits.
|
||||||
|
if desired.bottom() > limits.bottom() {
|
||||||
|
desired.origin.y -= desired.bottom() - limits.bottom() + edges.bottom;
|
||||||
|
}
|
||||||
|
if desired.top() < limits.top() {
|
||||||
|
desired.origin.y = limits.origin.y + edges.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = desired.origin - bounds.origin;
|
||||||
|
let offset = point(offset.x.round(), offset.y.round());
|
||||||
|
|
||||||
|
window.with_element_offset(offset, |window| {
|
||||||
|
for child in &mut self.children {
|
||||||
|
child.prepaint(window, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(
|
||||||
|
&mut self,
|
||||||
|
_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&InspectorElementId>,
|
||||||
|
_bounds: Bounds<Pixels>,
|
||||||
|
_request_layout: &mut Self::RequestLayoutState,
|
||||||
|
_prepaint: &mut Self::PrepaintState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
for child in &mut self.children {
|
||||||
|
child.paint(window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoElement for Anchored {
|
||||||
|
type Element = Self;
|
||||||
|
|
||||||
|
fn into_element(self) -> Self::Element {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which algorithm to use when fitting the anchored element to be inside the window.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Copy, Clone, PartialEq)]
|
||||||
|
pub enum AnchoredFitMode {
|
||||||
|
/// Snap the anchored element to the window edge.
|
||||||
|
SnapToWindow,
|
||||||
|
/// Snap to window edge and leave some margins.
|
||||||
|
SnapToWindowWithMargin(Edges<Pixels>),
|
||||||
|
/// Switch which corner anchor this anchored element is attached to.
|
||||||
|
SwitchAnchor,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which algorithm to use when positioning the anchored element.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Copy, Clone, PartialEq)]
|
||||||
|
pub enum AnchoredPositionMode {
|
||||||
|
/// Position the anchored element relative to the window.
|
||||||
|
Window,
|
||||||
|
/// Position the anchored element relative to its parent.
|
||||||
|
Local,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnchoredPositionMode {
|
||||||
|
fn get_position_and_bounds(
|
||||||
|
&self,
|
||||||
|
anchor_position: Option<Point<Pixels>>,
|
||||||
|
anchor_corner: Anchor,
|
||||||
|
size: Size<Pixels>,
|
||||||
|
bounds: Bounds<Pixels>,
|
||||||
|
offset: Option<Point<Pixels>>,
|
||||||
|
) -> (Point<Pixels>, Bounds<Pixels>) {
|
||||||
|
let offset = offset.unwrap_or_default();
|
||||||
|
|
||||||
|
match self {
|
||||||
|
AnchoredPositionMode::Window => {
|
||||||
|
let anchor_position = anchor_position.unwrap_or(bounds.origin);
|
||||||
|
let bounds =
|
||||||
|
Self::from_corner_and_size(anchor_corner, anchor_position + offset, size);
|
||||||
|
(anchor_position, bounds)
|
||||||
|
}
|
||||||
|
AnchoredPositionMode::Local => {
|
||||||
|
let anchor_position = anchor_position.unwrap_or_default();
|
||||||
|
let bounds = Self::from_corner_and_size(
|
||||||
|
anchor_corner,
|
||||||
|
bounds.origin + anchor_position + offset,
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
(anchor_position, bounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/geometry.rs#L863
|
||||||
|
fn from_corner_and_size(
|
||||||
|
anchor: Anchor,
|
||||||
|
origin: Point<Pixels>,
|
||||||
|
size: Size<Pixels>,
|
||||||
|
) -> Bounds<Pixels> {
|
||||||
|
let origin = match anchor {
|
||||||
|
Anchor::TopLeft => origin,
|
||||||
|
Anchor::TopCenter => Point {
|
||||||
|
x: origin.x - size.width.half(),
|
||||||
|
y: origin.y,
|
||||||
|
},
|
||||||
|
Anchor::TopRight => Point {
|
||||||
|
x: origin.x - size.width,
|
||||||
|
y: origin.y,
|
||||||
|
},
|
||||||
|
Anchor::BottomLeft => Point {
|
||||||
|
x: origin.x,
|
||||||
|
y: origin.y - size.height,
|
||||||
|
},
|
||||||
|
Anchor::BottomCenter => Point {
|
||||||
|
x: origin.x - size.width.half(),
|
||||||
|
y: origin.y - size.height,
|
||||||
|
},
|
||||||
|
Anchor::BottomRight => Point {
|
||||||
|
x: origin.x - size.width,
|
||||||
|
y: origin.y - size.height,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Bounds { origin, size }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,15 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement, IntoElement,
|
div, relative, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement,
|
||||||
ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
|
IntoElement, ParentElement, RenderOnce, SharedString, Stateful,
|
||||||
StyleRefinement, Styled, Window, div, relative,
|
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::indicator::Indicator;
|
use crate::indicator::Indicator;
|
||||||
use crate::tooltip::Tooltip;
|
use crate::tooltip::Tooltip;
|
||||||
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
|
use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ButtonCustomVariant {
|
pub struct ButtonCustomVariant {
|
||||||
@@ -617,7 +617,7 @@ impl ButtonVariant {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let fg = match self {
|
let fg = match self {
|
||||||
ButtonVariant::Primary => cx.theme().text_muted,
|
ButtonVariant::Primary => cx.theme().text_muted, // TODO: use a different color?
|
||||||
_ => cx.theme().text_muted,
|
_ => cx.theme().text_muted,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use instant::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
|
|||||||
@@ -214,8 +214,6 @@ impl Dock {
|
|||||||
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.open = open;
|
self.open = open;
|
||||||
let item = self.panel.clone();
|
let item = self.panel.clone();
|
||||||
// Use defer_in (not window.defer) so the callback is cancelled
|
|
||||||
// if this Dock entity is dropped before the deferred frame runs.
|
|
||||||
cx.defer_in(window, move |_, window, cx| {
|
cx.defer_in(window, move |_, window, cx| {
|
||||||
item.set_collapsed(!open, window, cx);
|
item.set_collapsed(!open, window, cx);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity, EntityId,
|
AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations, Edges, Entity,
|
||||||
EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
|
EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||||
Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
|
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
|
||||||
};
|
};
|
||||||
|
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
|
||||||
|
|
||||||
use crate::ElementExt;
|
use crate::ElementExt;
|
||||||
|
|
||||||
@@ -109,8 +110,15 @@ impl DockItem {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let mut items = items;
|
||||||
|
|
||||||
let stack_panel = cx.new(|cx| {
|
let stack_panel = cx.new(|cx| {
|
||||||
let mut stack_panel = StackPanel::new(axis, window, cx);
|
let mut stack_panel = StackPanel::new(axis, window, cx);
|
||||||
|
for (i, item) in items.iter_mut().enumerate() {
|
||||||
|
let view = item.view();
|
||||||
|
let size = sizes.get(i).copied().flatten();
|
||||||
|
stack_panel.add_panel(view.clone(), size, dock_area.clone(), window, cx)
|
||||||
|
}
|
||||||
|
|
||||||
for (i, item) in items.iter().enumerate() {
|
for (i, item) in items.iter().enumerate() {
|
||||||
let view = item.view();
|
let view = item.view();
|
||||||
@@ -737,22 +745,34 @@ impl EventEmitter<DockEvent> for DockArea {}
|
|||||||
impl Render for DockArea {
|
impl Render for DockArea {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let view = cx.entity().clone();
|
let view = cx.entity().clone();
|
||||||
|
let decorations = window.window_decorations();
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.id("dock-area")
|
.id("dock-area")
|
||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
|
.overflow_hidden()
|
||||||
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
|
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if let Some(zoom_view) = self.zoom_view.clone() {
|
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 {
|
} else {
|
||||||
// render dock
|
// render dock
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_row()
|
.flex_row()
|
||||||
.size_full()
|
.h_full()
|
||||||
// Left dock
|
// Left dock
|
||||||
.when_some(self.left_dock.clone(), |this, dock| {
|
.when_some(self.left_dock.clone(), |this, dock| {
|
||||||
this.child(div().flex().flex_none().child(dock))
|
this.child(div().flex().flex_none().child(dock))
|
||||||
@@ -763,8 +783,14 @@ impl Render for DockArea {
|
|||||||
.flex()
|
.flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
|
.overflow_hidden()
|
||||||
// Top center
|
// Top center
|
||||||
.child(div().flex_1().child(self.render_items(window, cx)))
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(self.render_items(window, cx)),
|
||||||
|
)
|
||||||
// Bottom Dock
|
// Bottom Dock
|
||||||
.when_some(self.bottom_dock.clone(), |this, dock| {
|
.when_some(self.bottom_dock.clone(), |this, dock| {
|
||||||
this.child(dock)
|
this.child(dock)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity,
|
IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity,
|
||||||
Window,
|
Window,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
use theme::{AxisExt as _, Placement};
|
use theme::{ActiveTheme, AxisExt as _, CLIENT_SIDE_DECORATION_ROUNDING, Placement};
|
||||||
|
|
||||||
use super::{DockArea, PanelEvent};
|
use super::{DockArea, PanelEvent};
|
||||||
use crate::dock::panel::{Panel, PanelView};
|
use crate::dock::panel::{Panel, PanelView};
|
||||||
@@ -368,12 +369,18 @@ impl Focusable for StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for StackPanel {}
|
impl EventEmitter<PanelEvent> for StackPanel {}
|
||||||
|
|
||||||
impl EventEmitter<DismissEvent> for StackPanel {}
|
impl EventEmitter<DismissEvent> for StackPanel {}
|
||||||
|
|
||||||
impl Render for StackPanel {
|
impl Render for StackPanel {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
h_flex().size_full().overflow_hidden().child(
|
h_flex()
|
||||||
|
.size_full()
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(cx.theme().panel_background)
|
||||||
|
.when(cx.theme().platform.is_linux(), |this| {
|
||||||
|
this.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
ResizablePanelGroup::new("stack-panel-group")
|
ResizablePanelGroup::new("stack-panel-group")
|
||||||
.with_state(&self.state)
|
.with_state(&self.state)
|
||||||
.axis(self.axis)
|
.axis(self.axis)
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Anchor, App, AppContext, Context, DefiniteLength, DismissEvent, DragMoveEvent, Empty, Entity,
|
App, AppContext, Context, Corner, DefiniteLength, DismissEvent, DragMoveEvent, Empty, Entity,
|
||||||
EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, MouseButton,
|
EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, MouseButton,
|
||||||
ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled,
|
ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled,
|
||||||
WeakEntity, Window, div, px, rems,
|
WeakEntity, Window, div, px, rems,
|
||||||
};
|
};
|
||||||
use theme::{ActiveTheme, AxisExt, Placement, TABBAR_HEIGHT};
|
use theme::{ActiveTheme, AxisExt, CLIENT_SIDE_DECORATION_ROUNDING, Placement, TABBAR_HEIGHT};
|
||||||
|
|
||||||
use crate::button::{Button, ButtonVariants as _};
|
use crate::button::{Button, ButtonVariants as _};
|
||||||
use crate::dock::dock::DockPlacement;
|
use crate::dock::dock::DockPlacement;
|
||||||
@@ -460,7 +460,7 @@ impl TabPanel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.anchor(Anchor::TopRight),
|
.anchor(Corner::TopRight),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +567,6 @@ impl TabPanel {
|
|||||||
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
|
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
|
||||||
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, window, cx);
|
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, window, cx);
|
||||||
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, window, cx);
|
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, window, cx);
|
||||||
|
|
||||||
let has_extend_dock_button = left_dock_button.is_some() || bottom_dock_button.is_some();
|
let has_extend_dock_button = left_dock_button.is_some() || bottom_dock_button.is_some();
|
||||||
let tabs_count = self.panels.len();
|
let tabs_count = self.panels.len();
|
||||||
let is_bottom_dock = bottom_dock_button.is_some();
|
let is_bottom_dock = bottom_dock_button.is_some();
|
||||||
@@ -587,7 +586,6 @@ impl TabPanel {
|
|||||||
.py_2()
|
.py_2()
|
||||||
.pl_3()
|
.pl_3()
|
||||||
.pr_2()
|
.pr_2()
|
||||||
.rounded_t(cx.theme().radius_lg)
|
|
||||||
.bg(cx.theme().panel_background)
|
.bg(cx.theme().panel_background)
|
||||||
.when(left_dock_button.is_some(), |this| this.pl_2())
|
.when(left_dock_button.is_some(), |this| this.pl_2())
|
||||||
.when(right_dock_button.is_some(), |this| this.pr_2())
|
.when(right_dock_button.is_some(), |this| this.pr_2())
|
||||||
@@ -643,14 +641,17 @@ impl TabPanel {
|
|||||||
TabBar::new("tab-bar")
|
TabBar::new("tab-bar")
|
||||||
.track_scroll(&self.tab_bar_scroll_handle)
|
.track_scroll(&self.tab_bar_scroll_handle)
|
||||||
.h(TABBAR_HEIGHT)
|
.h(TABBAR_HEIGHT)
|
||||||
.bg(cx.theme().panel_background)
|
|
||||||
.rounded_t(cx.theme().radius_lg)
|
|
||||||
.when(has_extend_dock_button, |this| {
|
.when(has_extend_dock_button, |this| {
|
||||||
this.prefix(
|
this.prefix(
|
||||||
h_flex()
|
h_flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.top_0()
|
.top_0()
|
||||||
.right(-px(1.))
|
.right(-px(1.))
|
||||||
|
.border_r_1()
|
||||||
|
.border_b_1()
|
||||||
|
.h_full()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.bg(cx.theme().tab_background)
|
||||||
.pl_0p5()
|
.pl_0p5()
|
||||||
.pr_1()
|
.pr_1()
|
||||||
.children(left_dock_button)
|
.children(left_dock_button)
|
||||||
@@ -688,7 +689,6 @@ impl TabPanel {
|
|||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
move |view, _ev, window, cx| {
|
move |view, _ev, window, cx| {
|
||||||
view.remove_panel(&panel, window, cx);
|
view.remove_panel(&panel, window, cx);
|
||||||
view.set_active_ix(ix, window, cx);
|
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
@@ -750,7 +750,7 @@ impl TabPanel {
|
|||||||
div()
|
div()
|
||||||
.id("tab-bar-empty-space")
|
.id("tab-bar-empty-space")
|
||||||
.h_full()
|
.h_full()
|
||||||
.flex_grow_1()
|
.flex_grow()
|
||||||
.min_w_16()
|
.min_w_16()
|
||||||
.when(state.droppable, |this| {
|
.when(state.droppable, |this| {
|
||||||
let view = cx.entity();
|
let view = cx.entity();
|
||||||
@@ -780,8 +780,12 @@ impl TabPanel {
|
|||||||
.top_0()
|
.top_0()
|
||||||
.right_0()
|
.right_0()
|
||||||
.h_full()
|
.h_full()
|
||||||
|
.border_l_1()
|
||||||
|
.border_b_1()
|
||||||
.px_0p5()
|
.px_0p5()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.bg(cx.theme().tab_background)
|
||||||
.child(self.render_toolbar(state, window, cx))
|
.child(self.render_toolbar(state, window, cx))
|
||||||
.when_some(right_dock_button, |this, btn| this.child(btn)),
|
.when_some(right_dock_button, |this, btn| this.child(btn)),
|
||||||
)
|
)
|
||||||
@@ -811,8 +815,10 @@ impl TabPanel {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
.rounded_b(cx.theme().radius_lg)
|
|
||||||
.bg(cx.theme().panel_background)
|
.bg(cx.theme().panel_background)
|
||||||
|
.when(cx.theme().platform.is_linux(), |this| {
|
||||||
|
this.rounded_b(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||||
|
})
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(
|
.child(
|
||||||
active_panel
|
active_panel
|
||||||
@@ -1134,24 +1140,17 @@ impl Render for TabPanel {
|
|||||||
state.closable = false;
|
state.closable = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
div()
|
v_flex()
|
||||||
.when(!self.collapsed, |this| {
|
.when(!self.collapsed, |this| {
|
||||||
this.on_action(cx.listener(Self::on_action_toggle_zoom))
|
this.on_action(cx.listener(Self::on_action_toggle_zoom))
|
||||||
.on_action(cx.listener(Self::on_action_close_panel))
|
.on_action(cx.listener(Self::on_action_close_panel))
|
||||||
})
|
})
|
||||||
.id("tab-panel")
|
.id("tab-panel")
|
||||||
|
.tab_group()
|
||||||
.track_focus(&focus_handle)
|
.track_focus(&focus_handle)
|
||||||
.size_full()
|
.size_full()
|
||||||
.p_1()
|
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.rounded(cx.theme().radius_lg)
|
|
||||||
.when(cx.theme().shadow, |this| this.shadow_xs())
|
|
||||||
.size_full()
|
|
||||||
.tab_group()
|
|
||||||
.child(self.render_title_bar(&state, window, cx))
|
.child(self.render_title_bar(&state, window, cx))
|
||||||
.child(self.render_active_panel(&state, window, cx)),
|
.child(self.render_active_panel(&state, window, cx))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use instant::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// A HistoryItem represents a single change in the history.
|
|
||||||
/// It must implement Clone and PartialEq to be used in the History.
|
|
||||||
pub trait HistoryItem: Clone + PartialEq {
|
pub trait HistoryItem: Clone + PartialEq {
|
||||||
fn version(&self) -> usize;
|
fn version(&self) -> usize;
|
||||||
fn set_version(&mut self, version: usize);
|
fn set_version(&mut self, version: usize);
|
||||||
@@ -24,11 +22,10 @@ pub struct History<I: HistoryItem> {
|
|||||||
redos: Vec<I>,
|
redos: Vec<I>,
|
||||||
last_changed_at: Instant,
|
last_changed_at: Instant,
|
||||||
version: usize,
|
version: usize,
|
||||||
pub(crate) ignore: bool,
|
max_undo: usize,
|
||||||
max_undos: usize,
|
|
||||||
group_interval: Option<Duration>,
|
group_interval: Option<Duration>,
|
||||||
grouping: bool,
|
|
||||||
unique: bool,
|
unique: bool,
|
||||||
|
pub ignore: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I> History<I>
|
impl<I> History<I>
|
||||||
@@ -42,16 +39,15 @@ where
|
|||||||
ignore: false,
|
ignore: false,
|
||||||
last_changed_at: Instant::now(),
|
last_changed_at: Instant::now(),
|
||||||
version: 0,
|
version: 0,
|
||||||
max_undos: 1000,
|
max_undo: 1000,
|
||||||
group_interval: None,
|
group_interval: None,
|
||||||
grouping: false,
|
|
||||||
unique: false,
|
unique: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the maximum number of undo steps to keep, defaults to 1000.
|
/// Set the maximum number of undo steps to keep, defaults to 1000.
|
||||||
pub fn max_undos(mut self, max_undos: usize) -> Self {
|
pub fn max_undo(mut self, max_undo: usize) -> Self {
|
||||||
self.max_undos = max_undos;
|
self.max_undo = max_undo;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,20 +64,10 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start grouping changes, this will prevent the version from being incremented until `end_grouping` is called.
|
|
||||||
pub fn start_grouping(&mut self) {
|
|
||||||
self.grouping = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End grouping changes, this will allow the version to be incremented again.
|
|
||||||
pub fn end_grouping(&mut self) {
|
|
||||||
self.grouping = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago.
|
/// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago.
|
||||||
fn inc_version(&mut self) -> usize {
|
fn inc_version(&mut self) -> usize {
|
||||||
let t = Instant::now();
|
let t = Instant::now();
|
||||||
if !self.grouping && Some(self.last_changed_at.elapsed()) > self.group_interval {
|
if Some(self.last_changed_at.elapsed()) > self.group_interval {
|
||||||
self.version += 1;
|
self.version += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,11 +80,10 @@ where
|
|||||||
self.version
|
self.version
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push a new change to the history.
|
|
||||||
pub fn push(&mut self, item: I) {
|
pub fn push(&mut self, item: I) {
|
||||||
let version = self.inc_version();
|
let version = self.inc_version();
|
||||||
|
|
||||||
if self.undos.len() >= self.max_undos {
|
if self.undos.len() >= self.max_undo {
|
||||||
self.undos.remove(0);
|
self.undos.remove(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +113,6 @@ where
|
|||||||
self.redos.clear();
|
self.redos.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Undo the last change and return the changes that were undone.
|
|
||||||
pub fn undo(&mut self) -> Option<Vec<I>> {
|
pub fn undo(&mut self) -> Option<Vec<I>> {
|
||||||
if let Some(first_change) = self.undos.pop() {
|
if let Some(first_change) = self.undos.pop() {
|
||||||
let mut changes = vec![first_change.clone()];
|
let mut changes = vec![first_change.clone()];
|
||||||
@@ -151,7 +135,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Redo the last undone change and return the changes that were redone.
|
|
||||||
pub fn redo(&mut self) -> Option<Vec<I>> {
|
pub fn redo(&mut self) -> Option<Vec<I>> {
|
||||||
if let Some(first_change) = self.redos.pop() {
|
if let Some(first_change) = self.redos.pop() {
|
||||||
let mut changes = vec![first_change.clone()];
|
let mut changes = vec![first_change.clone()];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use instant::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
use instant::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use gpui::{Context, Pixels, Task, px};
|
use gpui::{px, Context, Pixels};
|
||||||
|
|
||||||
static INTERVAL: Duration = Duration::from_millis(500);
|
static INTERVAL: Duration = Duration::from_millis(500);
|
||||||
static PAUSE_DELAY: Duration = Duration::from_millis(300);
|
static PAUSE_DELAY: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
// On Windows, Linux, we should use integer to avoid blurry cursor.
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
pub(super) const CURSOR_WIDTH: Pixels = px(2.);
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
|
pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
|
||||||
|
|
||||||
/// To manage the Input cursor blinking.
|
/// To manage the Input cursor blinking.
|
||||||
@@ -17,12 +12,10 @@ pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
|
|||||||
/// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint.
|
/// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint.
|
||||||
///
|
///
|
||||||
/// The input painter will check if this in visible state, then it will draw the cursor.
|
/// The input painter will check if this in visible state, then it will draw the cursor.
|
||||||
pub(crate) struct BlinkCursor {
|
pub struct BlinkCursor {
|
||||||
visible: bool,
|
visible: bool,
|
||||||
paused: bool,
|
paused: bool,
|
||||||
epoch: usize,
|
epoch: usize,
|
||||||
|
|
||||||
_task: Task<()>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BlinkCursor {
|
impl BlinkCursor {
|
||||||
@@ -31,7 +24,6 @@ impl BlinkCursor {
|
|||||||
visible: false,
|
visible: false,
|
||||||
paused: false,
|
paused: false,
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
_task: Task::ready(()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,12 +53,14 @@ impl BlinkCursor {
|
|||||||
|
|
||||||
// Schedule the next blink
|
// Schedule the next blink
|
||||||
let epoch = self.next_epoch();
|
let epoch = self.next_epoch();
|
||||||
self._task = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(INTERVAL).await;
|
cx.background_executor().timer(INTERVAL).await;
|
||||||
|
|
||||||
if let Some(this) = this.upgrade() {
|
if let Some(this) = this.upgrade() {
|
||||||
this.update(cx, |this, cx| this.blink(epoch, cx));
|
this.update(cx, |this, cx| this.blink(epoch, cx));
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn visible(&self) -> bool {
|
pub fn visible(&self) -> bool {
|
||||||
@@ -82,7 +76,7 @@ impl BlinkCursor {
|
|||||||
|
|
||||||
// delay 500ms to start the blinking
|
// delay 500ms to start the blinking
|
||||||
let epoch = self.next_epoch();
|
let epoch = self.next_epoch();
|
||||||
self._task = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(PAUSE_DELAY).await;
|
cx.background_executor().timer(PAUSE_DELAY).await;
|
||||||
|
|
||||||
if let Some(this) = this.upgrade() {
|
if let Some(this) = this.upgrade() {
|
||||||
@@ -91,6 +85,13 @@ impl BlinkCursor {
|
|||||||
this.blink(epoch, cx);
|
this.blink(epoch, cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BlinkCursor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
|
|
||||||
use crate::{history::HistoryItem, input::Selection};
|
use crate::history::HistoryItem;
|
||||||
|
use crate::input::cursor::Selection;
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone)]
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
pub struct Change {
|
pub struct Change {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
use gpui::{App, Styled};
|
use gpui::{App, Styled};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::button::{Button, ButtonVariants as _};
|
use crate::button::{Button, ButtonVariants};
|
||||||
use crate::{Icon, IconName, Sizable as _};
|
use crate::{Icon, IconName, Sizable};
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub(crate) fn clear_button(cx: &App) -> Button {
|
pub(crate) fn clear_button(cx: &App) -> Button {
|
||||||
Button::new("clean")
|
Button::new("clean")
|
||||||
.icon(Icon::new(IconName::CloseCircle))
|
.icon(Icon::new(IconName::CloseCircle))
|
||||||
.ghost()
|
.tooltip("Clear")
|
||||||
.xsmall()
|
.small()
|
||||||
.tab_stop(false)
|
.transparent()
|
||||||
.text_color(cx.theme().icon_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::ops::{Range, RangeBounds};
|
use std::ops::Range;
|
||||||
|
|
||||||
/// A selection in the text, represented by start and end byte indices.
|
/// A selection in the text, represented by start and end byte indices.
|
||||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
|
||||||
@@ -42,12 +42,5 @@ impl From<Selection> for Range<usize> {
|
|||||||
value.start..value.end
|
value.start..value.end
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl RangeBounds<usize> for Selection {
|
|
||||||
fn start_bound(&self) -> std::ops::Bound<&usize> {
|
|
||||||
std::ops::Bound::Included(&self.start)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn end_bound(&self) -> std::ops::Bound<&usize> {
|
pub type Position = lsp_types::Position;
|
||||||
std::ops::Bound::Excluded(&self.end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
use std::ops::Range;
|
|
||||||
|
|
||||||
use gpui::{App, Font, Pixels};
|
|
||||||
use ropey::Rope;
|
|
||||||
|
|
||||||
use super::text_wrapper::{LineItem, WrapDisplayPoint};
|
|
||||||
use super::wrap_map::WrapMap;
|
|
||||||
use crate::input::Point as TreeSitterPoint;
|
|
||||||
|
|
||||||
/// DisplayMap is the main interface for Input coordinate mapping.
|
|
||||||
pub struct DisplayMap {
|
|
||||||
wrap_map: WrapMap,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DisplayMap {
|
|
||||||
pub fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
|
|
||||||
Self {
|
|
||||||
wrap_map: WrapMap::new(font, font_size, wrap_width),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get total number of display rows (same as wrap rows without folding)
|
|
||||||
#[inline]
|
|
||||||
pub fn display_row_count(&self) -> usize {
|
|
||||||
self.wrap_map.wrap_row_count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the buffer line for a given display row
|
|
||||||
pub fn display_row_to_buffer_line(&self, display_row: usize) -> usize {
|
|
||||||
self.wrap_map.wrap_row_to_buffer_line(display_row)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the display row range for a buffer line: [start, end)
|
|
||||||
pub fn buffer_line_to_display_row_range(&self, line: usize) -> Option<Range<usize>> {
|
|
||||||
let range = self.wrap_map.buffer_line_to_wrap_row_range(line);
|
|
||||||
if range.is_empty() { None } else { Some(range) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a buffer line is completely hidden (never true without folding)
|
|
||||||
#[inline]
|
|
||||||
pub fn is_buffer_line_hidden(&self, _line: usize) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All wrap rows are visible since there's no folding.
|
|
||||||
#[inline]
|
|
||||||
pub fn folded_ranges(&self) -> &[()] {
|
|
||||||
&[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adjust folds for edit (no-op without folding)
|
|
||||||
pub fn adjust_folds_for_edit(
|
|
||||||
&mut self,
|
|
||||||
_old_text: &Rope,
|
|
||||||
_range: &Range<usize>,
|
|
||||||
_new_text: &str,
|
|
||||||
) {
|
|
||||||
// No-op: no folding
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update text (incremental or full)
|
|
||||||
pub fn on_text_changed(
|
|
||||||
&mut self,
|
|
||||||
changed_text: &Rope,
|
|
||||||
range: &Range<usize>,
|
|
||||||
new_text: &Rope,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
self.wrap_map
|
|
||||||
.on_text_changed(changed_text, range, new_text, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update layout parameters (wrap width or font)
|
|
||||||
pub fn on_layout_changed(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
|
|
||||||
self.wrap_map.on_layout_changed(wrap_width, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set font parameters
|
|
||||||
pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
|
|
||||||
self.wrap_map.set_font(font, font_size, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure text is prepared (initializes wrapper if needed)
|
|
||||||
pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) {
|
|
||||||
self.wrap_map.ensure_text_prepared(text, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize with text
|
|
||||||
pub fn set_text(&mut self, text: &Rope, cx: &mut App) {
|
|
||||||
self.wrap_map.set_text(text, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert byte offset to wrap display point (with soft wrap info).
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn offset_to_wrap_display_point(&self, offset: usize) -> WrapDisplayPoint {
|
|
||||||
self.wrap_map.wrapper().offset_to_display_point(offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert wrap display point to byte offset.
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn wrap_display_point_to_offset(&self, point: WrapDisplayPoint) -> usize {
|
|
||||||
self.wrap_map.wrapper().display_point_to_offset(point)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert wrap display point to TreeSitterPoint (buffer line/col).
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn wrap_display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint {
|
|
||||||
self.wrap_map.wrapper().display_point_to_point(point)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Since there's no folding, wrap row == display row.
|
|
||||||
#[inline]
|
|
||||||
pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option<usize> {
|
|
||||||
if wrap_row < self.wrap_row_count() {
|
|
||||||
Some(wrap_row)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Since there's no folding, nearest visible row is the row itself.
|
|
||||||
#[inline]
|
|
||||||
pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize {
|
|
||||||
wrap_row.min(self.wrap_row_count().saturating_sub(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Since there's no folding, display row == wrap row.
|
|
||||||
#[inline]
|
|
||||||
pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option<usize> {
|
|
||||||
if display_row < self.wrap_row_count() {
|
|
||||||
Some(display_row)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the longest row index (by byte length).
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn longest_row(&self) -> usize {
|
|
||||||
self.wrap_map.wrapper().longest_row.row
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get access to line items (for rendering)
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn lines(&self) -> &[LineItem] {
|
|
||||||
self.wrap_map.lines()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the rope text
|
|
||||||
#[inline]
|
|
||||||
pub fn text(&self) -> &Rope {
|
|
||||||
self.wrap_map.text()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate how many wrap rows of a buffer line are visible
|
|
||||||
#[inline]
|
|
||||||
pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize {
|
|
||||||
self.wrap_map.visible_wrap_row_count_for_buffer_line(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the wrap row count
|
|
||||||
#[inline]
|
|
||||||
pub fn wrap_row_count(&self) -> usize {
|
|
||||||
self.wrap_map.wrap_row_count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the buffer line count (logical lines)
|
|
||||||
#[inline]
|
|
||||||
pub fn buffer_line_count(&self) -> usize {
|
|
||||||
self.wrap_map.buffer_line_count()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#[allow(clippy::module_inception)]
|
|
||||||
mod display_map;
|
|
||||||
mod text_wrapper;
|
|
||||||
mod wrap_map;
|
|
||||||
|
|
||||||
pub use self::display_map::DisplayMap;
|
|
||||||
pub(crate) use self::text_wrapper::LineLayout;
|
|
||||||
@@ -1,582 +0,0 @@
|
|||||||
use std::ops::Range;
|
|
||||||
|
|
||||||
use gpui::{
|
|
||||||
App, Font, Half, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px,
|
|
||||||
size,
|
|
||||||
};
|
|
||||||
use ropey::Rope;
|
|
||||||
use smallvec::SmallVec;
|
|
||||||
|
|
||||||
use crate::input::{LastLayout, Point as TreeSitterPoint, RopeExt, WhitespaceIndicators};
|
|
||||||
|
|
||||||
/// A line with soft wrapped lines info.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub(crate) struct LineItem {
|
|
||||||
/// The original line text, without end `\n`.
|
|
||||||
line: Rope,
|
|
||||||
/// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line).
|
|
||||||
///
|
|
||||||
/// Not contains the line end `\n`.
|
|
||||||
pub(crate) wrapped_lines: Vec<Range<usize>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LineItem {
|
|
||||||
/// Get the bytes length of this line.
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn len(&self) -> usize {
|
|
||||||
self.line.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get number of soft wrapped lines of this line (include the first line).
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn lines_len(&self) -> usize {
|
|
||||||
self.wrapped_lines.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub(crate) struct LongestRow {
|
|
||||||
/// The 0-based row index.
|
|
||||||
pub row: usize,
|
|
||||||
/// The bytes length of the longest line.
|
|
||||||
pub len: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Used to prepare the text with soft wrap to be get lines to displayed in the Editor.
|
|
||||||
///
|
|
||||||
/// After use lines to calculate the scroll size of the Editor.
|
|
||||||
pub(crate) struct TextWrapper {
|
|
||||||
text: Rope,
|
|
||||||
/// Total wrapped lines (Inlucde the first line), value is start and end index of the line.
|
|
||||||
soft_lines: usize,
|
|
||||||
font: Font,
|
|
||||||
font_size: Pixels,
|
|
||||||
/// If is none, it means the text is not wrapped
|
|
||||||
wrap_width: Option<Pixels>,
|
|
||||||
/// The longest (row, bytes len) in characters, used to calculate the horizontal scroll width.
|
|
||||||
pub(crate) longest_row: LongestRow,
|
|
||||||
/// The lines by split \n
|
|
||||||
pub(crate) lines: Vec<LineItem>,
|
|
||||||
|
|
||||||
_initialized: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(unused)]
|
|
||||||
impl TextWrapper {
|
|
||||||
pub(crate) fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
|
|
||||||
Self {
|
|
||||||
text: Rope::new(),
|
|
||||||
font,
|
|
||||||
font_size,
|
|
||||||
wrap_width,
|
|
||||||
soft_lines: 0,
|
|
||||||
longest_row: LongestRow::default(),
|
|
||||||
lines: Vec::new(),
|
|
||||||
_initialized: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn set_default_text(&mut self, text: &Rope) {
|
|
||||||
self.text = text.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get reference to the rope text.
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn text(&self) -> &Rope {
|
|
||||||
&self.text
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the total number of lines including wrapped lines.
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn len(&self) -> usize {
|
|
||||||
self.soft_lines
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the line item by row index.
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn line(&self, row: usize) -> Option<&LineItem> {
|
|
||||||
self.lines.get(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn set_wrap_width(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
|
|
||||||
if wrap_width == self.wrap_width {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.wrap_width = wrap_width;
|
|
||||||
self.update_all(&self.text.clone(), cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
|
|
||||||
if self.font.eq(&font) && self.font_size == font_size {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.font = font;
|
|
||||||
self.font_size = font_size;
|
|
||||||
self.update_all(&self.text.clone(), cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) -> bool {
|
|
||||||
if self._initialized {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
self._initialized = true;
|
|
||||||
self.update_all(text, cx);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the text wrapper and recalculate the wrapped lines.
|
|
||||||
///
|
|
||||||
/// If the `text` is the same as the current text, do nothing.
|
|
||||||
///
|
|
||||||
/// - `changed_text`: The text [`Rope`] that has changed.
|
|
||||||
/// - `range`: The `selected_range` before change.
|
|
||||||
/// - `new_text`: The inserted text.
|
|
||||||
/// - `force`: Whether to force the update, if false, the update will be skipped if the text is the same.
|
|
||||||
/// - `cx`: The application context.
|
|
||||||
pub(crate) fn update(
|
|
||||||
&mut self,
|
|
||||||
changed_text: &Rope,
|
|
||||||
range: &Range<usize>,
|
|
||||||
new_text: &Rope,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
let mut line_wrapper = cx
|
|
||||||
.text_system()
|
|
||||||
.line_wrapper(self.font.clone(), self.font_size);
|
|
||||||
self._update(
|
|
||||||
changed_text,
|
|
||||||
range,
|
|
||||||
new_text,
|
|
||||||
&mut |line_str, wrap_width| {
|
|
||||||
line_wrapper
|
|
||||||
.wrap_line(&[LineFragment::text(line_str)], wrap_width)
|
|
||||||
.collect()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn _update<F>(
|
|
||||||
&mut self,
|
|
||||||
changed_text: &Rope,
|
|
||||||
range: &Range<usize>,
|
|
||||||
new_text: &Rope,
|
|
||||||
wrap_line: &mut F,
|
|
||||||
) where
|
|
||||||
F: FnMut(&str, Pixels) -> Vec<gpui::Boundary>,
|
|
||||||
{
|
|
||||||
// Remove the old changed lines.
|
|
||||||
let start_row = self.text.offset_to_point(range.start).row;
|
|
||||||
let start_row = start_row.min(self.lines.len().saturating_sub(1));
|
|
||||||
let end_row = self.text.offset_to_point(range.end).row;
|
|
||||||
let end_row = end_row.min(self.lines.len().saturating_sub(1));
|
|
||||||
let rows_range = start_row..=end_row;
|
|
||||||
|
|
||||||
if rows_range.contains(&self.longest_row.row) {
|
|
||||||
self.longest_row = LongestRow::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut longest_row_ix = self.longest_row.row;
|
|
||||||
let mut longest_row_len = self.longest_row.len;
|
|
||||||
|
|
||||||
// To add the new lines.
|
|
||||||
let new_start_row = changed_text.offset_to_point(range.start).row;
|
|
||||||
let new_start_offset = changed_text.line_start_offset(new_start_row);
|
|
||||||
let new_end_row = changed_text
|
|
||||||
.offset_to_point(range.start + new_text.len())
|
|
||||||
.row;
|
|
||||||
let new_end_offset = changed_text.line_end_offset(new_end_row);
|
|
||||||
let new_range = new_start_offset..new_end_offset;
|
|
||||||
|
|
||||||
let mut new_lines = vec![];
|
|
||||||
let wrap_width = self.wrap_width;
|
|
||||||
|
|
||||||
// line not contains `\n`.
|
|
||||||
for (ix, line) in Rope::from(changed_text.slice(new_range))
|
|
||||||
.iter_lines()
|
|
||||||
.enumerate()
|
|
||||||
{
|
|
||||||
let line_str = line.to_string();
|
|
||||||
let mut wrapped_lines = vec![];
|
|
||||||
let mut prev_boundary_ix = 0;
|
|
||||||
|
|
||||||
if line_str.len() > longest_row_len {
|
|
||||||
longest_row_ix = new_start_row + ix;
|
|
||||||
longest_row_len = line_str.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If wrap_width is Pixels::MAX, skip wrapping to disable word wrap
|
|
||||||
if let Some(wrap_width) = wrap_width {
|
|
||||||
// Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty.
|
|
||||||
for boundary in wrap_line(&line_str, wrap_width) {
|
|
||||||
wrapped_lines.push(prev_boundary_ix..boundary.ix);
|
|
||||||
prev_boundary_ix = boundary.ix;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset of the line
|
|
||||||
if !line_str[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 {
|
|
||||||
wrapped_lines.push(prev_boundary_ix..line.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
new_lines.push(LineItem {
|
|
||||||
line: Rope::from(line),
|
|
||||||
wrapped_lines,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.lines.is_empty() {
|
|
||||||
self.lines = new_lines;
|
|
||||||
} else {
|
|
||||||
self.lines.splice(rows_range, new_lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.text = changed_text.clone();
|
|
||||||
self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum();
|
|
||||||
self.longest_row = LongestRow {
|
|
||||||
row: longest_row_ix,
|
|
||||||
len: longest_row_len,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the text wrapper and recalculate the wrapped lines.
|
|
||||||
///
|
|
||||||
/// If the `text` is the same as the current text, do nothing.
|
|
||||||
fn update_all(&mut self, text: &Rope, cx: &mut App) {
|
|
||||||
self.update(text, &(0..text.len()), text, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return display point (with soft wrap) from the given byte offset in the text.
|
|
||||||
///
|
|
||||||
/// Panics if the `offset` is out of bounds.
|
|
||||||
pub(crate) fn offset_to_display_point(&self, offset: usize) -> WrapDisplayPoint {
|
|
||||||
let row = self.text.offset_to_point(offset).row;
|
|
||||||
let start = self.text.line_start_offset(row);
|
|
||||||
let line = &self.lines[row];
|
|
||||||
|
|
||||||
let mut wrapped_row = self
|
|
||||||
.lines
|
|
||||||
.iter()
|
|
||||||
.take(row)
|
|
||||||
.map(|l| l.lines_len())
|
|
||||||
.sum::<usize>();
|
|
||||||
|
|
||||||
let local_offset = offset.saturating_sub(start);
|
|
||||||
for (ix, range) in line.wrapped_lines.iter().enumerate() {
|
|
||||||
if range.contains(&local_offset) {
|
|
||||||
return WrapDisplayPoint::new(
|
|
||||||
wrapped_row + ix,
|
|
||||||
ix,
|
|
||||||
local_offset.saturating_sub(range.start),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise return the eof of the line.
|
|
||||||
let last_range = line.wrapped_lines.last().unwrap_or(&(0..0));
|
|
||||||
let ix = line.lines_len().saturating_sub(1);
|
|
||||||
|
|
||||||
WrapDisplayPoint::new(wrapped_row + ix, ix, last_range.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return byte offset in the text from the given display point (with soft wrap).
|
|
||||||
///
|
|
||||||
/// Panics if the `point.row` is out of bounds.
|
|
||||||
pub(crate) fn display_point_to_offset(&self, point: WrapDisplayPoint) -> usize {
|
|
||||||
let mut wrapped_row = 0;
|
|
||||||
for (row, line) in self.lines.iter().enumerate() {
|
|
||||||
if wrapped_row + line.lines_len() > point.row {
|
|
||||||
let line_start = self.text.line_start_offset(row);
|
|
||||||
let local_row = point.row.saturating_sub(wrapped_row);
|
|
||||||
if let Some(range) = line.wrapped_lines.get(local_row) {
|
|
||||||
return line_start + (range.start + point.column).min(range.end);
|
|
||||||
} else {
|
|
||||||
// If not found, return the end of the line.
|
|
||||||
return line_start + line.len();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wrapped_row += line.lines_len();
|
|
||||||
}
|
|
||||||
|
|
||||||
self.text.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint {
|
|
||||||
let offset = self.display_point_to_offset(point);
|
|
||||||
self.text.offset_to_point(offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn point_to_display_point(&self, point: TreeSitterPoint) -> WrapDisplayPoint {
|
|
||||||
let offset = self.text.point_to_offset(point);
|
|
||||||
self.offset_to_display_point(offset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A display point within the soft-wrapped text.
|
|
||||||
///
|
|
||||||
/// This represents a position in the text after soft-wrapping,
|
|
||||||
/// with an additional `local_row` field tracking the wrap line
|
|
||||||
/// within the original buffer line.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub(crate) struct WrapDisplayPoint {
|
|
||||||
/// The 0-based soft wrapped row index in the text.
|
|
||||||
pub row: usize,
|
|
||||||
/// The 0-based row index in local line (include first line).
|
|
||||||
///
|
|
||||||
/// This value only valid when return from [`TextWrapper::offset_to_display_point`], otherwise it will be ignored.
|
|
||||||
pub local_row: usize,
|
|
||||||
/// The 0-based column byte index in the display line (with soft wrap).
|
|
||||||
pub column: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WrapDisplayPoint {
|
|
||||||
pub fn new(row: usize, local_row: usize, column: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
row,
|
|
||||||
local_row,
|
|
||||||
column,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The layout info of a line with soft wrapped lines.
|
|
||||||
pub(crate) struct LineLayout {
|
|
||||||
/// Total bytes length of this line.
|
|
||||||
len: usize,
|
|
||||||
/// The soft wrapped lines of this line (Include the first line).
|
|
||||||
pub(crate) wrapped_lines: SmallVec<[ShapedLine; 1]>,
|
|
||||||
pub(crate) longest_width: Pixels,
|
|
||||||
pub(crate) whitespace_indicators: Option<WhitespaceIndicators>,
|
|
||||||
/// Whitespace indicators: (line_index, x_position, is_tab)
|
|
||||||
pub(crate) whitespace_chars: Vec<(usize, Pixels, bool)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LineLayout {
|
|
||||||
pub(crate) fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
len: 0,
|
|
||||||
longest_width: px(0.),
|
|
||||||
wrapped_lines: SmallVec::new(),
|
|
||||||
whitespace_chars: Vec::new(),
|
|
||||||
whitespace_indicators: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn lines(mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) -> Self {
|
|
||||||
self.set_wrapped_lines(wrapped_lines);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn set_wrapped_lines(&mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) {
|
|
||||||
self.len = wrapped_lines.iter().map(|l| l.len).sum();
|
|
||||||
let width = wrapped_lines
|
|
||||||
.iter()
|
|
||||||
.map(|l| l.width)
|
|
||||||
.max()
|
|
||||||
.unwrap_or_default();
|
|
||||||
self.longest_width = width;
|
|
||||||
self.wrapped_lines = wrapped_lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn with_whitespaces(mut self, indicators: Option<WhitespaceIndicators>) -> Self {
|
|
||||||
self.whitespace_indicators = indicators;
|
|
||||||
let Some(indicators) = self.whitespace_indicators.as_ref() else {
|
|
||||||
return self;
|
|
||||||
};
|
|
||||||
|
|
||||||
let space_indicator_offset = indicators.space.width.half();
|
|
||||||
|
|
||||||
for (line_index, wrapped_line) in self.wrapped_lines.iter().enumerate() {
|
|
||||||
for (relative_offset, c) in wrapped_line.text.char_indices() {
|
|
||||||
if matches!(c, ' ' | '\t') {
|
|
||||||
let is_tab = c == '\t';
|
|
||||||
let start_x = wrapped_line.x_for_index(relative_offset);
|
|
||||||
let end_x = wrapped_line.x_for_index(relative_offset + c.len_utf8());
|
|
||||||
// Center the indicator in the actual character's space
|
|
||||||
let x_position = if c == ' ' {
|
|
||||||
(start_x + end_x).half() - space_indicator_offset
|
|
||||||
} else {
|
|
||||||
start_x
|
|
||||||
};
|
|
||||||
|
|
||||||
self.whitespace_chars.push((line_index, x_position, is_tab));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn len(&self) -> usize {
|
|
||||||
self.len
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the position (x, y) for the given index in this line layout.
|
|
||||||
///
|
|
||||||
/// - The `offset` is a local byte index in this line layout.
|
|
||||||
/// - When `line_end_affinity` is true, an offset at a soft wrap boundary is placed at
|
|
||||||
/// the end of the current visual line rather than the start of the next one.
|
|
||||||
/// - The return value is relative to the top-left corner of this line layout, start from (0, 0)
|
|
||||||
pub(crate) fn position_for_index(
|
|
||||||
&self,
|
|
||||||
offset: usize,
|
|
||||||
last_layout: &LastLayout,
|
|
||||||
line_end_affinity: bool,
|
|
||||||
) -> Option<Point<Pixels>> {
|
|
||||||
let mut acc_len = 0;
|
|
||||||
let mut offset_y = px(0.);
|
|
||||||
|
|
||||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
|
||||||
|
|
||||||
for (i, line) in self.wrapped_lines.iter().enumerate() {
|
|
||||||
let is_last = i + 1 == self.wrapped_lines.len();
|
|
||||||
|
|
||||||
let matches = if line.len == 0 {
|
|
||||||
// Empty visual lines still own their boundary offset.
|
|
||||||
offset == acc_len
|
|
||||||
} else if is_last || line_end_affinity {
|
|
||||||
// Inclusive: cursor can sit at end of this visual line.
|
|
||||||
offset >= acc_len && offset <= acc_len + line.len
|
|
||||||
} else {
|
|
||||||
// Exclusive: boundary offset belongs to the next visual line.
|
|
||||||
offset >= acc_len && offset < acc_len + line.len
|
|
||||||
};
|
|
||||||
|
|
||||||
if matches {
|
|
||||||
let x = line.x_for_index(offset.saturating_sub(acc_len)) + x_offset;
|
|
||||||
return Some(point(x, offset_y));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always advance by actual line length. The last line gets +1 so the
|
|
||||||
// cursor can be placed after the final character.
|
|
||||||
acc_len += if is_last { line.len + 1 } else { line.len };
|
|
||||||
offset_y += last_layout.line_height;
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the closest index for the given x in this line layout.
|
|
||||||
pub(crate) fn closest_index_for_x(&self, x: Pixels, last_layout: &LastLayout) -> usize {
|
|
||||||
let mut acc_len = 0;
|
|
||||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
|
||||||
let x = x - x_offset;
|
|
||||||
|
|
||||||
for (i, line) in self.wrapped_lines.iter().enumerate() {
|
|
||||||
let is_last = i + 1 == self.wrapped_lines.len();
|
|
||||||
if x <= line.width {
|
|
||||||
let mut ix = line.closest_index_for_x(x);
|
|
||||||
if !is_last && ix == line.text.len() {
|
|
||||||
// For soft wrap line, we can't put the cursor at the end of the line.
|
|
||||||
let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0);
|
|
||||||
ix = ix.saturating_sub(c_len);
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc_len + ix;
|
|
||||||
}
|
|
||||||
acc_len += line.text.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
acc_len
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the index for the given position (x, y) in this line layout.
|
|
||||||
///
|
|
||||||
/// The `pos` is relative to the top-left corner of this line layout, start from (0, 0)
|
|
||||||
/// The return value is a local byte index in this line layout, start from 0.
|
|
||||||
pub(crate) fn closest_index_for_position(
|
|
||||||
&self,
|
|
||||||
pos: Point<Pixels>,
|
|
||||||
last_layout: &LastLayout,
|
|
||||||
) -> Option<usize> {
|
|
||||||
let mut offset = 0;
|
|
||||||
let mut line_top = px(0.);
|
|
||||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
|
||||||
for (i, line) in self.wrapped_lines.iter().enumerate() {
|
|
||||||
let is_last = i + 1 == self.wrapped_lines.len();
|
|
||||||
let line_bottom = line_top + last_layout.line_height;
|
|
||||||
if pos.y >= line_top && pos.y < line_bottom {
|
|
||||||
let mut ix = line.closest_index_for_x(pos.x - x_offset);
|
|
||||||
if !is_last && ix == line.text.len() {
|
|
||||||
// For soft wrap line, we can't put the cursor at the end of the line.
|
|
||||||
let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0);
|
|
||||||
ix = ix.saturating_sub(c_len);
|
|
||||||
}
|
|
||||||
return Some(offset + ix);
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += line.text.len();
|
|
||||||
line_top = line_bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn index_for_position(
|
|
||||||
&self,
|
|
||||||
pos: Point<Pixels>,
|
|
||||||
last_layout: &LastLayout,
|
|
||||||
) -> Option<usize> {
|
|
||||||
let mut offset = 0;
|
|
||||||
let mut line_top = px(0.);
|
|
||||||
let x_offset = last_layout.alignment_offset(self.longest_width);
|
|
||||||
for line in self.wrapped_lines.iter() {
|
|
||||||
let line_bottom = line_top + last_layout.line_height;
|
|
||||||
if pos.y >= line_top && pos.y < line_bottom {
|
|
||||||
let ix = line.index_for_x(pos.x - x_offset)?;
|
|
||||||
return Some(offset + ix);
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += line.text.len();
|
|
||||||
line_top = line_bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn size(&self, line_height: Pixels) -> Size<Pixels> {
|
|
||||||
size(self.longest_width, self.wrapped_lines.len() * line_height)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn paint(
|
|
||||||
&self,
|
|
||||||
pos: Point<Pixels>,
|
|
||||||
line_height: Pixels,
|
|
||||||
text_align: TextAlign,
|
|
||||||
align_width: Option<Pixels>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
for (ix, line) in self.wrapped_lines.iter().enumerate() {
|
|
||||||
_ = line.paint(
|
|
||||||
pos + point(px(0.), ix * line_height),
|
|
||||||
line_height,
|
|
||||||
text_align,
|
|
||||||
align_width,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paint whitespace indicators
|
|
||||||
if let Some(indicators) = self.whitespace_indicators.as_ref() {
|
|
||||||
for (line_index, x_position, is_tab) in &self.whitespace_chars {
|
|
||||||
let invisible = if *is_tab {
|
|
||||||
indicators.tab.clone()
|
|
||||||
} else {
|
|
||||||
indicators.space.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let origin = point(
|
|
||||||
pos.x + *x_position,
|
|
||||||
pos.y + *line_index as f32 * line_height,
|
|
||||||
);
|
|
||||||
|
|
||||||
_ = invisible.paint(origin, line_height, text_align, align_width, window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||