30 Commits

Author SHA1 Message Date
0faa8694f8 use instant 2026-07-28 16:39:09 +07:00
5ddae637f6 . 2026-07-28 16:28:05 +07:00
0ae38d38d4 . 2026-07-28 16:22:19 +07:00
4253a6df3e fix 2026-07-28 15:43:44 +07:00
3a95456917 update web 2026-07-28 14:03:56 +07:00
a8bdd49072 . 2026-07-27 17:43:31 +07:00
fd32b8c0ce . 2026-07-27 17:22:50 +07:00
8d4d8184e6 . 2026-07-27 16:47:32 +07:00
e85974497b fix 2026-07-27 16:29:25 +07:00
da7167013f . 2026-07-27 16:07:25 +07:00
2ff83079b8 . 2026-07-27 15:02:50 +07:00
b349656c56 . 2026-07-27 10:46:10 +07:00
8bbb472103 optimize 2026-07-27 10:14:19 +07:00
dfda7ff157 clean up 2026-07-27 09:25:38 +07:00
f46f15e10c update 2026-07-27 09:23:26 +07:00
2f15615d7b clean up input component 2026-07-27 08:38:25 +07:00
9addbd49f4 fix web build 2026-07-27 08:09:55 +07:00
b518c729f6 feat: add initial support web via wasm (#35)
Reviewed-on: #35
2026-07-25 03:43:51 +00:00
4b57a1d2a6 chore: update deps 2026-07-16 10:43:16 +07:00
5ea6cdb34f chore: prepare for rc version (#34)
**TODOs:**

- [x] Fix all clippy issues
- [x] Make NIP-4e optional (disabled by default)
- [x] Remove support for bunker (Nostr Connect)
- [x] Group messages in the same timeframe
- [ ] ...

Reviewed-on: #34
2026-06-19 09:16:37 +00:00
1f04a824d7 chore: release version 1.0.0-beta5 2026-06-03 20:20:30 +07:00
c78e0a5163 fix: chat input crashing when moving the cursor (#33)
Reviewed-on: #33
2026-06-03 13:18:03 +00:00
5d4c8634ef chore: update gpui 2026-06-01 15:28:49 +07:00
4efeec08c4 chore: connect to search relays only when needed 2026-06-01 15:07:11 +07:00
2e451aae12 chore: fix ci and clean up 2026-05-30 07:51:04 +07:00
cdfcfdd782 chore: release version 1.0.0-beta4 2026-05-28 09:02:17 +07:00
9817dd29a6 nip4e: remove app name in the client tag 2026-05-22 07:52:49 +07:00
f066cb8223 chore: force gossip ignore neg sync 2026-05-20 18:03:46 +07:00
6d60726f27 chore: bump gpui 2026-04-25 07:01:14 +07:00
80186a79e5 chore: update deps and remove mobile crate 2026-04-17 07:41:52 +07:00
120 changed files with 8521 additions and 8244 deletions

View File

@@ -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: crates/coop working-directory: desktop
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: softprops/action-gh-release@v2 uses: akkuman/gitea-release-action@v1
with: with:
tag_name: ${{ steps.version.outputs.tag }} server_url: "https://git.reya.su/"
name: ${{ steps.version.outputs.tag }} repository: "reya/coop"
token: ${{ secrets.GITEA_TOKEN }}
draft: true draft: true
prerelease: false prerelease: false
generate_release_notes: true
files: | files: |
artifacts/**/* artifacts/**/*

4
.gitignore vendored
View File

@@ -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/

4007
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["crates/*", "desktop", "web", "mobile"] members = ["crates/*", "desktop", "web"]
default-members = ["desktop"] default-members = ["desktop"]
[workspace.package] [workspace.package]
version = "1.0.0-beta3" version = "1.0.0-beta5"
edition = "2024" edition = "2024"
publish = false publish = false
@@ -20,11 +20,12 @@ 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-connect = { 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-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 = [ "nip96", "nip59", "nip49", "nip44" ] } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
# Others # Others
anyhow = "1.0.44" anyhow = "1.0.44"
@@ -34,15 +35,20 @@ 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 = "8.5.0" rust-embed = { version = "8.5", features = ["include-exclude"] }
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"] } 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

View File

@@ -1,54 +0,0 @@
# Coop - Gemini Context
## Project Overview
Coop is a simple, fast, and reliable Nostr client for secure messaging across all platforms. It is written in Rust and structured as a Cargo workspace. The project utilizes:
- **GPUI**: The GPU-accelerated UI framework developed by Zed Industries for cross-platform, high-performance user interfaces.
- **Rust Nostr SDK**: For handling the Nostr protocol (including NIPs like 44, 49, 59, 96).
The workspace is divided into several sub-crates under `crates/`, including the main application (`coop`), UI components (`ui`, `chat_ui`, `title_bar`, `theme`), and domain logic (`chat`, `person`, `relay_auth`, `state`, `device`).
## Building and Running
### Prerequisites
- **Rust Toolchain**: Use `rustup` to install the Rust toolchain.
- **System Dependencies**: Depending on your OS, you must run the provided setup scripts to install necessary libraries before compiling.
### Commands
- **Install Linux Dependencies**:
```bash
./script/linux
```
- **Install FreeBSD Dependencies**:
```bash
./script/freebsd
```
- **Install macOS Dependencies**:
```bash
./script/macos
```
- **Build the project** (debug mode):
```bash
cargo build
```
- **Run the application**:
```bash
cargo run
```
- **Build for Production** (optimized release binary):
```bash
cargo build --release
```
### Packaging
- Packaging scripts and manifests are available in the `script/` directory (e.g., `bundle-linux`, `bundle-snap`, `prepare-flathub`) and the `flathub/` directory.
## Development Conventions
- **Code Formatting**: The project enforces a strict Rust code formatting style via `rustfmt.toml`.
- **Edition**: 2024
- **Indentation**: Block style with 4 tab spaces.
- **Imports**: Grouped by `StdExternalCrate`, with module granularity and automatic reordering of imports, modules, and impl items.
- **Modularity**: Code is split into focused crates (e.g., separating UI code in `chat_ui` from core messaging logic in `chat`). When contributing, ensure your code respects these boundaries.
- **Contributing**: Contributions are made via Pull Requests on the `lumehq/coop` GitHub repository. Ensure all tests pass before submitting.
- **UI Architecture**: Because Coop uses GPUI, UI components are built using GPUI's element tree structure, event handling, and view contexts.

View File

@@ -1,12 +1,12 @@
![Coop](/docs/coop.png) ![Coop](/docs/coop.png)
<p> <p>
<a href="https://github.com/lumehq/coop/actions/workflows/rust.yml"> <a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/rust.yml/badge.svg"> <img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
</a> </a>
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/lumehq/coop"> <img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/lumehq/coop"> <img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/lumehq/coop"> <img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/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/lumehq/coop/releases). - Visit the [Coop Releases page on GitHub](https://github.com/reyakov/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/lumehq/coop.git git clone https://github.com/reyakov/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/lumehq/coop/issues/) - [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
### License ### License

View File

@@ -8,9 +8,8 @@ publish.workspace = true
common = { path = "../common" } common = { path = "../common" }
gpui.workspace = true gpui.workspace = true
gpui_tokio.workspace = true instant.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"] }
@@ -18,4 +17,6 @@ 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

View File

@@ -1,7 +1,8 @@
#![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};
@@ -9,6 +10,7 @@ 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};

View File

@@ -13,15 +13,16 @@ 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
smol.workspace = true
log.workspace = true log.workspace = true
futures.workspace = true
flume.workspace = true flume.workspace = true
serde.workspace = true
serde_json.workspace = true
futures.workspace = true
fuzzy-matcher = "0.3.7" fuzzy-matcher = "0.3.7"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,106 @@ use common::{EventExt, NostrParser, extract_and_remove_media_urls};
use gpui::{SharedString, SharedUri}; use gpui::{SharedString, SharedUri};
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 {
@@ -44,74 +144,6 @@ 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,
@@ -124,106 +156,6 @@ 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,
/// 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 RenderedMessage {
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 RenderedMessage {
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 RenderedMessage {
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 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();
@@ -242,13 +174,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.filter(TagKind::e()) { for tag in inner.iter().filter(|tag| tag.kind() == "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.filter(TagKind::q()) { for tag in inner.iter().filter(|tag| tag.kind() == "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);
} }

View File

@@ -1,15 +1,16 @@
use std::cmp::Ordering; use std::cmp::Ordering;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::time::Duration; use instant::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}; use state::{NostrRegistry, TIMEOUT, UniversalSigner};
use crate::NewMessage; use crate::NewMessage;
@@ -21,7 +22,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>>, pub output: Option<Output<EventId, EventSendStatus>>,
} }
impl SendReport { impl SendReport {
@@ -41,7 +42,7 @@ impl SendReport {
} }
/// Set the output. /// Set the output.
pub fn output(mut self, output: Output<EventId>) -> Self { pub fn output(mut self, output: Output<EventId, EventSendStatus>) -> Self {
self.output = Some(output); self.output = Some(output);
self self
} }
@@ -171,7 +172,8 @@ 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
.find(TagKind::Subject) .iter()
.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 {
@@ -205,7 +207,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)
.build(author); .finalize_unsigned(author);
// Ensure that the ID is set // Ensure that the ID is set
event.ensure_id(); event.ensure_id();
@@ -361,22 +363,31 @@ impl Room {
.exit_policy(ReqExitPolicy::ExitOnEOSE) .exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT))); .timeout(Some(Duration::from_secs(TIMEOUT)));
for public_key in members.into_iter() { let tasks: Vec<_> = members
let inbox = Filter::new() .into_iter()
.author(public_key) .map(|public_key| {
.kind(Kind::InboxRelays) let client = client.clone();
.limit(1); async move {
let inbox = Filter::new()
.author(public_key)
.kind(Kind::InboxRelays)
.limit(1);
let announcement = Filter::new() let announcement = Filter::new()
.author(public_key) .author(public_key)
.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(())
@@ -387,12 +398,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 conversation_id = self.id.to_string(); let room_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::C), conversation_id); .custom_tag(SingleLetterTag::lowercase(Alphabet::R), room_id);
let messages = client let messages = client
.database() .database()
@@ -400,10 +411,6 @@ impl Room {
.await? .await?
.into_iter() .into_iter()
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok()) .filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
.filter(|event| {
// Only process private direct messages and file messages
event.kind == Kind::PrivateDirectMessage || event.kind == Kind::Custom(15)
})
.sorted_by_key(|message| message.created_at) .sorted_by_key(|message| message.created_at)
.collect(); .collect();
@@ -425,24 +432,14 @@ 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).signer().public_key()?; let sender = nostr.read(cx).current_user()?;
// 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::from_standardized_without_cell(TagStandard::Subject( tags.push(Tag::custom("subject", vec![value.to_string()]));
value.to_string(),
)));
} }
// Add all reply tags // Add all reply tags
@@ -450,21 +447,23 @@ impl Room {
tags.push(Tag::event(id)) tags.push(Tag::event(id))
} }
// Add all receiver tags // Add all receiver tags (no intermediate allocation)
for member in members.into_iter() { for public_key in self.members.iter().filter(|pk| *pk != &sender) {
tags.push(Tag::from_standardized_without_cell( let member = persons.read(cx).get(public_key, cx);
TagStandard::PublicKey { tags.push(
Nip01Tag::PublicKey {
public_key: member.public_key(), public_key: member.public_key(),
relay_url: member.messaging_relay_hint(), relay_hint: member.messaging_relay_hint(),
alias: None, }
uppercase: false, .to_tag(),
}, );
));
} }
// 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).tags(tags).build(sender); let mut event = EventBuilder::new(kind, content)
.tags(tags)
.finalize_unsigned(sender);
// Ensure that the ID is set // Ensure that the ID is set
event.ensure_id(); event.ensure_id();
@@ -472,17 +471,45 @@ 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 signer = nostr.read(cx).signer(); let user_signer = nostr.read(cx).signer();
let current_user = nostr.read(cx).current_user()?;
// Get current user's public key // Get sender's profile
let public_key = nostr.read(cx).signer().public_key()?; let persons = PersonRegistry::global(cx);
let sender = persons.read(cx).get(&public_key, cx); let sender = persons.read(cx).get(&current_user, cx);
// Get all members (excluding sender) // Get all members (excluding sender)
let members: Vec<Person> = self let members: Vec<Person> = self
@@ -496,9 +523,6 @@ 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();
@@ -523,23 +547,12 @@ impl Room {
} }
// Determine the signer to use // Determine the signer to use
let signer = match signer_kind { let signer = Self::select_signer(
SignerKind::Auto => { signer_kind,
if announcement.is_some() announcement.is_some(),
&& let Some(encryption_signer) = encryption_signer.clone() &encryption_signer,
{ &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 {
@@ -559,23 +572,12 @@ 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 = match signer_kind { let signer = Self::select_signer(
SignerKind::Auto => { signer_kind,
if sender.announcement().is_some() sender.announcement().is_some(),
&& let Some(encryption_signer) = encryption_signer.clone() &encryption_signer,
{ &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),
@@ -592,17 +594,14 @@ impl Room {
} }
// Helper function to send a gift-wrapped event // Helper function to send a gift-wrapped event
async fn send_gift_wrap<T>( async fn send_gift_wrap(
client: &Client, client: &Client,
signer: &T, signer: &UniversalSigner,
receiver: &Person, receiver: &Person,
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
config: &SignerKind, config: &SignerKind,
) -> Result<SendReport, Error> ) -> Result<SendReport, Error> {
where let k_tag = Tag::custom("k", vec!["14"]);
T: NostrSigner + 'static,
{
let k_tag = Tag::custom(TagKind::k(), vec!["14"]);
let mut extra_tags = vec![k_tag]; let mut extra_tags = vec![k_tag];
// Determine the receiver public key based on the config // Determine the receiver public key based on the config
@@ -627,7 +626,10 @@ where
}; };
// Construct the gift wrap event // Construct the gift wrap event
let event = EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), extra_tags).await?; let event = nip59::GiftWrapBuilder::new(receiver, rumor.clone())
.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

View File

@@ -14,19 +14,15 @@ 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"

View File

@@ -1,30 +1,30 @@
use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc; use std::sync::{Arc, RwLock};
pub use actions::*; pub use actions::*;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::{Context as AnyhowContext, Error};
use chat::{ChatRegistry, Message, RenderedMessage, Room, RoomEvent, SendReport, SendStatus}; use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
use common::{TimestampExt, coop_cache}; use common::{TimestampExt, coop_cache};
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, SharedUri,
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, div,
deferred, div, img, list, px, red, relative, svg, white, img, list, px, red, 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::{InputEvent, InputState, TextInput}; use ui::input::{Input, InputEvent, InputState};
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,9 +38,6 @@ 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))
} }
@@ -63,7 +60,7 @@ pub struct ChatPanel {
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: Entity<BTreeMap<EventId, Vec<SendReport>>>, reports_by_id: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
/// Chat input state /// Chat input state
input: Entity<InputState>, input: Entity<InputState>,
@@ -75,7 +72,7 @@ pub struct ChatPanel {
subject_bar: Entity<bool>, subject_bar: Entity<bool>,
/// Sent message ids /// Sent message ids
sent_ids: Arc<RwLock<Vec<EventId>>>, sent_ids: Arc<Mutex<Vec<EventId>>>,
/// Replies to /// Replies to
replies_to: Entity<HashSet<EventId>>, replies_to: Entity<HashSet<EventId>>,
@@ -98,10 +95,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 = cx.new(|_| BTreeMap::new()); let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
// Define list of messages // Define list of messages
let messages = BTreeSet::from([Message::system()]); let messages = BTreeSet::default();
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
@@ -119,7 +116,6 @@ 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()
}); });
@@ -173,7 +169,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(RwLock::new(Vec::new())), sent_ids: Arc::new(Mutex::new(Vec::new())),
uploading: false, uploading: false,
subscriptions, subscriptions,
tasks: vec![], tasks: vec![],
@@ -193,7 +189,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.downgrade(); let reports = self.reports_by_id.clone();
let (tx, rx) = flume::bounded::<Arc<SendStatus>>(256); let (tx, rx) = flume::bounded::<Arc<SendStatus>>(256);
@@ -208,7 +204,7 @@ impl ChatPanel {
message, message,
} = *message } = *message
{ {
let sent_ids = sent_ids.read().await; let sent_ids = sent_ids.lock().await;
if sent_ids.contains(&event_id) { if sent_ids.contains(&event_id) {
let status = if status { let status = if status {
@@ -224,10 +220,11 @@ 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| { {
for reports in this.values_mut() { let mut map = reports.write().unwrap();
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;
@@ -235,7 +232,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()); output.success.insert(relay.clone(), EventSendStatus::Sent);
} }
} }
SendStatus::Failed { id, relay, message } => { SendStatus::Failed { id, relay, message } => {
@@ -244,10 +241,10 @@ impl ChatPanel {
} }
} }
} }
cx.notify();
} }
} }
})?; }
this.update(cx, |_, cx| cx.notify()).ok();
} }
Ok(()) Ok(())
})); }));
@@ -346,69 +343,47 @@ 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();
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let room = room.upgrade().context("Room is not available")?;
this.update_in(cx, |this, window, cx| {
match room.read(cx).rumor(content, replies, cx) {
Some(rumor) => {
this.insert_message(&rumor, true, cx);
this.send_and_wait(rumor, window, cx);
this.clear(window, cx);
}
None => {
window.push_notification("Failed to create message", cx);
}
}
})?;
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(); let sent_ids = self.sent_ids.clone();
// This can't fail, because we already ensured that the ID is set // Upgrade room and create rumor + send task in a single read lock
let id = rumor.id.unwrap(); let Some(room_entity) = room.upgrade() else {
return;
};
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
let rumor = room.rumor(content.clone(), replies, cx)?;
let send_task = room.send(rumor.clone(), cx)?;
Some((rumor, send_task))
}) {
Some(pair) => pair,
None => {
window.push_notification("Failed to create message", cx);
return;
}
};
// Add empty reports let id = rumor.id.expect("rumor must have an id");
// Insert optimistic message and clear input
self.insert_message(&rumor, true, cx);
self.insert_reports(id, vec![], cx); self.insert_reports(id, vec![], cx);
self.clear(window, cx);
// Upgrade room reference // Spawn a single task to await the send and update reports
let Some(room) = self.room.upgrade() else {
return;
};
// Get the send message task
let Some(task) = room.read(cx).send(rumor, cx) else {
window.push_notification("Failed to send message", cx);
return;
};
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Send and get reports let outputs = send_task.await;
let outputs = task.await;
// Add sent IDs to the list let mut sent_ids = sent_ids.lock().await;
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
@@ -430,10 +405,13 @@ 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.update(cx, |this, cx| { self.reports_by_id
this.entry(id).or_default().extend(reports); .write()
cx.notify(); .unwrap()
}); .entry(id)
.or_default()
.extend(reports);
cx.notify();
} }
/// Insert a message into the chat panel /// Insert a message into the chat panel
@@ -467,41 +445,22 @@ 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(cx).get(id).is_some() self.reports_by_id.read().unwrap().get(id).is_some()
} }
/// Check if a message was encrypted by the dekey fn sent_reports(&self, id: &EventId, _cx: &App) -> Option<Vec<SendReport>> {
fn encrypted_by_dekey(&self, id: &EventId, cx: &App) -> bool { self.reports_by_id.read().unwrap().get(id).cloned()
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<&RenderedMessage> { fn message(&self, id: &EventId) -> Option<&Message> {
self.messages.iter().find_map(|msg| { self.messages.iter().find(|msg| &msg.id == id)
if let Message::User(rendered) = msg
&& &rendered.id == id
{
return Some(rendered);
}
None
})
} }
fn scroll_to(&self, id: EventId) { /// Scroll to a message by its ID
if let Some(ix) = self.messages.iter().position(|m| { fn scroll_to(&self, id: &EventId) {
if let Message::User(msg) = m { if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) {
msg.id == id
} else {
false
}
}) {
self.list_state.scroll_to_reveal_item(ix); self.list_state.scroll_to_reveal_item(ix);
} }
} }
@@ -621,13 +580,19 @@ impl ChatPanel {
}) })
.is_err() .is_err()
{ {
window.push_notification( window.push_notification(Notification::error("Failed to change subject"), cx);
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| {
@@ -635,10 +600,7 @@ impl ChatPanel {
}) })
.is_err() .is_err()
{ {
window.push_notification( window.push_notification(Notification::error("Failed to change signer"), cx);
Notification::error("Failed to change signer").autohide(false),
cx,
);
} }
} }
Command::ToggleBackup => { Command::ToggleBackup => {
@@ -649,10 +611,7 @@ impl ChatPanel {
}) })
.is_err() .is_err()
{ {
window.push_notification( window.push_notification(Notification::error("Failed to toggle backup"), cx);
Notification::error("Failed to toggle backup").autohide(false),
cx,
);
} }
} }
Command::Copy(public_key) => { Command::Copy(public_key) => {
@@ -749,9 +708,11 @@ impl ChatPanel {
cx.open_url(&content); cx.open_url(&content);
} }
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement { fn render_announcement(&self, 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()
@@ -768,7 +729,7 @@ impl ChatPanel {
.size_12() .size_12()
.text_color(cx.theme().ghost_element_active), .text_color(cx.theme().ghost_element_active),
) )
.child(SharedString::from(ANNOUNCEMENT)) .child(MSG)
.into_any_element() .into_any_element()
} }
@@ -805,6 +766,34 @@ 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;
}
let mut iter = self.messages.iter();
if let Some(previous) = iter.nth(ix - 1)
&& let Some(current) = iter.next()
{
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,
@@ -812,24 +801,17 @@ impl ChatPanel {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
if let Some(message) = self.messages.iter().nth(ix) { if let Some(message) = self.messages.iter().nth(ix) {
match message { let persons = PersonRegistry::global(cx);
Message::User(rendered) => { let show_author = self.is_group_start(ix);
let persons = PersonRegistry::global(cx); 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, rendered, text, cx) self.render_text_message(ix, message, text, show_author, 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)
} }
@@ -838,8 +820,9 @@ impl ChatPanel {
fn render_text_message( fn render_text_message(
&self, &self,
ix: usize, ix: usize,
message: &RenderedMessage, message: &Message,
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;
@@ -849,7 +832,6 @@ 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);
@@ -866,17 +848,21 @@ impl ChatPanel {
.flex() .flex()
.gap_3() .gap_3()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
this.child( if show_author {
Avatar::new(author.avatar()) this.child(
.flex_shrink_0() Avatar::new(author.avatar())
.relative() .flex_shrink_0()
.dropdown_menu(move |this, _window, _cx| { .relative()
this.menu("Public Key", Box::new(Command::Copy(pk))) .dropdown_menu(move |this, _window, _cx| {
.menu("View Relays", Box::new(Command::Relays(pk))) this.menu("Public Key", Box::new(Command::Copy(pk)))
.separator() .menu("View Relays", Box::new(Command::Relays(pk)))
.menu("View on njump.me", Box::new(Command::Njump(pk))) .separator()
}), .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()
@@ -884,33 +870,24 @@ impl ChatPanel {
.w_full() .w_full()
.flex_initial() .flex_initial()
.overflow_hidden() .overflow_hidden()
.child( .when(show_author, |this| {
h_flex() this.child(
.gap_2() h_flex()
.text_sm() .gap_2()
.text_color(cx.theme().text_placeholder) .text_sm()
.child( .text_color(cx.theme().text_placeholder)
div() .child(
.font_semibold() div()
.text_color(cx.theme().text) .font_semibold()
.child(author.name()), .text_color(cx.theme().text)
) .child(author.name()),
.when(encrypted_by_dekey, |this| {
this.child(
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())
.child(message.created_at.to_human_time()) .when(has_reports, |this| {
.when(has_reports, |this| { this.child(self.render_sent_reports(&id, cx))
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))
}) })
@@ -1028,7 +1005,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);
}) })
}), }),
); );
@@ -1177,7 +1154,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.to_string())), .child(SharedString::from(url.0.to_string())),
) )
.child( .child(
div() div()
@@ -1421,7 +1398,7 @@ impl ChatPanel {
.icon(IconName::Emoji) .icon(IconName::Emoji)
.ghost() .ghost()
.large() .large()
.dropdown_menu_with_anchor(gpui::Corner::BottomLeft, move |this, _window, _cx| { .dropdown_menu_with_anchor(gpui::Anchor::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("👎")))
@@ -1498,7 +1475,7 @@ impl Render for ChatPanel {
.border_b_1() .border_b_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.child( .child(
TextInput::new(&self.subject_input) Input::new(&self.subject_input)
.text_sm() .text_sm()
.small() .small()
.bordered(false), .bordered(false),
@@ -1519,15 +1496,28 @@ impl Render for ChatPanel {
v_flex() v_flex()
.flex_1() .flex_1()
.relative() .relative()
.child( .map(|this| {
list( if self.messages.is_empty() {
self.list_state.clone(), this.child(
cx.processor(move |this, ix, window, cx| { div()
this.render_message(ix, window, cx) .size_full()
}), .flex()
) .items_center()
.size_full(), .justify_end()
) .child(self.render_announcement(cx)),
)
} else {
this.child(
list(
self.list_state.clone(),
cx.processor(move |this, ix, window, cx| {
this.render_message(ix, window, cx)
}),
)
.size_full(),
)
}
})
.child(Scrollbar::vertical(&self.list_state)), .child(Scrollbar::vertical(&self.list_state)),
) )
.child( .child(
@@ -1553,12 +1543,7 @@ impl Render for ChatPanel {
this.upload(window, cx); this.upload(window, cx);
})), })),
) )
.child( .child(Input::new(&self.input).appearance(false).flex_1())
TextInput::new(&self.input)
.appearance(false)
.text_sm()
.flex_1(),
)
.child( .child(
h_flex() h_flex()
.pl_1() .pl_1()

View File

@@ -7,17 +7,14 @@ 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"
bech32 = "0.11.1"
regex = "1.10" regex = "1.10"

View File

@@ -1,5 +1,5 @@
use std::marker::PhantomData; use std::marker::PhantomData;
use std::time::Duration; use instant::Duration;
use futures::channel::oneshot; use futures::channel::oneshot;
use futures::FutureExt; use futures::FutureExt;

View File

@@ -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().copied().collect(); let mut public_keys: Vec<PublicKey> = self.tags.public_keys().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().copied().collect(); let mut public_keys: Vec<PublicKey> = self.tags.public_keys().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()
} }

View File

@@ -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
serde_json.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true

View File

@@ -2,7 +2,9 @@ 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::time::Duration; use std::sync::Arc;
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::{
@@ -11,7 +13,9 @@ use gpui::{
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use state::{Announcement, NostrRegistry, StateEvent, TIMEOUT, app_name}; use settings::AppSettings;
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;
@@ -19,8 +23,6 @@ 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);
@@ -35,10 +37,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),
} }
@@ -57,17 +59,20 @@ 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
_subscription: Option<Subscription>, _subscriptions: SmallVec<[Subscription; 2]>,
} }
impl EventEmitter<DeviceEvent> for DeviceRegistry {} impl EventEmitter<DeviceEvent> for DeviceRegistry {}
@@ -86,35 +91,54 @@ 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);
// Subscribe to nostr state events let signer = cx.new(|_| None);
let subscription = cx.subscribe_in(&nostr, window, |this, _e, event, _window, cx| { let mut subscriptions = smallvec![];
if event == &StateEvent::SignerSet {
this.set_initializing(true, cx); subscriptions.push(
this.get_announcement(cx); // Subscribe to nostr state events
}; cx.observe(&settings, move |this, settings, cx| {
}); if settings.read(cx).is_nip4e_enabled(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 {
initializing: true, signer,
pending_request: false, pending_request: false,
announcement_existed: Arc::new(AtomicBool::new(false)),
tasks: vec![], tasks: vec![],
_subscription: Some(subscription), _subscriptions: subscriptions,
} }
} }
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
@@ -126,15 +150,15 @@ impl DeviceRegistry {
} }
match event.kind { match event.kind {
Kind::Custom(4454) => { Kind::Custom(10044) if current_user == Some(event.pubkey) => {
if verify_author(&client, event.as_ref()).await { announcement_existed.store(true, Ordering::Relaxed);
tx.send_async(event.into_owned()).await?; tx.send_async(event.into_owned()).await?;
}
} }
Kind::Custom(4455) => { Kind::Custom(4454) if current_user == Some(event.pubkey) => {
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?;
} }
_ => {} _ => {}
} }
@@ -147,6 +171,11 @@ 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| {
@@ -166,48 +195,41 @@ 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<S>(&mut self, new: S, cx: &mut Context<Self>) fn set_signer(&mut self, new_signer: Keys, cx: &mut Context<Self>) {
where self.signer.update(cx, |this, cx| {
S: NostrSigner + 'static, *this = Some(UniversalSigner::new(new_signer));
{ cx.notify();
let nostr = NostrRegistry::global(cx); });
let signer = nostr.read(cx).signer(); cx.emit(DeviceEvent::Set);
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);
})?;
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).await?; let keys = get_keys(&client, &signer).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(())
@@ -218,48 +240,42 @@ 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();
let task: Task<Result<Event, Error>> = cx.background_spawn(async move { self.tasks.push(cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?; let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let public_key = signer.get_public_key().await?; let current_user = signer.get_public_key_async().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(public_key) .author(current_user)
.limit(1); .limit(1);
// Stream events from user's write relays client
let mut stream = client .subscribe(filter)
.stream_events(filter) .close_on(opts)
.timeout(Duration::from_secs(TIMEOUT)) .with_id(SubscriptionId::new("nip4e"))
.await?; .await?;
while let Some((_url, res)) = stream.next().await { Ok(())
if let Ok(event) = res { }));
return Ok(event);
}
}
Err(anyhow!("Announcement not found")) let announcement_existed = self.announcement_existed.clone();
});
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match task.await { // Wait for 5 seconds
Ok(event) => { cx.background_executor().timer(Duration::from_secs(5)).await;
// Set encryption key from the announcement event
this.update(cx, |this, cx| { // Then check if the msg relays have been found
this.set_encryption(&event, cx); if announcement_existed.load(Ordering::Acquire) {
})?; return Ok(());
}
Err(_) => {
// User has no announcement, create a new one
this.update(cx, |this, cx| {
this.set_announcement(Keys::generate(), cx);
})?;
}
} }
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::NotSet);
})?;
Ok(()) Ok(())
})); }));
} }
@@ -268,9 +284,6 @@ 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) => {
@@ -293,19 +306,20 @@ 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 builder = EventBuilder::new(Kind::Custom(10044), "").tags(vec![ let event = EventBuilder::new(Kind::Custom(10044), "")
Tag::custom(TagKind::custom("n"), vec![n]), .tags(vec![
Tag::client(app_name()), Tag::custom("n", vec![n]),
]); Tag::custom("client", vec![CLIENT_NAME]),
])
// Sign the event with user's signer .finalize_async(&signer)
let event = client.sign_event_builder(builder).await?; .await?;
// Publish announcement // Publish announcement
client client
@@ -315,7 +329,7 @@ impl DeviceRegistry {
.await?; .await?;
// Save device keys to the database // Save device keys to the database
set_keys(&client, &secret).await?; set_keys(&client, &signer, &secret).await?;
Ok(keys) Ok(keys)
}) })
@@ -325,13 +339,14 @@ 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).await?; let keys = get_keys(&client, &signer).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 {
@@ -363,7 +378,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().await?; let public_key = signer.get_public_key_async().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
@@ -385,11 +400,12 @@ 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 = nostr.read(cx).keys(); let app_keys_task = get_or_init_app_keys(cx);
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 public_key = signer.get_public_key().await?; let app_keys = app_keys_task.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()
@@ -404,13 +420,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 builder = EventBuilder::new(Kind::Custom(4454), "").tags(vec![ let event = EventBuilder::new(Kind::Custom(4454), "")
Tag::custom(TagKind::custom("P"), vec![app_pubkey]), .tags(vec![
Tag::client(app_name()), Tag::custom("P", vec![app_pubkey]),
]); Tag::custom("client", vec![CLIENT_NAME]),
])
// Sign the event with user's signer .finalize_async(&signer)
let event = client.sign_event_builder(builder).await?; .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?;
@@ -429,10 +445,7 @@ 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) => {
@@ -451,8 +464,10 @@ 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().await?; let public_key = signer.get_public_key_async().await?;
// Construct a filter for device key requests // Construct a filter for device key requests
let filter = Filter::new() let filter = Filter::new()
@@ -469,19 +484,20 @@ 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 nostr = NostrRegistry::global(cx); let app_keys_task = get_or_init_app_keys(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
.find(TagKind::custom("P")) .iter()
.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(&master, payload).await?; let decrypted = app_keys.nip44_decrypt_async(&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);
@@ -510,6 +526,7 @@ 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();
@@ -517,31 +534,32 @@ 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).await?; let keys = get_keys(&client, &signer).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
.find(TagKind::custom("P")) .iter()
.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(&target, &secret).await?; let payload = keys.nip44_encrypt_async(&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 builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![ let event = EventBuilder::new(Kind::Custom(4455), payload)
Tag::custom(TagKind::custom("P"), vec![keys.public_key().to_hex()]), .tags(vec![
Tag::public_key(target), Tag::custom("P", vec![keys.public_key().to_hex()]),
]); Tag::public_key(target),
])
// Sign the builder .finalize_async(&signer)
let event = client.sign_event_builder(builder).await?; .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?;
@@ -549,7 +567,7 @@ impl DeviceRegistry {
Ok(()) Ok(())
}); });
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(_) => { Ok(_) => {
cx.update(|window, cx| { cx.update(|window, cx| {
@@ -567,8 +585,9 @@ impl DeviceRegistry {
.ok(); .ok();
} }
}; };
})
.detach(); Ok(())
}));
} }
/// Handle encryption request /// Handle encryption request
@@ -586,6 +605,9 @@ 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);
@@ -688,29 +710,45 @@ impl DeviceRegistry {
struct DeviceNotification; struct DeviceNotification;
/// Verify the author of an event /// Get or create new app keys (async, returns a task)
async fn verify_author(client: &Client, event: &Event) -> bool { fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
if let Some(signer) = client.signer() let read = cx.read_credentials(CLIENT_NAME);
&& let Ok(public_key) = signer.get_public_key().await
{ cx.spawn(async move |cx| {
return public_key == event.pubkey; if let Ok(Some((_, secret))) = read.await
} && let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
false {
return Ok(keys);
}
// 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, secret: &str) -> Result<(), Error> { async fn set_keys(client: &Client, signer: &UniversalSigner, secret: &str) -> Result<(), Error> {
let signer = client.signer().context("Signer not found")?; let public_key = signer.get_public_key_async().await?;
let public_key = signer.get_public_key().await?; let content = signer.nip44_encrypt_async(&public_key, secret).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))
.build(public_key) .finalize_async(signer)
.sign(&Keys::generate())
.await?; .await?;
// Save the event to the database // Save the event to the database
@@ -720,9 +758,8 @@ async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> {
} }
/// Get device keys from the local database. /// Get device keys from the local database.
async fn get_keys(client: &Client) -> Result<Keys, Error> { async fn get_keys(client: &Client, signer: &UniversalSigner) -> Result<Keys, Error> {
let signer = client.signer().context("Signer not found")?; let public_key = signer.get_public_key_async().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
@@ -730,7 +767,10 @@ async fn get_keys(client: &Client) -> Result<Keys, Error> {
.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.nip44_decrypt(&public_key, &event.content).await?; let content = signer
.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);

View File

@@ -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"

View File

@@ -1,7 +1,6 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::rc::Rc; use std::sync::RwLock;
use std::time::Duration; use instant::Duration;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use common::EventExt; use common::EventExt;
@@ -24,9 +23,9 @@ impl Global for GlobalPersonRegistry {}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Dispatch { enum Dispatch {
Person(Box<Person>), Person(Person),
Announcement(Box<Event>), Announcement(Event),
Relays(Box<Event>), Relays(Event),
} }
/// Person Registry /// Person Registry
@@ -36,7 +35,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
seens: Rc<RefCell<HashSet<PublicKey>>>, seen: RwLock<HashSet<PublicKey>>,
/// Sender for requesting metadata /// Sender for requesting metadata
sender: flume::Sender<PublicKey>, sender: flume::Sender<PublicKey>,
@@ -63,53 +62,38 @@ 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 (mta_tx, mta_rx) = flume::unbounded::<PublicKey>(); let (metadata_tx, metadata_rx) = flume::unbounded::<PublicKey>();
let mut tasks = smallvec![]; let mut tasks = smallvec![];
tasks.push( let client2 = client.clone();
// Handle nostr notifications tasks.push(cx.background_spawn(async move {
cx.background_spawn({ Self::handle_notifications(&client2, &tx).await;
let client = client.clone(); }));
async move { let client3 = client.clone();
Self::handle_notifications(&client, &tx).await; tasks.push(cx.background_spawn(async move {
} Self::handle_requests(&client3, &metadata_rx).await;
}), }));
);
tasks.push( tasks.push(cx.spawn(async move |this, cx| {
// Handle metadata requests while let Ok(event) = rx.recv_async().await {
cx.background_spawn({ this.update(cx, |this, cx| {
let client = client.clone(); match event {
Dispatch::Person(person) => {
async move { this.insert(person, cx);
Self::handle_requests(&client, &mta_rx).await; }
} Dispatch::Announcement(event) => {
}), this.set_announcement(&event, cx);
); }
Dispatch::Relays(event) => {
tasks.push( this.set_messaging_relays(&event, cx);
// Update GPUI state }
cx.spawn(async move |this, cx| { };
while let Ok(event) = rx.recv_async().await { })
this.update(cx, |this, cx| { .ok();
match event { }
Dispatch::Person(person) => { }));
this.insert(*person, cx);
}
Dispatch::Announcement(event) => {
this.set_announcement(&event, cx);
}
Dispatch::Relays(event) => {
this.set_messaging_relays(&event, cx);
}
};
})
.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| {
@@ -118,8 +102,8 @@ impl PersonRegistry {
Self { Self {
persons: HashMap::new(), persons: HashMap::new(),
seens: Rc::new(RefCell::new(HashSet::new())), seen: RwLock::new(HashSet::new()),
sender: mta_tx, sender: metadata_tx,
tasks, tasks,
} }
} }
@@ -145,24 +129,25 @@ 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);
let val = Box::new(person); if tx.send_async(Dispatch::Person(person)).await.is_err() {
// Send log::warn!("PersonRegistry channel closed, dropping metadata event");
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();
// Get metadata for all public keys if let Err(e) = get_metadata(client, public_keys).await {
get_metadata(client, public_keys).await.ok(); log::warn!("Failed to get metadata for contact list: {e}");
}
} }
Kind::InboxRelays => { Kind::InboxRelays => {
let val = Box::new(event.into_owned()); tx.send_async(Dispatch::Relays(event.into_owned()))
// Send .await
tx.send_async(Dispatch::Relays(val)).await.ok(); .ok();
} }
Kind::Custom(10044) => { Kind::Custom(10044) => {
let val = Box::new(event.into_owned()); tx.send_async(Dispatch::Announcement(event.into_owned()))
// Send .await
tx.send_async(Dispatch::Announcement(val)).await.ok(); .ok();
} }
_ => {} _ => {}
} }
@@ -182,13 +167,17 @@ 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
get_metadata(client, std::mem::take(&mut batch)).await.ok(); && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
} }
} }
_ => { _ => {
if !batch.is_empty() { if !batch.is_empty()
get_metadata(client, std::mem::take(&mut batch)).await.ok(); && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
} }
} }
} }
@@ -217,7 +206,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_inserts(persons, cx); this.bulk_insert(persons, cx);
}) })
.ok(); .ok();
} }
@@ -242,7 +231,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).cloned().collect(); let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).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| {
@@ -256,7 +245,7 @@ impl PersonRegistry {
} }
/// Insert batch of persons /// Insert batch of persons
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) { fn bulk_insert(&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
@@ -290,15 +279,14 @@ impl PersonRegistry {
} }
let public_key = *public_key; let public_key = *public_key;
let mut seen = self.seens.borrow_mut();
if seen.insert(public_key) { if self.seen.write().unwrap().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();

View File

@@ -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); return SharedString::from(display_name.trim());
} }
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); return SharedString::from(name.trim());
} }
SharedString::from(shorten_pubkey(self.public_key(), 4)) SharedString::from(shorten_pubkey(self.public_key(), 4))

View File

@@ -1,377 +0,0 @@
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;

View File

@@ -10,7 +10,6 @@ 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
@@ -18,3 +17,6 @@ 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

View File

@@ -1,4 +1,3 @@
use std::collections::{HashMap, HashSet};
use std::fmt::Display; use std::fmt::Display;
use std::rc::Rc; use std::rc::Rc;
@@ -20,13 +19,15 @@ 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).values.$field.clone() Self::global(cx).read(cx).inner.read(cx).$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.values.$field = value; this.inner.update(cx, |inner, cx| {
cx.notify(); inner.$field = value;
cx.notify();
});
}); });
} }
} }
@@ -40,9 +41,9 @@ 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 auth_mode: AuthMode,
pub trusted_relays: HashSet<RelayUrl>, pub trusted_relays: Vec<String>,
pub room_configs: HashMap<u64, RoomConfig>,
pub file_server: Url, pub file_server: Url,
} }
@@ -66,10 +67,10 @@ impl Display for AuthMode {
/// 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 {
#[default]
Auto, Auto,
User,
Encryption, Encryption,
#[default]
User,
} }
impl SignerKind { impl SignerKind {
@@ -97,7 +98,7 @@ impl RoomConfig {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
backup: true, backup: true,
signer_kind: SignerKind::Auto, signer_kind: SignerKind::default(),
} }
} }
@@ -137,14 +138,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
pub nip4e: bool,
/// Authentication mode /// Authentication mode
pub auth_mode: AuthMode, 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: HashSet<RelayUrl>, pub trusted_relays: Vec<String>,
/// 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,
@@ -157,9 +158,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(), auth_mode: AuthMode::default(),
trusted_relays: HashSet::default(), trusted_relays: vec![],
room_configs: HashMap::default(),
file_server: Url::parse("https://blossom.band/").unwrap(), file_server: Url::parse("https://blossom.band/").unwrap(),
} }
} }
@@ -178,7 +179,7 @@ impl Global for GlobalAppSettings {}
/// Application settings /// Application settings
pub struct AppSettings { pub struct AppSettings {
/// Settings /// Settings
values: Settings, inner: Entity<Settings>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>, _subscriptions: SmallVec<[Subscription; 2]>,
@@ -196,11 +197,12 @@ 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_self(|this, cx| { cx.observe(&inner, |this, _inner, cx| {
this.save(cx); this.save(cx);
}), }),
); );
@@ -211,27 +213,30 @@ impl AppSettings {
}); });
Self { Self {
values: Settings::default(), inner,
_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.values = settings; self.inner.update(cx, |this, cx| {
cx.notify(); *this = settings;
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 {
let path = config_dir().join(".settings"); #[cfg(not(target_arch = "wasm32"))]
{
if let Ok(content) = smol::fs::read_to_string(&path).await { let path = config_dir().join(".settings");
Ok(serde_json::from_str(&content)?) if let Ok(content) = smol::fs::read_to_string(&path).await {
} else { return Ok(serde_json::from_str(&content)?);
Err(anyhow!("Not found")) }
} }
Err(anyhow!("Not found"))
}); });
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
@@ -249,19 +254,15 @@ 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.values.clone(); let settings = self.inner.read(cx);
if let Ok(content) = serde_json::to_string(&settings) {
let task: Task<Result<(), Error>> = cx.background_spawn(async move { #[cfg(not(target_arch = "wasm32"))]
let path = config_dir().join(".settings"); cx.background_spawn(async move {
let content = serde_json::to_string(&settings)?; let path = config_dir().join(".settings");
smol::fs::write(&path, content).await.ok();
// Write settings to file })
smol::fs::write(&path, content).await?; .detach();
}
Ok(())
});
task.detach();
} }
/// Set theme /// Set theme
@@ -270,8 +271,10 @@ impl AppSettings {
T: Into<String>, T: Into<String>,
{ {
// Update settings // Update settings
self.values.theme = Some(theme.into()); self.inner.update(cx, |this, cx| {
cx.notify(); this.theme = Some(theme.into());
cx.notify();
});
// Apply the new theme // Apply the new theme
self.apply_theme(window, cx); self.apply_theme(window, cx);
@@ -279,16 +282,17 @@ 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.values.theme = None; self.inner.update(cx, |this, cx| {
cx.notify(); this.theme = None;
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.values.theme.as_ref() { if let Some(name) = self.inner.read(cx).theme.as_ref() {
let mode = self.values.theme_mode; let mode = self.inner.read(cx).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);
@@ -301,26 +305,32 @@ 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.values.trusted_relays.iter().any(|relay| { self.inner
relay.as_str_without_trailing_slash() == url.as_str_without_trailing_slash() .read(cx)
}) .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.values.trusted_relays.insert(url.clone()); self.inner.update(cx, |this, cx| {
cx.notify(); if !this
} .trusted_relays
.iter()
/// Add a room configuration .any(|relay| relay == url.as_str_without_trailing_slash())
pub fn add_room_config(&mut self, id: u64, config: RoomConfig, cx: &mut Context<Self>) { {
self.values this.trusted_relays
.room_configs .push(url.as_str_without_trailing_slash().to_string());
.entry(id) cx.notify();
.and_modify(|this| *this = config) }
.or_default(); });
cx.notify();
} }
} }

View File

@@ -9,22 +9,26 @@ 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-connect.workspace = true
nostr-blossom.workspace = true nostr-blossom.workspace = true
nostr-connect.workspace = true
gpui.workspace = true gpui.workspace = true
gpui_tokio.workspace = true instant.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
rustls = "0.23"
petname = "2.0.2"
whoami = "1.6.1"
mime_guess = "2.0.4" 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"

View File

@@ -1,12 +1,14 @@
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{anyhow, Error}; use anyhow::{Error, anyhow};
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?;
@@ -25,3 +27,8 @@ 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"))
}

View File

@@ -1,5 +1,3 @@
use std::sync::OnceLock;
/// Client name (Application name) /// Client name (Application name)
pub const CLIENT_NAME: &str = "Coop"; pub const CLIENT_NAME: &str = "Coop";
@@ -10,7 +8,8 @@ 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 KEYRING: &str = "Coop Safe Storage"; pub const MASTER_KEYRING: &str = "Coop Master Key";
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;
@@ -47,19 +46,7 @@ pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.
/// Default bootstrap relays /// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 3] = [ pub const BOOTSTRAP_RELAYS: [&str; 3] = [
"wss://relay.damus.io", "wss://relay.ditto.pub",
"wss://relay.primal.net", "wss://relay.primal.net",
"wss://user.kindpag.es", "wss://user.kindpag.es",
]; ];
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})")
})
}

View File

@@ -1,37 +1,40 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use instant::Duration;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow}; use anyhow::{Error, anyhow};
use common::config_dir; use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Task, Window}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
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 device::*; pub use nip4e::*;
pub use nip05::*; pub use nip05::*;
pub use signer::*; pub use signer::{CoopAuthUrlHandler, UniversalSigner};
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);
@@ -44,27 +47,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 {
/// Connecting to the bootstrapping relay /// The state is busy
Connecting, Busy,
/// Connected to the bootstrapping relay /// User has no signer
Connected, NoSigner,
/// Creating the signer /// The signer has changed
Creating, SignerChanged,
/// Show the identity dialog
Show,
/// A new signer has been set
SignerSet,
/// An error occurred /// An error occurred
Error(SharedString), Error(String),
} }
impl StateEvent { impl StateEvent {
pub fn error<T>(error: T) -> Self pub fn error<T>(error: T) -> Self
where where
T: Into<SharedString>, T: Into<String>,
{ {
Self::Error(error.into()) Self::Error(error.into())
} }
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
} }
/// Nostr Registry /// Nostr Registry
@@ -73,19 +76,11 @@ pub struct NostrRegistry {
/// Nostr client /// Nostr client
client: Client, client: Client,
/// Nostr signer /// Universal signer
signer: Arc<CoopSigner>, signer: UniversalSigner,
/// All local stored identities /// Current user's public key
npubs: Entity<Vec<PublicKey>>, current_user: Option<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>>>,
@@ -106,58 +101,43 @@ 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 key_dir = config_dir().join("keys"); let signer = UniversalSigner::new(Keys::generate());
let app_keys = get_or_init_app_keys(cx).unwrap_or(Keys::generate()); let authenticator = SignerAuthenticator::new(signer.clone());
// 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
let lmdb = cx.foreground_executor().block_on(async move { #[cfg(not(target_arch = "wasm32"))]
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()
.signer(signer.clone()) .database(database)
.database(lmdb) .authenticator(authenticator)
.gossip(NostrGossipMemory::unbounded()) .gossip(NostrGossipMemory::unbounded())
.automatic_authentication(false) .gossip_config(GossipConfig::default().no_background_refresh())
.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();
// Run at the end of current cycle // Connect to bootstrap relays after the window is ready
cx.defer_in(window, |this, _window, cx| { cx.defer_in(window, |this, _window, cx| {
this.connect(cx); this.connect_bootstrap_relays(cx);
// Create an identity if none exists #[cfg(not(target_arch = "wasm32"))]
if this.npubs.read(cx).is_empty() { this.get_user_credential(cx);
this.create_identity(cx);
} else {
// Show the identity dialog
cx.emit(StateEvent::Show);
}
}); });
Self { Self {
client, client,
signer, signer,
npubs, current_user: None,
key_dir,
app_keys,
tasks: vec![], tasks: vec![],
} }
} }
@@ -167,61 +147,51 @@ impl NostrRegistry {
self.client.clone() self.client.clone()
} }
/// Get the nostr signer /// Get the current signer
pub fn signer(&self) -> Arc<CoopSigner> { pub fn signer(&self) -> UniversalSigner {
self.signer.clone() self.signer.clone()
} }
/// Get the npubs entity /// Get the current user's public key
pub fn npubs(&self) -> Entity<Vec<PublicKey>> { pub fn current_user(&self) -> Option<PublicKey> {
self.npubs.clone() self.current_user
} }
/// Get the app keys /// Update the signer
pub fn keys(&self) -> Keys { pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
self.app_keys.clone() where
} 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()));
})?;
}
};
/// Discover all npubs in the keys directory Ok(())
fn discover(dir: &PathBuf) -> Result<Vec<PublicKey>, Error> { });
// Ensure keys directory exists self.tasks.push(task);
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(&mut self, cx: &mut Context<Self>) { fn connect_bootstrap_relays(&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
@@ -236,345 +206,102 @@ impl NostrRegistry {
} }
// Connect to all added relays // Connect to all added relays
client client.connect().await;
.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 {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::Connected);
})?;
} }
Ok(()) Ok(())
})); }));
} }
/// Get the secret for a given npub. /// Check the user's credential and set the signer if valid
pub fn get_secret( #[cfg(not(target_arch = "wasm32"))]
&self, fn get_user_credential(&mut self, cx: &mut Context<Self>) {
public_key: PublicKey, let user_keyring = cx.read_credentials(USER_KEYRING);
cx: &App, let master_keyring = self.get_master_key(cx);
) -> 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)
}
}
/// 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.background_spawn(async move {
let (_, secret) = read
.await
.map_err(|_| anyhow!("Failed to get signer. Please re-import the secret key"))?
.ok_or_else(|| anyhow!("Failed to get signer. Please re-import the secret key"))?;
// Try to parse as a direct secret key first
if let Ok(secret_key) = SecretKey::from_slice(&secret) {
return Ok(Keys::new(secret_key).into_nostr_signer());
}
// Convert the secret into string
let sec = String::from_utf8(secret)
.map_err(|_| anyhow!("Failed to parse secret as UTF-8"))?;
// Try to parse as a NIP-46 URI
let uri =
NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?;
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let mut nip46 = NostrConnect::new(uri, app_keys, timeout, None)?;
// Set the auth URL handler
nip46.auth_url_handler(CoopAuthUrlHandler);
Ok(nip46.into_nostr_signer())
})
}
/// 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| { self.tasks.push(cx.spawn(async move |this, cx| {
match task.await { match user_keyring.await {
Ok(_) => { Ok(Some((_username, secret))) => {
this.update(cx, |this, cx| { let content = String::from_utf8(secret)?;
this.set_signer(keys, cx);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::error(e.to_string()));
})?;
}
};
Ok(()) if content.starts_with("nsec1") {
})); let secret_key = SecretKey::parse(&content)?;
} let keys = Keys::new(secret_key);
/// Set the signer for the nostr client and verify the public key this.update(cx, |this, cx| {
pub fn set_signer<T>(&mut self, new: T, cx: &mut Context<Self>) this.set_signer(keys, cx);
where cx.notify();
T: NostrSigner + 'static, })?;
{ } else if content.starts_with("bunker://") {
let client = self.client(); let keys = master_keyring.await;
let signer = self.signer(); let timeout = Duration::from_secs(30);
let uri = NostrConnectUri::parse(content)?;
// Create a task to update the signer and verify the public key // Construct the nostr connect signer
let task: Task<Result<PublicKey, Error>> = cx.background_spawn(async move { let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
// Update signer and unsubscribe
signer.switch(new).await;
client.unsubscribe_all().await?;
// Verify and get public key // Handle auth url with the default browser
let signer = client.signer().context("Signer not found")?; signer.auth_url_handler(CoopAuthUrlHandler);
let public_key = signer.get_public_key().await?;
log::info!("Signer's public key: {}", public_key); this.update(cx, |this, cx| {
Ok(public_key) this.set_signer(signer, cx);
}); cx.notify();
})?;
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| { this.update(cx, |_, cx| {
cx.emit(StateEvent::error(e.to_string())); cx.emit(StateEvent::NoSigner);
})?; })?;
} }
}; }
Ok(()) Ok(())
})); }));
} }
/// Get the master key that used for Nostr Connect
pub fn get_master_key(&self, cx: &App) -> Task<Keys> {
if cfg!(target_arch = "wasm32") {
return cx.background_spawn(async move { Keys::generate() });
}
let task = cx.read_credentials(MASTER_KEYRING);
cx.spawn(async move |cx| {
let (keys, new_key) = match task.await {
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
Ok(secret_key) => (Keys::new(secret_key), false),
_ => (Keys::generate(), true),
},
_ => (Keys::generate(), true),
};
if new_key {
let keys_clone = keys.clone();
let username = keys_clone.public_key().to_hex();
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
})
}
/// Get the public key of a NIP-05 address /// Get the public key of a NIP-05 address
pub fn get_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> { pub fn query_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();
@@ -611,7 +338,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.get_address(addr, cx)) Some(self.query_address(addr, cx))
} else { } else {
None None
}; };
@@ -627,6 +354,18 @@ 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);
@@ -670,14 +409,17 @@ 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 builder = EventBuilder::new(Kind::Custom(5315), "").tags(vec![ let event = EventBuilder::new(Kind::Custom(5315), "")
Tag::custom(TagKind::custom("param"), vec!["search", &query]), .tags(vec![
Tag::custom(TagKind::custom("param"), vec!["limit", "10"]), Tag::custom("param", vec!["search", &query]),
]); 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?;
@@ -727,78 +469,3 @@ 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(())
})
}
}

View File

@@ -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 {

View File

@@ -16,14 +16,15 @@ impl From<&Event> for Announcement {
let public_key = val let public_key = val
.tags .tags
.iter() .iter()
.find(|tag| tag.kind().as_str() == "n") .find(|tag| tag.kind() == "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
.find(TagKind::Client) .iter()
.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());

View File

@@ -1,134 +1,206 @@
use std::borrow::Cow; use std::error::Error;
use std::result::Result; use std::fmt;
use std::sync::Arc; use std::future::Future;
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 CoopSigner { pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
/// User's signer
signer: RwLock<Arc<dyn NostrSigner>>,
/// User's signer public key impl fmt::Display for UniversalSignerError {
signer_pkey: RwLock<Option<PublicKey>>, fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
/// Specific signer for encryption purposes }
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
} }
impl CoopSigner { impl Error for UniversalSignerError {
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: IntoNostrSigner, T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + '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,
{ {
Self { Self {
signer: RwLock::new(signer.into_nostr_signer()), inner: Arc::new(RwLock::new(Arc::new(InnerSignerImpl(signer)))),
signer_pkey: RwLock::new(None),
encryption_signer: RwLock::new(None),
} }
} }
/// Get the current signer. /// Swap the inner signer in-place. All clones see the new signer.
pub async fn get(&self) -> Arc<dyn NostrSigner> { pub fn swap_inner<T>(&self, new_signer: T)
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: IntoNostrSigner, T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + '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,
{ {
let new_signer = new.into_nostr_signer(); *self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_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());
} }
} }
impl NostrSigner for CoopSigner { trait InnerSigner: fmt::Debug + Send + Sync + 'static {
#[allow(mismatched_lifetime_syntaxes)] fn get_public_key_async(
fn backend(&self) -> SignerBackend { &self,
SignerBackend::Custom(Cow::Borrowed("custom")) ) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
} 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,
) -> BoxedFuture<'a, Result<Event, SignerError>> { ) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
Box::pin(async move { self.get().await.sign_event(unsigned).await }) fn nip44_encrypt_async<'a>(
}
fn nip04_encrypt<'a>(
&'a self, &'a self,
public_key: &'a PublicKey, public_key: &'a PublicKey,
content: &'a str, content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> { ) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
Box::pin(async move { self.get().await.nip04_encrypt(public_key, content).await }) fn nip44_decrypt_async<'a>(
}
fn nip04_decrypt<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
self.get()
.await
.nip04_decrypt(public_key, encrypted_content)
.await
})
}
fn nip44_encrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move { self.get().await.nip44_encrypt(public_key, content).await })
}
fn nip44_decrypt<'a>(
&'a self, &'a self,
public_key: &'a PublicKey, public_key: &'a PublicKey,
payload: &'a str, payload: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> { ) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
Box::pin(async move { self.get().await.nip44_decrypt(public_key, payload).await }) }
#[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 {
AsyncGetPublicKey::get_public_key_async(&self.0)
.await
.map_err(UniversalSignerError::new)
})
}
fn sign_event_async(
&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,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
.await
.map_err(UniversalSignerError::new)
})
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
Box::pin(async move {
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(())
})
} }
} }

View File

@@ -1,6 +1,6 @@
use std::fmt::{self, Debug, Display, Formatter}; use std::fmt::{self, Debug, Display, Formatter};
use gpui::{AbsoluteLength, Axis, Corner, Length, Pixels}; use gpui::{AbsoluteLength, Axis, 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,141 +49,6 @@ 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.

View File

@@ -192,7 +192,6 @@ 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",

View File

@@ -1,10 +1,9 @@
use gpui::{Pixels, px}; use gpui::{Anchor, Pixels, px};
use serde::{Deserialize, Serialize};
use crate::{Anchor, Edges, TITLEBAR_HEIGHT}; use crate::{Edges, TITLEBAR_HEIGHT};
/// The settings for notifications. /// The settings for notifications.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone)]
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,

View File

@@ -3,6 +3,7 @@ pub enum PlatformKind {
Mac, Mac,
Linux, Linux,
Windows, Windows,
Web,
} }
impl PlatformKind { impl PlatformKind {
@@ -11,22 +12,21 @@ impl PlatformKind {
Self::Linux Self::Linux
} else if cfg!(target_os = "windows") { } else if cfg!(target_os = "windows") {
Self::Windows Self::Windows
} else { } else if cfg!(target_os = "macos") {
Self::Mac Self::Mac
} else {
Self::Web
} }
} }
#[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)
} }

View File

@@ -1,23 +0,0 @@
[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"

View File

@@ -1,172 +0,0 @@
#[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,
})
}
}

View File

@@ -1,227 +0,0 @@
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
})
}

View File

@@ -1,6 +0,0 @@
/// 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.;

View File

@@ -1,4 +0,0 @@
#[cfg(target_os = "linux")]
pub mod linux;
pub mod mac;
pub mod windows;

View File

@@ -1,147 +0,0 @@
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}",
})
}
}

View File

@@ -9,9 +9,8 @@ common = { path = "../common" }
theme = { path = "../theme" } theme = { path = "../theme" }
gpui.workspace = true gpui.workspace = true
smol.workspace = true instant.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
@@ -20,7 +19,10 @@ 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"
rope = { git = "https://github.com/zed-industries/zed" } ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] }
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"

View File

@@ -1,191 +0,0 @@
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

View File

@@ -1,332 +0,0 @@
//! 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 }
}
}

View File

@@ -2,15 +2,15 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
div, relative, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement, IntoElement,
IntoElement, ParentElement, RenderOnce, SharedString, Stateful, ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window, StyleRefinement, Styled, Window, div, relative,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::indicator::Indicator; use crate::indicator::Indicator;
use crate::tooltip::Tooltip; use crate::tooltip::Tooltip;
use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt}; use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
#[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, // TODO: use a different color? ButtonVariant::Primary => cx.theme().text_muted,
_ => cx.theme().text_muted, _ => cx.theme().text_muted,
}; };

View File

@@ -1,5 +1,5 @@
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use instant::Duration;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{

View File

@@ -214,6 +214,8 @@ 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);
}); });

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Corner, DefiniteLength, DismissEvent, DragMoveEvent, Empty, Entity, Anchor, App, AppContext, Context, 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,
@@ -460,7 +460,7 @@ impl TabPanel {
}) })
} }
}) })
.anchor(Corner::TopRight), .anchor(Anchor::TopRight),
) )
} }
@@ -750,7 +750,7 @@ impl TabPanel {
div() div()
.id("tab-bar-empty-space") .id("tab-bar-empty-space")
.h_full() .h_full()
.flex_grow() .flex_grow_1()
.min_w_16() .min_w_16()
.when(state.droppable, |this| { .when(state.droppable, |this| {
let view = cx.entity(); let view = cx.entity();

View File

@@ -1,6 +1,8 @@
use std::fmt::Debug; use std::fmt::Debug;
use std::time::{Duration, Instant}; use instant::{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);
@@ -22,10 +24,11 @@ pub struct History<I: HistoryItem> {
redos: Vec<I>, redos: Vec<I>,
last_changed_at: Instant, last_changed_at: Instant,
version: usize, version: usize,
max_undo: usize, pub(crate) ignore: bool,
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>
@@ -39,15 +42,16 @@ where
ignore: false, ignore: false,
last_changed_at: Instant::now(), last_changed_at: Instant::now(),
version: 0, version: 0,
max_undo: 1000, max_undos: 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_undo(mut self, max_undo: usize) -> Self { pub fn max_undos(mut self, max_undos: usize) -> Self {
self.max_undo = max_undo; self.max_undos = max_undos;
self self
} }
@@ -64,10 +68,20 @@ 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 Some(self.last_changed_at.elapsed()) > self.group_interval { if !self.grouping && Some(self.last_changed_at.elapsed()) > self.group_interval {
self.version += 1; self.version += 1;
} }
@@ -80,10 +94,11 @@ 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_undo { if self.undos.len() >= self.max_undos {
self.undos.remove(0); self.undos.remove(0);
} }
@@ -113,6 +128,7 @@ 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()];
@@ -135,6 +151,7 @@ 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()];

View File

@@ -1,4 +1,4 @@
use std::time::Duration; use instant::Duration;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{

View File

@@ -1,9 +1,14 @@
use std::time::Duration; use instant::Duration;
use gpui::{px, Context, Pixels}; use gpui::{Context, Pixels, Task, px};
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.
@@ -12,10 +17,12 @@ 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 struct BlinkCursor { pub(crate) struct BlinkCursor {
visible: bool, visible: bool,
paused: bool, paused: bool,
epoch: usize, epoch: usize,
_task: Task<()>,
} }
impl BlinkCursor { impl BlinkCursor {
@@ -24,6 +31,7 @@ impl BlinkCursor {
visible: false, visible: false,
paused: false, paused: false,
epoch: 0, epoch: 0,
_task: Task::ready(()),
} }
} }
@@ -53,14 +61,12 @@ impl BlinkCursor {
// Schedule the next blink // Schedule the next blink
let epoch = self.next_epoch(); let epoch = self.next_epoch();
cx.spawn(async move |this, cx| { self._task = 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 {
@@ -76,7 +82,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();
cx.spawn(async move |this, cx| { self._task = 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() {
@@ -85,13 +91,6 @@ impl BlinkCursor {
this.blink(epoch, cx); this.blink(epoch, cx);
}); });
} }
}) });
.detach();
}
}
impl Default for BlinkCursor {
fn default() -> Self {
Self::new()
} }
} }

View File

@@ -1,7 +1,6 @@
use std::fmt::Debug; use std::fmt::Debug;
use crate::history::HistoryItem; use crate::{history::HistoryItem, input::Selection};
use crate::input::cursor::Selection;
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub struct Change { pub struct Change {

View File

@@ -1,15 +1,15 @@
use gpui::{App, Styled}; use gpui::{App, Styled};
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::button::{Button, ButtonVariants}; use crate::button::{Button, ButtonVariants as _};
use crate::{Icon, IconName, Sizable}; use crate::{Icon, IconName, Sizable as _};
#[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))
.tooltip("Clear") .ghost()
.small() .xsmall()
.transparent() .tab_stop(false)
.text_color(cx.theme().text_muted) .text_color(cx.theme().icon_muted)
} }

View File

@@ -1,4 +1,4 @@
use std::ops::Range; use std::ops::{Range, RangeBounds};
/// 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,5 +42,12 @@ 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)
}
pub type Position = lsp_types::Position; fn end_bound(&self) -> std::ops::Bound<&usize> {
std::ops::Bound::Excluded(&self.end)
}
}

View File

@@ -0,0 +1,172 @@
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()
}
}

View File

@@ -0,0 +1,7 @@
#[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;

View File

@@ -0,0 +1,582 @@
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);
}
}
}
}

View File

@@ -0,0 +1,172 @@
/// WrapMap: Soft-wrapping layer (Buffer → Wrap rows).
///
/// This module wraps the existing TextWrapper and provides:
/// - BufferPoint ↔ WrapPoint mapping
/// - Efficient buffer_line → wrap_row queries via prefix sum cache
/// - Incremental updates when text or layout changes
use std::ops::Range;
use gpui::{App, Font, Pixels};
use ropey::Rope;
use super::text_wrapper::{LineItem, TextWrapper};
/// WrapMap manages soft-wrapping and provides buffer ↔ wrap coordinate mapping.
pub struct WrapMap {
/// The underlying text wrapper (reuses existing implementation)
wrapper: TextWrapper,
/// Prefix sum cache: buffer_line_starts[line] = first wrap_row for buffer line `line`
/// This allows O(1) lookup of buffer_line → wrap_row
buffer_line_starts: Vec<usize>,
/// Cached line count from last rebuild
cached_line_count: usize,
/// Cached total wrap row count from last rebuild.
/// Used together with `cached_line_count` to detect if the cache is stale.
/// When soft wrap changes a line's wrap count without changing buffer line count,
/// this catches the staleness.
cached_wrap_row_count: usize,
}
impl WrapMap {
pub fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
Self {
wrapper: TextWrapper::new(font, font_size, wrap_width),
buffer_line_starts: Vec::new(),
cached_line_count: 0,
cached_wrap_row_count: 0,
}
}
/// Get total number of wrap rows (visual rows after soft-wrapping)
#[inline]
pub fn wrap_row_count(&self) -> usize {
self.wrapper.len()
}
/// Get total number of buffer lines (logical lines)
#[inline]
pub fn buffer_line_count(&self) -> usize {
self.wrapper.lines.len()
}
/// Get the buffer line for a given wrap row
pub fn wrap_row_to_buffer_line(&self, wrap_row: usize) -> usize {
if wrap_row >= self.wrap_row_count() {
return self.buffer_line_count().saturating_sub(1);
}
// Binary search in prefix sum cache
match self.buffer_line_starts.binary_search(&wrap_row) {
Ok(line) => line,
Err(insert_pos) => insert_pos.saturating_sub(1),
}
}
/// Get the first wrap row for a given buffer line
pub fn buffer_line_to_first_wrap_row(&self, line: usize) -> usize {
if line >= self.buffer_line_starts.len() {
return self.wrap_row_count();
}
self.buffer_line_starts[line]
}
/// Get the wrap row range for a buffer line: [start, end)
pub fn buffer_line_to_wrap_row_range(&self, line: usize) -> Range<usize> {
let start = self.buffer_line_to_first_wrap_row(line);
let end = if line + 1 < self.buffer_line_starts.len() {
self.buffer_line_starts[line + 1]
} else {
self.wrap_row_count()
};
start..end
}
/// 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.wrapper.update(changed_text, range, new_text, cx);
self.rebuild_cache();
}
/// Update layout parameters (wrap width or font)
pub fn on_layout_changed(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
self.wrapper.set_wrap_width(wrap_width, cx);
self.rebuild_cache();
}
/// Set font parameters
pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
self.wrapper.set_font(font, font_size, cx);
self.rebuild_cache();
}
/// Ensure text is prepared (initializes wrapper if needed)
pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) -> bool {
let did_initialize = self.wrapper.prepare_if_need(text, cx);
if did_initialize {
self.rebuild_cache();
}
did_initialize
}
/// Initialize with text
pub fn set_text(&mut self, text: &Rope, cx: &mut App) {
self.wrapper.set_default_text(text);
self.wrapper.prepare_if_need(text, cx);
self.rebuild_cache();
}
/// Rebuild the prefix sum cache: buffer_line_starts
fn rebuild_cache(&mut self) {
let line_count = self.wrapper.lines.len();
let wrap_row_count = self.wrapper.len();
// Skip if nothing changed: both buffer line count and total wrap row count must match.
if line_count == self.cached_line_count
&& wrap_row_count == self.cached_wrap_row_count
&& !self.buffer_line_starts.is_empty()
{
return;
}
self.buffer_line_starts.clear();
let mut wrap_row = 0;
for line_item in &self.wrapper.lines {
self.buffer_line_starts.push(wrap_row);
wrap_row += line_item.lines_len();
}
self.cached_line_count = line_count;
self.cached_wrap_row_count = wrap_row_count;
}
/// Get access to the underlying wrapper (for rendering/hit-testing)
pub(crate) fn wrapper(&self) -> &TextWrapper {
&self.wrapper
}
/// Get access to line items (for rendering)
pub(crate) fn lines(&self) -> &[LineItem] {
&self.wrapper.lines
}
/// Get the rope text
pub fn text(&self) -> &Rope {
self.wrapper.text()
}
/// Calculate how many wrap rows of a buffer line are visible.
/// Without folding, all wrap rows are visible.
pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize {
self.buffer_line_to_wrap_row_range(line).len()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,269 @@
use gpui::{Context, EntityInputHandler, SharedString, Window};
use ropey::RopeSlice;
use crate::input::mode::InputMode;
use crate::input::{Indent, IndentInline, InputState, Outdent, OutdentInline};
#[derive(Debug, Copy, Clone)]
pub struct TabSize {
/// Default is 2
pub tab_size: usize,
/// Set true to use `\t` as tab indent, default is false
pub hard_tabs: bool,
}
impl Default for TabSize {
fn default() -> Self {
Self {
tab_size: 2,
hard_tabs: false,
}
}
}
impl TabSize {
pub(super) fn to_string(self) -> SharedString {
if self.hard_tabs {
"\t".into()
} else {
" ".repeat(self.tab_size).into()
}
}
/// Count the indent size of the line in spaces.
pub fn indent_count(&self, line: &RopeSlice) -> usize {
let mut count = 0;
for ch in line.chars() {
match ch {
'\t' => count += self.tab_size,
' ' => count += 1,
_ => break,
}
}
count
}
}
impl InputState {
/// Set the tab size for the input.
///
/// Only for [`InputMode::PlainText`] mode with multi_line.
pub fn tab_size(mut self, tab: TabSize) -> Self {
debug_assert!(self.mode.is_multi_line());
if let InputMode::PlainText { tab: t, .. } = &mut self.mode {
*t = tab;
}
self
}
pub(super) fn indent_inline(
&mut self,
_: &IndentInline,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.indent(false, window, cx);
}
pub(super) fn indent_block(&mut self, _: &Indent, window: &mut Window, cx: &mut Context<Self>) {
self.indent(true, window, cx);
}
pub(super) fn outdent_inline(
&mut self,
_: &OutdentInline,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.outdent(false, window, cx);
}
pub(super) fn outdent_block(
&mut self,
_: &Outdent,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.outdent(true, window, cx);
}
pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context<Self>) {
if !self.mode.is_indentable() {
cx.propagate();
return;
};
let tab_indent = self.mode.tab_size().to_string();
let selected_range = self.selected_range;
let mut added_len = 0;
let is_selected = !self.selected_range.is_empty();
if is_selected || block {
let start_offset = self.start_of_line_of_selection(window, cx);
let mut offset = start_offset;
let selected_text = self
.text_for_range(
self.range_to_utf16(&(offset..selected_range.end)),
&mut None,
window,
cx,
)
.unwrap_or("".into());
for line in selected_text.split('\n') {
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&(offset..offset))),
&tab_indent,
window,
cx,
);
added_len += tab_indent.len();
// +1 for "\n", the `\r` is included in the `line`.
offset += line.len() + tab_indent.len() + 1;
}
if is_selected {
self.selected_range = (start_offset..selected_range.end + added_len).into();
} else {
self.selected_range =
(selected_range.start + added_len..selected_range.end + added_len).into();
}
} else {
// Selected none
let offset = self.selected_range.start;
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&(offset..offset))),
&tab_indent,
window,
cx,
);
added_len = tab_indent.len();
self.selected_range =
(selected_range.start + added_len..selected_range.end + added_len).into();
}
}
pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context<Self>) {
if !self.mode.is_indentable() {
cx.propagate();
return;
};
let tab_indent = self.mode.tab_size().to_string();
let selected_range = self.selected_range;
let mut removed_len = 0;
let is_selected = !self.selected_range.is_empty();
if is_selected || block {
let start_offset = self.start_of_line_of_selection(window, cx);
let mut offset = start_offset;
let selected_text = self
.text_for_range(
self.range_to_utf16(&(offset..selected_range.end)),
&mut None,
window,
cx,
)
.unwrap_or("".into());
for line in selected_text.split('\n') {
if line.starts_with(tab_indent.as_ref()) {
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))),
"",
window,
cx,
);
removed_len += tab_indent.len();
// +1 for "\n"
offset += line.len().saturating_sub(tab_indent.len()) + 1;
} else {
offset += line.len() + 1;
}
}
if is_selected {
self.selected_range =
(start_offset..selected_range.end.saturating_sub(removed_len)).into();
} else {
self.selected_range = (selected_range.start.saturating_sub(removed_len)
..selected_range.end.saturating_sub(removed_len))
.into();
}
} else {
// Selected none
let start_offset = self.selected_range.start;
let offset = self.start_of_line_of_selection(window, cx);
let offset = self.offset_from_utf16(self.offset_to_utf16(offset));
// FIXME: To improve performance
if self
.text
.slice(offset..self.text.len())
.to_string()
.starts_with(tab_indent.as_ref())
{
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))),
"",
window,
cx,
);
removed_len = tab_indent.len();
let new_offset = start_offset.saturating_sub(removed_len);
self.selected_range = (new_offset..new_offset).into();
}
}
}
}
#[cfg(test)]
mod tests {
use ropey::RopeSlice;
use super::TabSize;
#[test]
fn test_tab_size() {
let tab = TabSize {
tab_size: 2,
hard_tabs: false,
};
assert_eq!(tab.to_string(), " ");
let tab = TabSize {
tab_size: 4,
hard_tabs: false,
};
assert_eq!(tab.to_string(), " ");
let tab = TabSize {
tab_size: 2,
hard_tabs: true,
};
assert_eq!(tab.to_string(), "\t");
let tab = TabSize {
tab_size: 4,
hard_tabs: true,
};
assert_eq!(tab.to_string(), "\t");
}
#[test]
fn test_tab_size_indent_count() {
let tab = TabSize {
tab_size: 4,
hard_tabs: false,
};
assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0);
assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 2);
assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 4);
assert_eq!(tab.indent_count(&RopeSlice::from("\tabc")), 4);
assert_eq!(tab.indent_count(&RopeSlice::from(" \tabc")), 6);
assert_eq!(tab.indent_count(&RopeSlice::from(" \t abc ")), 6);
assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0);
}
}

View File

@@ -1,19 +1,30 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _, AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, InteractiveElement as _,
IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled, IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled,
Window, TextAlign, Window, div, px, relative,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use super::clear_button::clear_button; use super::InputState;
use super::state::{InputState, CONTEXT}; use super::element::EditorScrollbar;
use crate::button::{Button, ButtonVariants}; use crate::button::{Button, ButtonVariants as _};
use crate::indicator::Indicator; use crate::indicator::Indicator;
use crate::{h_flex, IconName, Sizable, Size, StyleSized, StyledExt}; use crate::input::clear_button;
use crate::{IconName, Selectable, Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
/// Returns `(background, foreground)` colors for input-like components.
pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) {
if disabled {
(cx.theme().surface_background, cx.theme().text_muted)
} else {
(cx.theme().elevated_surface_background, cx.theme().text)
}
}
/// A text input element bind to an [`InputState`].
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct TextInput { pub struct Input {
state: Entity<InputState>, state: Entity<InputState>,
style: StyleRefinement, style: StyleRefinement,
size: Size, size: Size,
@@ -26,17 +37,30 @@ pub struct TextInput {
disabled: bool, disabled: bool,
bordered: bool, bordered: bool,
focus_bordered: bool, focus_bordered: bool,
tab_index: isize,
selected: bool,
} }
impl Sizable for TextInput { impl Sizable for Input {
fn with_size(mut self, size: impl Into<Size>) -> Self { fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into(); self.size = size.into();
self self
} }
} }
impl TextInput { impl Selectable for Input {
/// Create a new [`TextInput`] element bind to the [`InputState`]. fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl Input {
/// Create a new [`Input`] element bind to the [`InputState`].
pub fn new(state: &Entity<InputState>) -> Self { pub fn new(state: &Entity<InputState>) -> Self {
Self { Self {
state: state.clone(), state: state.clone(),
@@ -51,6 +75,8 @@ impl TextInput {
disabled: false, disabled: false,
bordered: true, bordered: true,
focus_bordered: true, focus_bordered: true,
tab_index: 0,
selected: false,
} }
} }
@@ -94,9 +120,9 @@ impl TextInput {
self self
} }
/// Set true to show the clear button when the input field is not empty. /// Set whether to show the clear button when the input field is not empty, default is false.
pub fn cleanable(mut self) -> Self { pub fn cleanable(mut self, cleanable: bool) -> Self {
self.cleanable = true; self.cleanable = cleanable;
self self
} }
@@ -112,79 +138,117 @@ impl TextInput {
self self
} }
fn render_toggle_mask_button(state: Entity<InputState>) -> impl IntoElement { /// Set the tab index for the input, default is 0.
pub fn tab_index(mut self, index: isize) -> Self {
self.tab_index = index;
self
}
fn render_toggle_mask_button(state: &Entity<InputState>, cx: &App) -> impl IntoElement {
let _masked = state.read(cx).masked;
Button::new("toggle-mask") Button::new("toggle-mask")
.icon(IconName::Eye) .icon(IconName::Eye)
.xsmall() .xsmall()
.ghost() .ghost()
.on_mouse_down(MouseButton::Left, { .tab_stop(false)
.on_click({
let state = state.clone(); let state = state.clone();
move |_, window, cx| { move |_, window, cx| {
state.update(cx, |state, cx| { state.update(cx, |state, cx| {
state.set_masked(false, window, cx); state.set_masked(!state.masked, window, cx);
})
}
})
.on_mouse_up(MouseButton::Left, {
let state = state.clone();
move |_, window, cx| {
state.update(cx, |state, cx| {
state.set_masked(true, window, cx);
}) })
} }
}) })
} }
/// This method must after the refine_style.
fn render_editor(
paddings: EdgesRefinement<DefiniteLength>,
input_state: &Entity<InputState>,
state: &InputState,
window: &Window,
) -> impl IntoElement {
let base_size = window.text_style().font_size;
let rem_size = window.rem_size();
let paddings = Edges {
left: paddings
.left
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
right: paddings
.right
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
top: paddings
.top
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
bottom: paddings
.bottom
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
};
state.editor_scrollbar_paddings.set(paddings);
state.editor_scrollbar_snapshot.set(None);
v_flex().size_full().child(
div()
.relative()
.flex_1()
.child(input_state.clone())
.child(EditorScrollbar::new(input_state.clone())),
)
}
} }
impl Styled for TextInput { impl Styled for Input {
fn style(&mut self) -> &mut StyleRefinement { fn style(&mut self) -> &mut StyleRefinement {
&mut self.style &mut self.style
} }
} }
impl RenderOnce for TextInput { impl RenderOnce for Input {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
const LINE_HEIGHT: Rems = Rems(1.25); const LINE_HEIGHT: Rems = Rems(1.25);
let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left);
let font = window.text_style().font(); self.state.update(cx, |state, _| {
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
self.state.update(cx, |state, cx| {
state.text_wrapper.set_font(font, font_size, cx);
state.text_wrapper.prepare_if_need(&state.text, cx);
state.disabled = self.disabled; state.disabled = self.disabled;
state.size = self.size;
// Only for single line mode
if state.mode.is_single_line() {
state.text_align = text_align;
}
}); });
let state = self.state.read(cx); let state = self.state.read(cx);
let focused = state.focus_handle.is_focused(window) && !state.disabled; let _focused = state.focus_handle.is_focused(window) && !state.disabled;
let gap_x = match self.size { let gap_x = match self.size {
Size::Small => px(4.), Size::Small => px(4.),
Size::Large => px(8.), Size::Large => px(8.),
_ => px(4.), _ => px(6.),
}; };
let bg = if state.disabled { let (bg, _) = input_style(state.disabled, cx);
cx.theme().surface_background
} else {
cx.theme().elevated_surface_background
};
let prefix = self.prefix; let prefix = self.prefix;
let suffix = self.suffix; let suffix = self.suffix;
let show_clear_button = self.cleanable let show_clear_button = self.cleanable
&& !state.disabled
&& !state.loading && !state.loading
&& !state.text.is_empty() && state.text.len() > 0
&& state.mode.is_single_line(); && state.mode.is_single_line();
let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button; let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button;
div() div()
.id(("input", self.state.entity_id())) .id(("input", self.state.entity_id()))
.flex() .flex()
.key_context(CONTEXT) .key_context(crate::input::CONTEXT)
.track_focus(&state.focus_handle) .track_focus(&state.focus_handle.clone())
.tab_index(self.tab_index)
.when(!state.disabled, |this| { .when(!state.disabled, |this| {
this.on_action(window.listener_for(&self.state, InputState::backspace)) this.on_action(window.listener_for(&self.state, InputState::backspace))
.on_action(window.listener_for(&self.state, InputState::delete)) .on_action(window.listener_for(&self.state, InputState::delete))
@@ -205,9 +269,6 @@ impl RenderOnce for TextInput {
.on_action(window.listener_for(&self.state, InputState::outdent_inline)) .on_action(window.listener_for(&self.state, InputState::outdent_inline))
.on_action(window.listener_for(&self.state, InputState::indent_block)) .on_action(window.listener_for(&self.state, InputState::indent_block))
.on_action(window.listener_for(&self.state, InputState::outdent_block)) .on_action(window.listener_for(&self.state, InputState::outdent_block))
.on_action(
window.listener_for(&self.state, InputState::shift_to_new_line),
)
}) })
}) })
.on_action(window.listener_for(&self.state, InputState::left)) .on_action(window.listener_for(&self.state, InputState::left))
@@ -260,8 +321,8 @@ impl RenderOnce for TextInput {
.input_px(self.size) .input_px(self.size)
.input_py(self.size) .input_py(self.size)
.input_h(self.size) .input_h(self.size)
.cursor_text() .input_font_size(self.size)
.text_size(font_size) .when(!self.disabled, |this| this.cursor_text())
.items_center() .items_center()
.when(state.mode.is_multi_line(), |this| { .when(state.mode.is_multi_line(), |this| {
this.h_auto() this.h_auto()
@@ -269,33 +330,34 @@ impl RenderOnce for TextInput {
}) })
.when(self.appearance, |this| { .when(self.appearance, |this| {
this.bg(bg) this.bg(bg)
.when(self.disabled, |this| this.opacity(0.5))
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.when(self.bordered, |this| { .when(self.bordered, |this| {
this.border_color(cx.theme().border) this.border_color(cx.theme().border)
.border_1() .border_1()
.when(cx.theme().shadow, |this| this.shadow_xs()) .when(cx.theme().shadow, |this| this.shadow_xs())
.when(focused && self.focus_bordered, |this| {
this.border_color(cx.theme().border_focused)
})
}) })
}) })
.items_center() .items_center()
.gap(gap_x) .gap(gap_x)
.refine_style(&self.style) .refine_style(&self.style)
.children(prefix) .children(prefix)
.child(self.state.clone()) .when(state.mode.is_multi_line(), |mut this| {
let paddings = this.style().padding.clone();
this.child(Self::render_editor(paddings, &self.state, state, window))
})
.when(!state.mode.is_multi_line(), |this| {
this.child(self.state.clone())
})
.when(has_suffix, |this| { .when(has_suffix, |this| {
this.pr_2().child( this.pr_2().child(
h_flex() h_flex()
.id("suffix") .id("suffix")
.gap(gap_x) .gap(gap_x)
.when(self.appearance, |this| this.bg(bg))
.items_center() .items_center()
.when(state.loading, |this| { .when(state.loading, |this| this.child(Indicator::new()))
this.child(Indicator::new().color(cx.theme().text_muted))
})
.when(self.mask_toggle, |this| { .when(self.mask_toggle, |this| {
this.child(Self::render_toggle_mask_button(self.state.clone())) this.child(Self::render_toggle_mask_button(&self.state, cx))
}) })
.when(show_clear_button, |this| { .when(show_clear_button, |this| {
this.child(clear_button(cx).on_click({ this.child(clear_button(cx).on_click({
@@ -303,6 +365,7 @@ impl RenderOnce for TextInput {
move |_, window, cx| { move |_, window, cx| {
state.update(cx, |state, cx| { state.update(cx, |state, cx| {
state.clean(window, cx); state.clean(window, cx);
state.focus(window, cx);
}) })
} }
})) }))

View File

@@ -319,14 +319,14 @@ impl MaskPattern {
if fraction == &Some(0) { if fraction == &Some(0) {
int_with_sep int_with_sep
} else { } else {
format!("{int_with_sep}.{frac}") format!("{}.{}", int_with_sep, frac)
} }
} else { } else {
int_with_sep int_with_sep
}; };
let final_str = if let Some(sign) = maybe_signed { let final_str = if let Some(sign) = maybe_signed {
format!("{sign}{final_str}") format!("{}{}", sign, final_str)
} else { } else {
final_str final_str
}; };

View File

@@ -1,15 +1,27 @@
pub(super) const MASK_CHAR: char = '*';
mod blink_cursor; mod blink_cursor;
mod change; mod change;
mod clear_button;
mod cursor; mod cursor;
mod display_map;
mod element; mod element;
mod indent;
#[allow(clippy::module_inception)]
mod input;
mod mask_pattern; mod mask_pattern;
mod mode; mod mode;
mod movement;
mod rope_ext; mod rope_ext;
mod selection;
mod state; mod state;
mod text_input;
mod text_wrapper;
pub(crate) mod clear_button;
pub(crate) use clear_button::*;
pub use cursor::*;
pub use display_map::DisplayMap;
pub use indent::TabSize;
pub use input::*;
pub use mask_pattern::MaskPattern;
pub use rope_ext::{InputEdit, Point, RopeExt, RopeLines};
pub use ropey::Rope;
pub use state::*; pub use state::*;
pub use text_input::*;

View File

@@ -1,42 +1,14 @@
use gpui::SharedString; use super::display_map::DisplayMap;
use super::text_wrapper::TextWrapper; #[derive(Clone)]
pub(crate) enum InputMode {
#[derive(Debug, Copy, Clone)] /// A plain text input mode.
pub struct TabSize { PlainText {
/// Default is 2 multi_line: bool,
pub tab_size: usize, tab: crate::input::indent::TabSize,
/// Set true to use `\t` as tab indent, default is false
pub hard_tabs: bool,
}
impl Default for TabSize {
fn default() -> Self {
Self {
tab_size: 2,
hard_tabs: false,
}
}
}
impl TabSize {
pub(super) fn to_string(self) -> SharedString {
if self.hard_tabs {
"\t".into()
} else {
" ".repeat(self.tab_size).into()
}
}
}
#[derive(Default, Clone)]
pub enum InputMode {
#[default]
SingleLine,
MultiLine {
tab: TabSize,
rows: usize, rows: usize,
}, },
/// An auto grow input mode.
AutoGrow { AutoGrow {
rows: usize, rows: usize,
min_rows: usize, min_rows: usize,
@@ -44,11 +16,43 @@ pub enum InputMode {
}, },
} }
impl Default for InputMode {
fn default() -> Self {
InputMode::plain_text()
}
}
#[allow(unused)] #[allow(unused)]
impl InputMode { impl InputMode {
/// Create a plain input mode with default settings.
pub(super) fn plain_text() -> Self {
InputMode::PlainText {
multi_line: false,
tab: crate::input::indent::TabSize::default(),
rows: 1,
}
}
/// Create an auto grow input mode with given min and max rows.
pub(super) fn auto_grow(min_rows: usize, max_rows: usize) -> Self {
InputMode::AutoGrow {
rows: min_rows,
min_rows,
max_rows,
}
}
pub(super) fn multi_line(mut self, multi_line: bool) -> Self {
match &mut self {
InputMode::PlainText { multi_line: ml, .. } => *ml = multi_line,
InputMode::AutoGrow { .. } => {}
}
self
}
#[inline] #[inline]
pub(super) fn is_single_line(&self) -> bool { pub(super) fn is_single_line(&self) -> bool {
matches!(self, InputMode::SingleLine) !self.is_multi_line()
} }
#[inline] #[inline]
@@ -58,15 +62,15 @@ impl InputMode {
#[inline] #[inline]
pub(super) fn is_multi_line(&self) -> bool { pub(super) fn is_multi_line(&self) -> bool {
matches!( match self {
self, InputMode::PlainText { multi_line, .. } => *multi_line,
InputMode::MultiLine { .. } | InputMode::AutoGrow { .. } InputMode::AutoGrow { max_rows, .. } => *max_rows > 1,
) }
} }
pub(super) fn set_rows(&mut self, new_rows: usize) { pub(super) fn set_rows(&mut self, new_rows: usize) {
match self { match self {
InputMode::MultiLine { rows, .. } => { InputMode::PlainText { rows, .. } => {
*rows = new_rows; *rows = new_rows;
} }
InputMode::AutoGrow { InputMode::AutoGrow {
@@ -76,25 +80,27 @@ impl InputMode {
} => { } => {
*rows = new_rows.clamp(*min_rows, *max_rows); *rows = new_rows.clamp(*min_rows, *max_rows);
} }
_ => {}
} }
} }
pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) { pub(super) fn update_auto_grow(&mut self, display_map: &DisplayMap) {
if self.is_single_line() { if self.is_single_line() {
return; return;
} }
let wrapped_lines = text_wrapper.len(); let wrapped_lines = display_map.wrap_row_count();
self.set_rows(wrapped_lines); self.set_rows(wrapped_lines);
} }
/// At least 1 row be return. /// At least 1 row be return.
pub(super) fn rows(&self) -> usize { pub(super) fn rows(&self) -> usize {
if !self.is_multi_line() {
return 1;
}
match self { match self {
InputMode::MultiLine { rows, .. } => *rows, InputMode::PlainText { rows, .. } => *rows,
InputMode::AutoGrow { rows, .. } => *rows, InputMode::AutoGrow { rows, .. } => *rows,
_ => 1,
} }
.max(1) .max(1)
} }
@@ -103,7 +109,6 @@ impl InputMode {
#[allow(unused)] #[allow(unused)]
pub(super) fn min_rows(&self) -> usize { pub(super) fn min_rows(&self) -> usize {
match self { match self {
InputMode::MultiLine { .. } => 1,
InputMode::AutoGrow { min_rows, .. } => *min_rows, InputMode::AutoGrow { min_rows, .. } => *min_rows,
_ => 1, _ => 1,
} }
@@ -112,18 +117,29 @@ impl InputMode {
#[allow(unused)] #[allow(unused)]
pub(super) fn max_rows(&self) -> usize { pub(super) fn max_rows(&self) -> usize {
if !self.is_multi_line() {
return 1;
}
match self { match self {
InputMode::MultiLine { .. } => usize::MAX,
InputMode::AutoGrow { max_rows, .. } => *max_rows, InputMode::AutoGrow { max_rows, .. } => *max_rows,
_ => 1, _ => usize::MAX,
} }
} }
#[inline] #[inline]
pub(super) fn tab_size(&self) -> Option<&TabSize> { pub(super) fn is_indentable(&self) -> bool {
match self { match self {
InputMode::MultiLine { tab, .. } => Some(tab), InputMode::PlainText { multi_line, .. } => *multi_line,
_ => None, _ => false,
}
}
#[inline]
pub(super) fn tab_size(&self) -> crate::input::indent::TabSize {
match self {
InputMode::PlainText { tab, .. } => *tab,
_ => crate::input::indent::TabSize::default(),
} }
} }
} }

View File

@@ -0,0 +1,264 @@
use gpui::{Context, Point, Window};
use crate::input::{
InputState, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight,
MoveToEnd, MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveUp, RopeExt as _,
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum MoveDirection {
Up,
Down,
}
impl InputState {
/// Called after moving the cursor. Updates preferred_column if we know where the cursor now is.
pub(super) fn update_preferred_column(&mut self) {
let Some(last_layout) = &self.last_layout else {
self.preferred_column = None;
return;
};
let point = self.text.offset_to_point(self.cursor());
let Some(line) = last_layout.line(point.row) else {
self.preferred_column = None;
return;
};
let Some(pos) = line.position_for_index(point.column, last_layout, false) else {
self.preferred_column = None;
return;
};
self.preferred_column = Some((pos.x, point.column));
}
/// Move the cursor to the given offset.
///
/// The offset is the UTF-8 offset.
///
/// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset.
pub(crate) fn move_to(
&mut self,
offset: usize,
direction: Option<MoveDirection>,
cx: &mut Context<Self>,
) {
let offset = offset.clamp(0, self.text.len());
self.cursor_line_end_affinity = false;
self.selected_range = (offset..offset).into();
self.scroll_to(offset, direction, cx);
self.pause_blink_cursor(cx);
self.update_preferred_column();
cx.notify()
}
/// Move the cursor vertically by one line (up or down) while preserving the column if possible.
///
/// move_lines: Number of lines to move vertically (positive for down, negative for up).
pub(super) fn move_vertical(
&mut self,
move_lines: isize,
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.mode.is_single_line() {
return;
}
let Some(last_layout) = &self.last_layout else {
return;
};
let offset = self.cursor();
let was_preferred_column = self.preferred_column;
let mut display_point = self.display_map.offset_to_wrap_display_point(offset);
// Convert wrap row → display row (skips folded rows), move, then convert back
let current_display_row = self
.display_map
.wrap_row_to_display_row(display_point.row)
.unwrap_or_else(|| {
self.display_map
.nearest_visible_display_row(display_point.row)
});
let max_display_row = self.display_map.display_row_count().saturating_sub(1);
let target_display_row = current_display_row
.saturating_add_signed(move_lines)
.min(max_display_row);
let target_wrap_row = self
.display_map
.display_row_to_wrap_row(target_display_row)
.unwrap_or(display_point.row);
display_point.row = target_wrap_row;
display_point.column = 0;
let mut new_offset = self.display_map.wrap_display_point_to_offset(display_point);
if let Some((preferred_x, column)) = was_preferred_column {
// Get display point again to update local_row.
let mut next_display_point = self.display_map.offset_to_wrap_display_point(new_offset);
next_display_point.column = 0;
let next_point = self
.display_map
.wrap_display_point_to_point(next_display_point);
let line_start_offset = self.text.line_start_offset(next_point.row);
// If in visible range, prefer to use position to get column.
if let Some(line) = last_layout.line(next_point.row) {
if let Some(x) = line.closest_index_for_position(
Point {
x: preferred_x,
y: next_display_point.local_row * last_layout.line_height,
},
last_layout,
) {
new_offset = line_start_offset + x;
}
} else {
// Not in visible range, use column directly.
let max_line_len = self.text.slice_line(next_point.row).len();
new_offset = line_start_offset + column.min(max_line_len);
}
}
self.pause_blink_cursor(cx);
let direction = if move_lines < 0 {
MoveDirection::Up
} else {
MoveDirection::Down
};
self.move_to(new_offset, Some(direction), cx);
// Set back the preferred_column
self.preferred_column = was_preferred_column;
cx.notify();
}
pub(super) fn left(&mut self, _: &MoveLeft, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
if self.selected_range.is_empty() {
self.move_to(self.previous_boundary(self.cursor()), None, cx);
} else {
self.move_to(self.selected_range.start, None, cx)
}
}
pub(super) fn right(&mut self, _: &MoveRight, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
if self.selected_range.is_empty() {
self.move_to(self.next_boundary(self.selected_range.end), None, cx);
} else {
self.move_to(self.selected_range.end, None, cx)
}
}
pub(super) fn up(&mut self, _action: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
if self.mode.is_single_line() {
return;
}
if !self.selected_range.is_empty() {
self.move_to(
self.previous_boundary(self.selected_range.start.saturating_sub(1)),
Some(MoveDirection::Up),
cx,
);
}
self.pause_blink_cursor(cx);
self.move_vertical(-1, window, cx);
}
pub(super) fn down(&mut self, _action: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
if self.mode.is_single_line() {
return;
}
if !self.selected_range.is_empty() {
self.move_to(
self.next_boundary(self.selected_range.end.saturating_sub(1)),
Some(MoveDirection::Down),
cx,
);
}
self.pause_blink_cursor(cx);
self.move_vertical(1, window, cx);
}
pub(super) fn page_up(&mut self, _: &MovePageUp, window: &mut Window, cx: &mut Context<Self>) {
if self.mode.is_single_line() {
return;
}
let Some(last_layout) = &self.last_layout else {
return;
};
let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize;
self.move_vertical(-display_lines, window, cx);
}
pub(super) fn page_down(
&mut self,
_: &MovePageDown,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.mode.is_single_line() {
return;
}
let Some(last_layout) = &self.last_layout else {
return;
};
let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize;
self.move_vertical(display_lines, window, cx);
}
pub(super) fn home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
let offset = self.start_of_line();
self.move_to(offset, Some(MoveDirection::Up), cx);
}
pub(super) fn end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut Context<Self>) {
self.pause_blink_cursor(cx);
let offset = self.end_of_line();
self.move_to(offset, Some(MoveDirection::Down), cx);
self.cursor_line_end_affinity = true;
}
pub(super) fn move_to_start(
&mut self,
_: &MoveToStart,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.move_to(0, None, cx);
}
pub(super) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(self.text.len(), None, cx);
}
pub(super) fn move_to_previous_word(
&mut self,
_: &MoveToPreviousWord,
_: &mut Window,
cx: &mut Context<Self>,
) {
let offset = self.previous_start_of_word();
self.move_to(offset, None, cx);
}
pub(super) fn move_to_next_word(
&mut self,
_: &MoveToNextWord,
_: &mut Window,
cx: &mut Context<Self>,
) {
let offset = self.next_end_of_word();
self.move_to(offset, None, cx);
}
}

View File

@@ -1,70 +1,49 @@
use std::ops::Range; use std::ops::Range;
use rope::{Point, Rope}; use ropey::{LineType, Rope, RopeSlice};
use sum_tree::Bias;
#[cfg(not(target_family = "wasm"))]
pub use tree_sitter::{InputEdit, Point};
use super::cursor::Position; #[cfg(target_family = "wasm")]
/// Stub type for tree-sitter Point on WASM (tree-sitter not available).
/// An extension trait for `Rope` to provide additional utility methods. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub trait RopeExt { pub struct Point {
/// Get the line at the given row (0-based) index, including the `\r` at the end, but not `\n`. pub row: usize,
/// pub column: usize,
/// Return empty rope if the row (0-based) is out of bounds.
fn line(&self, row: usize) -> Rope;
/// Start offset of the line at the given row (0-based) index.
fn line_start_offset(&self, row: usize) -> usize;
/// Line the end offset (including `\n`) of the line at the given row (0-based) index.
///
/// Return the end of the rope if the row is out of bounds.
fn line_end_offset(&self, row: usize) -> usize;
/// Return the number of lines in the rope.
fn lines_len(&self) -> usize;
/// Return the lines iterator.
///
/// Each line is including the `\r` at the end, but not `\n`.
fn lines(&self) -> RopeLines;
/// Check is equal to another rope.
fn eq(&self, other: &Rope) -> bool;
/// Total number of characters in the rope.
fn chars_count(&self) -> usize;
/// Get char at the given offset (byte).
///
/// If the offset is in the middle of a multi-byte character will panic.
///
/// If the offset is out of bounds, return None.
fn char_at(&self, offset: usize) -> Option<char>;
/// Get the byte offset from the given line, column [`Position`] (0-based).
fn position_to_offset(&self, line_col: &Position) -> usize;
/// Get the line, column [`Position`] (0-based) from the given byte offset.
fn offset_to_position(&self, offset: usize) -> Position;
/// Get the word byte range at the given offset (byte).
#[allow(dead_code)]
fn word_range(&self, offset: usize) -> Option<Range<usize>>;
/// Get word at the given offset (byte).
#[allow(dead_code)]
fn word_at(&self, offset: usize) -> String;
} }
#[cfg(target_family = "wasm")]
impl Point {
pub fn new(row: usize, column: usize) -> Self {
Self { row, column }
}
}
#[cfg(target_family = "wasm")]
/// Stub type for tree-sitter InputEdit on WASM (tree-sitter not available).
#[derive(Debug, Clone, Copy)]
pub struct InputEdit {
pub start_byte: usize,
pub old_end_byte: usize,
pub new_end_byte: usize,
pub start_position: Point,
pub old_end_position: Point,
pub new_end_position: Point,
}
pub type Position = lsp_types::Position;
/// An iterator over the lines of a `Rope`. /// An iterator over the lines of a `Rope`.
pub struct RopeLines { pub struct RopeLines<'a> {
rope: &'a Rope,
row: usize, row: usize,
end_row: usize, end_row: usize,
rope: Rope,
} }
impl RopeLines { impl<'a> RopeLines<'a> {
/// Create a new `RopeLines` iterator. /// Create a new `RopeLines` iterator.
pub fn new(rope: Rope) -> Self { pub fn new(rope: &'a Rope) -> Self {
let end_row = rope.lines_len(); let end_row = rope.lines_len();
Self { Self {
row: 0, row: 0,
@@ -73,9 +52,8 @@ impl RopeLines {
} }
} }
} }
impl<'a> Iterator for RopeLines<'a> {
impl Iterator for RopeLines { type Item = RopeSlice<'a>;
type Item = Rope;
#[inline] #[inline]
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
@@ -83,7 +61,7 @@ impl Iterator for RopeLines {
return None; return None;
} }
let line = self.rope.line(self.row); let line = self.rope.slice_line(self.row);
self.row += 1; self.row += 1;
Some(line) Some(line)
} }
@@ -101,23 +79,261 @@ impl Iterator for RopeLines {
} }
} }
impl std::iter::ExactSizeIterator for RopeLines {} impl std::iter::ExactSizeIterator for RopeLines<'_> {}
impl std::iter::FusedIterator for RopeLines {} impl std::iter::FusedIterator for RopeLines<'_> {}
/// An extension trait for [`Rope`] to provide additional utility methods.
pub trait RopeExt {
/// Start offset of the line at the given row (0-based) index.
///
/// # Example
///
/// ```
/// use gpui_component::{Rope, RopeExt};
///
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.line_start_offset(0), 0);
/// assert_eq!(rope.line_start_offset(1), 6);
/// ```
fn line_start_offset(&self, row: usize) -> usize;
/// Line the end offset (including `\n`) of the line at the given row (0-based) index.
///
/// Return the end of the rope if the row is out of bounds.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.line_end_offset(0), 5); // "Hello\n"
/// assert_eq!(rope.line_end_offset(1), 12); // "World\r\n"
/// ```
fn line_end_offset(&self, row: usize) -> usize;
/// Return a line slice at the given row (0-based) index. including `\r` if present, but not `\n`.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.slice_line(0).to_string(), "Hello");
/// assert_eq!(rope.slice_line(1).to_string(), "World\r");
/// assert_eq!(rope.slice_line(2).to_string(), "This is a test 中文");
/// assert_eq!(rope.slice_line(6).to_string(), ""); // out of bounds
/// ```
fn slice_line(&self, row: usize) -> RopeSlice<'_>;
/// Return a slice of rows in the given range (0-based, end exclusive).
///
/// If the range is out of bounds, it will be clamped to the valid range.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.slice_lines(0..2).to_string(), "Hello\nWorld\r");
/// assert_eq!(rope.slice_lines(1..3).to_string(), "World\r\nThis is a test 中文");
/// assert_eq!(rope.slice_lines(2..5).to_string(), "This is a test 中文\nRope");
/// assert_eq!(rope.slice_lines(3..10).to_string(), "Rope");
/// assert_eq!(rope.slice_lines(5..10).to_string(), ""); // out of bounds
/// ```
fn slice_lines(&self, rows_range: Range<usize>) -> RopeSlice<'_>;
/// Return an iterator over all lines in the rope.
///
/// Each line slice includes `\r` if present, but not `\n`.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// let lines: Vec<_> = rope.iter_lines().map(|r| r.to_string()).collect();
/// assert_eq!(lines, vec!["Hello", "World\r", "This is a test 中文", "Rope"]);
/// ```
fn iter_lines(&self) -> RopeLines<'_>;
/// Return the number of lines in the rope.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.lines_len(), 4);
/// ```
fn lines_len(&self) -> usize;
/// Return the length of the row (0-based) in characters, including `\r` if present, but not `\n`.
///
/// If the row is out of bounds, return 0.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// assert_eq!(rope.line_len(0), 5); // "Hello"
/// assert_eq!(rope.line_len(1), 6); // "World\r"
/// assert_eq!(rope.line_len(2), 21); // "This is a test 中文"
/// assert_eq!(rope.line_len(4), 0); // out of bounds
/// ```
fn line_len(&self, row: usize) -> usize;
/// Replace the text in the given byte range with new text.
///
/// # Panics
///
/// - If the range is not on char boundary.
/// - If the range is out of bounds.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let mut rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope");
/// rope.replace(6..11, "Universe");
/// assert_eq!(rope.to_string(), "Hello\nUniverse\r\nThis is a test 中文\nRope");
/// ```
fn replace(&mut self, range: Range<usize>, new_text: &str);
/// Get char at the given offset (byte).
///
/// - If the offset is in the middle of a multi-byte character will panic.
/// - If the offset is out of bounds, return None.
fn char_at(&self, offset: usize) -> Option<char>;
/// Get the byte offset from the given line, column [`Position`] (0-based).
///
/// The column is in characters.
fn position_to_offset(&self, line_col: &Position) -> usize;
/// Get the line, column [`Position`] (0-based) from the given byte offset.
///
/// The column is in characters.
fn offset_to_position(&self, offset: usize) -> Position;
/// Get point (row, column) from the given byte offset.
///
/// The column is in bytes.
fn offset_to_point(&self, offset: usize) -> Point;
/// Get byte offset from the given point (row, column).
///
/// The column is 0-based in bytes.
fn point_to_offset(&self, point: Point) -> usize;
/// Get the word byte range at the given byte offset (0-based).
fn word_range(&self, offset: usize) -> Option<Range<usize>>;
/// Get word at the given byte offset (0-based).
fn word_at(&self, offset: usize) -> String;
/// Convert offset in UTF-16 to byte offset (0-based).
///
/// Runs in O(log N) time.
fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize;
/// Convert byte offset (0-based) to offset in UTF-16.
///
/// Runs in O(log N) time.
fn offset_to_offset_utf16(&self, offset: usize) -> usize;
/// Get a clipped offset (avoid in a char boundary).
///
/// - If Bias::Left and inside the char boundary, return the ix - 1;
/// - If Bias::Right and in inside char boundary, return the ix + 1;
/// - Otherwise return the ix.
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// use sum_tree::Bias;
///
/// let rope = Rope::from("Hello 中文🎉 test\nRope");
/// assert_eq!(rope.clip_offset(5, Bias::Left), 5);
/// // Inside multi-byte character '中' (3 bytes)
/// assert_eq!(rope.clip_offset(7, Bias::Left), 6);
/// assert_eq!(rope.clip_offset(7, Bias::Right), 9);
/// ```
fn clip_offset(&self, offset: usize, bias: Bias) -> usize;
/// Convert offset in characters to byte offset (0-based).
///
/// Run in O(n) time.
///
/// # Example
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("a 中文🎉 test\nRope");
/// assert_eq!(rope.char_index_to_offset(0), 0);
/// assert_eq!(rope.char_index_to_offset(1), 1);
/// assert_eq!(rope.char_index_to_offset(3), "a 中".len());
/// assert_eq!(rope.char_index_to_offset(5), "a 中文🎉".len());
/// ```
fn char_index_to_offset(&self, char_index: usize) -> usize;
/// Convert byte offset (0-based) to offset in characters.
///
/// Run in O(n) time.
///
/// # Example
///
/// ```
/// use gpui_component::{Rope, RopeExt};
/// let rope = Rope::from("a 中文🎉 test\nRope");
/// assert_eq!(rope.offset_to_char_index(0), 0);
/// assert_eq!(rope.offset_to_char_index(1), 1);
/// assert_eq!(rope.offset_to_char_index(3), 3);
/// assert_eq!(rope.offset_to_char_index(4), 3);
/// ```
fn offset_to_char_index(&self, offset: usize) -> usize;
}
impl RopeExt for Rope { impl RopeExt for Rope {
fn line(&self, row: usize) -> Rope { fn slice_line(&self, row: usize) -> RopeSlice<'_> {
let start = self.line_start_offset(row); let total_lines = self.lines_len();
let end = start + self.line_len(row as u32) as usize; if row >= total_lines {
return self.slice(0..0);
}
let line = self.line(row, LineType::LF);
if line.len() > 0 {
let line_end = line.len() - 1;
if line.is_char_boundary(line_end) && line.char(line_end) == '\n' {
return line.slice(..line_end);
}
}
line
}
fn slice_lines(&self, rows_range: Range<usize>) -> RopeSlice<'_> {
let start = self.line_start_offset(rows_range.start);
let end = self.line_end_offset(rows_range.end.saturating_sub(1));
self.slice(start..end) self.slice(start..end)
} }
fn iter_lines(&self) -> RopeLines<'_> {
RopeLines::new(self)
}
fn line_len(&self, row: usize) -> usize {
self.slice_line(row).len()
}
fn line_start_offset(&self, row: usize) -> usize { fn line_start_offset(&self, row: usize) -> usize {
let row = row as u32;
self.point_to_offset(Point::new(row, 0)) self.point_to_offset(Point::new(row, 0))
} }
fn offset_to_point(&self, offset: usize) -> Point {
let offset = self.clip_offset(offset, Bias::Left);
let row = self.byte_to_line_idx(offset, LineType::LF);
let line_start = self.line_to_byte_idx(row, LineType::LF);
let column = offset.saturating_sub(line_start);
Point::new(row, column)
}
fn point_to_offset(&self, point: Point) -> usize {
if point.row >= self.lines_len() {
return self.len();
}
let line_start = self.line_to_byte_idx(point.row, LineType::LF);
line_start + point.column
}
fn position_to_offset(&self, pos: &Position) -> usize { fn position_to_offset(&self, pos: &Position) -> usize {
let line = self.line(pos.line as usize); let line = self.slice_line(pos.line as usize);
self.line_start_offset(pos.line as usize) self.line_start_offset(pos.line as usize)
+ line + line
.chars() .chars()
@@ -128,34 +344,22 @@ impl RopeExt for Rope {
fn offset_to_position(&self, offset: usize) -> Position { fn offset_to_position(&self, offset: usize) -> Position {
let point = self.offset_to_point(offset); let point = self.offset_to_point(offset);
let line = self.line(point.row as usize); let line = self.slice_line(point.row);
let column = line.clip_offset(point.column as usize, sum_tree::Bias::Left); let offset = line.utf16_to_byte_idx(line.byte_to_utf16_idx(point.column));
let character = line.slice(0..column).chars().count(); let character = line.slice(..offset).chars().count();
Position::new(point.row, character as u32) Position::new(point.row as u32, character as u32)
} }
fn line_end_offset(&self, row: usize) -> usize { fn line_end_offset(&self, row: usize) -> usize {
if row > self.max_point().row as usize { if row > self.lines_len() {
return self.len(); return self.len();
} }
self.line_start_offset(row) + self.line_len(row as u32) as usize self.line_start_offset(row) + self.line_len(row)
} }
fn lines_len(&self) -> usize { fn lines_len(&self) -> usize {
self.max_point().row as usize + 1 self.len_lines(LineType::LF)
}
fn lines(&self) -> RopeLines {
RopeLines::new(self.clone())
}
fn eq(&self, other: &Rope) -> bool {
self.summary() == other.summary()
}
fn chars_count(&self) -> usize {
self.chars().count()
} }
fn char_at(&self, offset: usize) -> Option<char> { fn char_at(&self, offset: usize) -> Option<char> {
@@ -163,8 +367,7 @@ impl RopeExt for Rope {
return None; return None;
} }
let offset = self.clip_offset(offset, sum_tree::Bias::Left); self.get_char(offset).ok()
self.slice(offset..self.len()).chars().next()
} }
fn word_range(&self, offset: usize) -> Option<Range<usize>> { fn word_range(&self, offset: usize) -> Option<Range<usize>> {
@@ -172,10 +375,9 @@ impl RopeExt for Rope {
return None; return None;
} }
let offset = self.clip_offset(offset, sum_tree::Bias::Left);
let mut left = String::new(); let mut left = String::new();
for c in self.reversed_chars_at(offset) { let offset = self.clip_offset(offset, Bias::Left);
for c in self.chars_at(offset).reversed() {
if c.is_alphanumeric() || c == '_' { if c.is_alphanumeric() || c == '_' {
left.insert(0, c); left.insert(0, c);
} else { } else {
@@ -191,11 +393,7 @@ impl RopeExt for Rope {
let end = offset + right.len(); let end = offset + right.len();
if start == end { if start == end { None } else { Some(start..end) }
None
} else {
Some(start..end)
}
} }
fn word_at(&self, offset: usize) -> String { fn word_at(&self, offset: usize) -> String {
@@ -205,4 +403,54 @@ impl RopeExt for Rope {
String::new() String::new()
} }
} }
#[inline]
fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize {
if offset_utf16 > self.len_utf16() {
return self.len();
}
self.utf16_to_byte_idx(offset_utf16)
}
#[inline]
fn offset_to_offset_utf16(&self, offset: usize) -> usize {
if offset > self.len() {
return self.len_utf16();
}
self.byte_to_utf16_idx(offset)
}
fn replace(&mut self, range: Range<usize>, new_text: &str) {
let range =
self.clip_offset(range.start, Bias::Left)..self.clip_offset(range.end, Bias::Right);
self.remove(range.clone());
self.insert(range.start, new_text);
}
fn clip_offset(&self, offset: usize, bias: Bias) -> usize {
if offset > self.len() {
return self.len();
}
if self.is_char_boundary(offset) {
return offset;
}
if bias == Bias::Left {
self.floor_char_boundary(offset)
} else {
self.ceil_char_boundary(offset)
}
}
fn char_index_to_offset(&self, char_offset: usize) -> usize {
self.chars().take(char_offset).map(|c| c.len_utf8()).sum()
}
fn offset_to_char_index(&self, offset: usize) -> usize {
let offset = self.clip_offset(offset, Bias::Right);
self.slice(..offset).chars().count()
}
} }

View File

@@ -0,0 +1,140 @@
use std::ops::Range;
use gpui::{Context, Window};
use ropey::Rope;
use sum_tree::Bias;
use crate::input::{InputState, RopeExt};
impl InputState {
/// Select the word at the given offset on double-click.
///
/// The offset is the UTF-8 offset.
pub(super) fn select_word(&mut self, offset: usize, _: &mut Window, cx: &mut Context<Self>) {
let Some(range) = TextSelector::word_range(&self.text, offset) else {
return;
};
self.selected_range = (range.start..range.end).into();
self.selected_word_range = Some(self.selected_range);
cx.notify()
}
/// Select the line at the given offset on triple-click.
///
/// The offset is the UTF-8 offset.
pub(super) fn select_line(&mut self, offset: usize, _: &mut Window, cx: &mut Context<Self>) {
let range = TextSelector::line_range(&self.text, offset);
self.selected_range = (range.start..range.end).into();
self.selected_word_range = None;
cx.notify()
}
}
struct TextSelector;
impl TextSelector {
/// Select a line in the given text at the specified offset.
///
/// The offset is the UTF-8 offset.
///
/// Returns the start and end offsets of the selected line.
pub fn line_range(text: &Rope, offset: usize) -> Range<usize> {
let offset = text.clip_offset(offset, Bias::Left);
let row = text.offset_to_point(offset).row;
let start = text.line_start_offset(row);
let end = text.line_end_offset(row);
start..end
}
/// Select a word in the given text at the specified offset.
///
/// The offset is the UTF-8 offset.
///
/// Returns the start and end offsets of the selected word.
pub fn word_range(text: &Rope, offset: usize) -> Option<Range<usize>> {
let offset = text.clip_offset(offset, Bias::Left);
let char = text.char_at(offset)?;
let end = offset + char.len_utf8();
let prev_chars = text.chars_at(offset).reversed().take(128);
let next_chars = text.chars_at(end).take(128);
Some(word_range_from_chars(offset, char, prev_chars, next_chars))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CharType {
/// a-z, A-Z, 0-9, _
Word,
/// '\t', ' ', '\u{00A0}' etc.
Whitespace,
/// \n, \r
Newline,
/// . , ; : ( ) [ ] { } ... or CJK characters: `汉`, `🎉` etc.
Other,
}
impl From<char> for CharType {
fn from(c: char) -> Self {
match c {
c if is_word_char(c) => CharType::Word,
c if c == '\n' || c == '\r' => CharType::Newline,
c if c.is_whitespace() => CharType::Whitespace,
_ => CharType::Other,
}
}
}
impl CharType {
fn is_connectable(self, c: char) -> bool {
matches!(
(self, CharType::from(c)),
(CharType::Word, CharType::Word) | (CharType::Whitespace, CharType::Whitespace)
)
}
}
fn is_word_char(c: char) -> bool {
matches!(c, '_')
// ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
|| c.is_ascii_alphanumeric()
// Latin script in Unicode for French, German, Spanish, etc.
|| matches!(c, '\u{00C0}'..='\u{00FF}')
|| matches!(c, '\u{0100}'..='\u{017F}')
|| matches!(c, '\u{0180}'..='\u{024F}')
// Cyrillic for Russian, Ukrainian, etc.
|| matches!(c, '\u{0400}'..='\u{04FF}')
// Vietnamese
|| matches!(c, '\u{1E00}'..='\u{1EFF}')
|| matches!(c, '\u{0300}'..='\u{036F}')
}
pub(crate) fn word_range_from_chars(
offset: usize,
c: char,
prev_chars: impl Iterator<Item = char>,
next_chars: impl Iterator<Item = char>,
) -> Range<usize> {
let char_type = CharType::from(c);
let mut start = offset;
let mut end = offset + c.len_utf8();
for prev in prev_chars.take(128) {
if char_type.is_connectable(prev) {
start -= prev.len_utf8();
} else {
break;
}
}
for next in next_chars.take(128) {
if char_type.is_connectable(next) {
end += next.len_utf8();
} else {
break;
}
}
start..end
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,227 +0,0 @@
use std::ops::Range;
use gpui::{App, Font, LineFragment, Pixels};
use rope::Rope;
use super::rope_ext::RopeExt;
/// A line with soft wrapped lines info.
#[derive(Clone)]
pub(super) struct LineItem {
/// The original line text.
line: Rope,
/// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line).
///
/// FIXME: Here in somecase, the `line_wrapper.wrap_line` has returned different
/// like the `window.text_system().shape_text`. So, this value may not equal
/// the actual rendered lines.
wrapped_lines: Vec<Range<usize>>,
}
impl LineItem {
/// Get the bytes length of this line.
#[inline]
pub(super) fn len(&self) -> usize {
self.line.len()
}
/// Get number of soft wrapped lines of this line (include the first line).
#[inline]
pub(super) fn lines_len(&self) -> usize {
self.wrapped_lines.len()
}
/// Get the height of this line item with given line height.
pub(super) fn height(&self, line_height: Pixels) -> Pixels {
self.lines_len() as f32 * line_height
}
}
/// 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(super) 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 lines by split \n
pub(super) lines: Vec<LineItem>,
_initialized: bool,
}
#[allow(unused)]
impl TextWrapper {
pub(super) fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
Self {
text: Rope::new(),
font,
font_size,
wrap_width,
soft_lines: 0,
lines: Vec::new(),
_initialized: false,
}
}
#[inline]
pub(super) fn set_default_text(&mut self, text: &Rope) {
self.text = text.clone();
}
/// Get the total number of lines including wrapped lines.
#[inline]
pub(super) fn len(&self) -> usize {
self.soft_lines
}
/// Get the line item by row index.
#[inline]
pub(super) fn line(&self, row: usize) -> Option<&LineItem> {
self.lines.get(row)
}
pub(super) 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(), true, cx);
}
pub(super) 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(), true, cx);
}
pub(super) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) {
if self._initialized {
return;
}
self._initialized = true;
self.update_all(text, true, cx);
}
/// 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(super) fn update(
&mut self,
changed_text: &Rope,
range: &Range<usize>,
new_text: &Rope,
force: bool,
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,
force,
&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,
force: bool,
wrap_line: &mut F,
) where
F: FnMut(&str, Pixels) -> Vec<gpui::Boundary>,
{
if self.text.eq(changed_text) && !force {
return;
}
// Remove the old changed lines.
let start_row = self.text.offset_to_point(range.start).row as usize;
let start_row = start_row.min(self.lines.len().saturating_sub(1));
let end_row = self.text.offset_to_point(range.end).row as usize;
let end_row = end_row.min(self.lines.len().saturating_sub(1));
let rows_range = start_row..=end_row;
// To add the new lines.
let new_start_row = changed_text.offset_to_point(range.start).row as usize;
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 as usize;
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;
for line in changed_text.slice(new_range).lines() {
let line_str = line.to_string();
let mut wrapped_lines = vec![];
let mut prev_boundary_ix = 0;
// 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: line.clone(),
wrapped_lines,
});
}
// dbg!(&new_lines.len());
// dbg!(self.lines.len());
if self.lines.is_empty() {
self.lines = new_lines;
} else {
self.lines.splice(rows_range, new_lines);
}
// dbg!(self.lines.len());
self.text = changed_text.clone();
self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum();
}
/// Update the text wrapper and recalculate the wrapped lines.
///
/// If the `text` is the same as the current text, do nothing.
pub(crate) fn update_all(&mut self, text: &Rope, force: bool, cx: &mut App) {
self.update(text, &(0..text.len()), text, force, cx);
}
}

View File

@@ -1,4 +1,3 @@
pub use anchored::*;
pub use element_ext::ElementExt; pub use element_ext::ElementExt;
pub use event::InteractiveElementExt; pub use event::InteractiveElementExt;
pub use focusable::FocusableCycle; pub use focusable::FocusableCycle;
@@ -7,6 +6,7 @@ pub use index_path::IndexPath;
pub use kbd::*; pub use kbd::*;
pub use root::{Root, window_paddings}; pub use root::{Root, window_paddings};
pub use styled::*; pub use styled::*;
pub use title_bar::*;
pub use window_ext::*; pub use window_ext::*;
pub use crate::Disableable; pub use crate::Disableable;
@@ -34,7 +34,6 @@ pub mod switch;
pub mod tab; pub mod tab;
pub mod tooltip; pub mod tooltip;
mod anchored;
mod element_ext; mod element_ext;
mod event; mod event;
mod focusable; mod focusable;
@@ -43,6 +42,7 @@ mod index_path;
mod kbd; mod kbd;
mod root; mod root;
mod styled; mod styled;
mod title_bar;
mod window_ext; mod window_ext;
/// Initialize the UI module. /// Initialize the UI module.

View File

@@ -1,5 +1,4 @@
use std::ops::Range; use std::ops::Range;
use std::time::Duration;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -9,11 +8,11 @@ use gpui::{
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
UniformListScrollHandle, Window, div, px, size, uniform_list, UniformListScrollHandle, Window, div, px, size, uniform_list,
}; };
use smol::Timer; use instant::Duration;
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
use crate::input::{InputEvent, InputState, TextInput}; use crate::input::{Input, InputEvent, InputState};
use crate::list::ListDelegate; use crate::list::ListDelegate;
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache}; use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
use crate::scroll::{Scrollbar, ScrollbarHandle}; use crate::scroll::{Scrollbar, ScrollbarHandle};
@@ -265,6 +264,7 @@ where
} }
self.set_searching(true, window, cx); self.set_searching(true, window, cx);
let search = self.delegate.perform_search(&text, window, cx); let search = self.delegate.perform_search(&text, window, cx);
if self.rows_cache.len() > 0 { if self.rows_cache.len() > 0 {
@@ -273,6 +273,7 @@ where
self._set_selected_index(None, window, cx); self._set_selected_index(None, window, cx);
} }
let executor = cx.background_executor().clone();
self._search_task = cx.spawn_in(window, async move |this, window| { self._search_task = cx.spawn_in(window, async move |this, window| {
search.await; search.await;
@@ -282,13 +283,14 @@ where
}); });
// Always wait 100ms to avoid flicker // Always wait 100ms to avoid flicker
Timer::after(Duration::from_millis(100)).await; executor.timer(Duration::from_millis(100)).await;
_ = this.update_in(window, |this, window, cx| { _ = this.update_in(window, |this, window, cx| {
this.set_searching(false, window, cx); this.set_searching(false, window, cx);
}); });
}); });
} }
InputEvent::PressEnter { secondary } => self.on_action_confirm( InputEvent::PressEnter { secondary, .. } => self.on_action_confirm(
&Confirm { &Confirm {
secondary: *secondary, secondary: *secondary,
}, },
@@ -498,7 +500,7 @@ where
let scroll_handle = self.scroll_handle.clone(); let scroll_handle = self.scroll_handle.clone();
v_flex() v_flex()
.flex_grow() .flex_grow_1()
.relative() .relative()
.size_full() .size_full()
.when_some(self.options.max_height, |this, h| this.max_h(h)) .when_some(self.options.max_height, |this, h| this.max_h(h))
@@ -632,10 +634,10 @@ where
.border_b_1() .border_b_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.child( .child(
TextInput::new(&input) Input::new(&input)
.with_size(self.options.size) .with_size(self.options.size)
.appearance(false) .appearance(false)
.cleanable() .cleanable(true)
.p_0() .p_0()
.prefix( .prefix(
Icon::new(IconName::Search).text_color(cx.theme().text_muted), Icon::new(IconName::Search).text_color(cx.theme().text_muted),

View File

@@ -1,14 +1,15 @@
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, Focusable,
Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, ParentElement,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, anchored,
deferred, div, px,
}; };
use crate::actions::{Cancel, SelectLeft, SelectRight}; use crate::actions::{Cancel, SelectLeft, SelectRight};
use crate::button::{Button, ButtonVariants}; use crate::button::{Button, ButtonVariants};
use crate::menu::PopupMenu; use crate::menu::PopupMenu;
use crate::{h_flex, Selectable, Sizable}; use crate::{Selectable, Sizable, h_flex};
const CONTEXT: &str = "AppMenuBar"; const CONTEXT: &str = "AppMenuBar";
@@ -241,7 +242,7 @@ impl Render for AppMenu {
.when(is_selected, |this| { .when(is_selected, |this| {
this.child(deferred( this.child(deferred(
anchored() anchored()
.anchor(gpui::Corner::TopLeft) .anchor(gpui::Anchor::TopLeft)
.snap_to_window_with_margin(px(8.)) .snap_to_window_with_margin(px(8.))
.child( .child(
div() div()

View File

@@ -3,10 +3,10 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
anchored, deferred, div, px, AnyElement, App, Context, Corner, DismissEvent, Element, Anchor, AnyElement, App, Context, DismissEvent, Element, ElementId, Entity, Focusable,
ElementId, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, Styled,
StyleRefinement, Styled, Subscription, Window, Subscription, Window, anchored, deferred, div, px,
}; };
use crate::menu::PopupMenu; use crate::menu::PopupMenu;
@@ -41,7 +41,7 @@ pub struct ContextMenu<E: ParentElement + Styled + Sized> {
menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>, menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
// This is not in use, just for style refinement forwarding. // This is not in use, just for style refinement forwarding.
_ignore_style: StyleRefinement, _ignore_style: StyleRefinement,
anchor: Corner, anchor: Anchor,
} }
impl<E: ParentElement + Styled> ContextMenu<E> { impl<E: ParentElement + Styled> ContextMenu<E> {
@@ -51,7 +51,7 @@ impl<E: ParentElement + Styled> ContextMenu<E> {
id: id.into(), id: id.into(),
element: Some(element), element: Some(element),
menu: None, menu: None,
anchor: Corner::TopLeft, anchor: Anchor::TopLeft,
_ignore_style: StyleRefinement::default(), _ignore_style: StyleRefinement::default(),
} }
} }

View File

@@ -1,7 +1,7 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
Context, Corner, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement, Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
RenderOnce, SharedString, StyleRefinement, Styled, Window, RenderOnce, SharedString, StyleRefinement, Styled, Window,
}; };
@@ -18,13 +18,13 @@ pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement +
self, self,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> DropdownMenuPopover<Self> { ) -> DropdownMenuPopover<Self> {
self.dropdown_menu_with_anchor(Corner::TopLeft, f) self.dropdown_menu_with_anchor(Anchor::TopLeft, f)
} }
/// Create a dropdown menu with the given items, anchored to the given corner /// Create a dropdown menu with the given items, anchored to the given corner
fn dropdown_menu_with_anchor( fn dropdown_menu_with_anchor(
mut self, mut self,
anchor: impl Into<Corner>, anchor: impl Into<Anchor>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> DropdownMenuPopover<Self> { ) -> DropdownMenuPopover<Self> {
let style = self.style().clone(); let style = self.style().clone();
@@ -42,7 +42,7 @@ impl DropdownMenu for Avatar {}
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> { pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
id: ElementId, id: ElementId,
style: StyleRefinement, style: StyleRefinement,
anchor: Corner, anchor: Anchor,
trigger: T, trigger: T,
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>, builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
@@ -54,7 +54,7 @@ where
{ {
fn new( fn new(
id: ElementId, id: ElementId,
anchor: impl Into<Corner>, anchor: impl Into<Anchor>,
trigger: T, trigger: T,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self { ) -> Self {
@@ -68,7 +68,7 @@ where
} }
/// Set the anchor corner for the dropdown menu popover. /// Set the anchor corner for the dropdown menu popover.
pub fn anchor(mut self, anchor: impl Into<Corner>) -> Self { pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into(); self.anchor = anchor.into();
self self
} }

View File

@@ -2,7 +2,7 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
Action, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, Corner, DismissEvent, Action, Anchor, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent,
Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, IntoElement, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, IntoElement,
KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, Pixels, Point, Render, ScrollHandle, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, Pixels, Point, Render, ScrollHandle,
SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, anchored, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, anchored,
@@ -299,7 +299,7 @@ pub struct PopupMenu {
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
/// This will update on render /// This will update on render
submenu_anchor: (Corner, Pixels), submenu_anchor: (Anchor, Pixels),
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -322,7 +322,7 @@ impl PopupMenu {
scroll_handle: ScrollHandle::default(), scroll_handle: ScrollHandle::default(),
external_link_icon: true, external_link_icon: true,
size: Size::default(), size: Size::default(),
submenu_anchor: (Corner::TopLeft, Pixels::ZERO), submenu_anchor: (Anchor::TopLeft, Pixels::ZERO),
_subscriptions: vec![], _subscriptions: vec![],
} }
} }
@@ -840,7 +840,7 @@ impl PopupMenu {
} }
fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) { fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
let handled = if matches!(self.submenu_anchor.0, Corner::TopLeft | Corner::BottomLeft) { let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
self._unselect_submenu(window, cx) self._unselect_submenu(window, cx)
} else { } else {
self._select_submenu(window, cx) self._select_submenu(window, cx)
@@ -861,7 +861,7 @@ impl PopupMenu {
} }
fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) { fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
let handled = if matches!(self.submenu_anchor.0, Corner::TopLeft | Corner::BottomLeft) { let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
self._select_submenu(window, cx) self._select_submenu(window, cx)
} else { } else {
self._unselect_submenu(window, cx) self._unselect_submenu(window, cx)
@@ -930,8 +930,9 @@ impl PopupMenu {
}; };
match parent.read(cx).submenu_anchor.0 { match parent.read(cx).submenu_anchor.0 {
Corner::TopLeft | Corner::BottomLeft => Side::Left, Anchor::TopLeft | Anchor::BottomLeft => Side::Left,
Corner::TopRight | Corner::BottomRight => Side::Right, Anchor::TopRight | Anchor::BottomRight => Side::Right,
_ => Side::Left,
} }
} }
@@ -1041,14 +1042,14 @@ impl PopupMenu {
let bounds = self.bounds; let bounds = self.bounds;
let max_width = self.max_width(); let max_width = self.max_width();
let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width { let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
(Corner::TopRight, -px(16.)) (Anchor::TopRight, -px(16.))
} else { } else {
(Corner::TopLeft, bounds.size.width - px(8.)) (Anchor::TopLeft, bounds.size.width - px(8.))
}; };
let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height; let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
self.submenu_anchor = if is_bottom_pos { self.submenu_anchor = if is_bottom_pos {
(anchor.other_side_corner_along(gpui::Axis::Vertical), left) (anchor.other_side_along(gpui::Axis::Vertical), left)
} else { } else {
(anchor, left) (anchor, left)
}; };
@@ -1230,7 +1231,7 @@ impl PopupMenu {
this.child({ this.child({
let (anchor, left) = self.submenu_anchor; let (anchor, left) = self.submenu_anchor;
let is_bottom_pos = let is_bottom_pos =
matches!(anchor, Corner::BottomLeft | Corner::BottomRight); matches!(anchor, Anchor::BottomLeft | Anchor::BottomRight);
anchored() anchored()
.anchor(anchor) .anchor(anchor)
.child( .child(

View File

@@ -1,5 +1,5 @@
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use instant::Duration;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -521,12 +521,14 @@ impl RenderOnce for Modal {
offset: point(px(0.), px(20.)), offset: point(px(0.), px(20.)),
blur_radius: px(25.), blur_radius: px(25.),
spread_radius: px(-5.), spread_radius: px(-5.),
inset: false,
}, },
BoxShadow { BoxShadow {
color: hsla(0., 0., 0., 0.1 * delta), color: hsla(0., 0., 0., 0.1 * delta),
offset: point(px(0.), px(8.)), offset: point(px(0.), px(8.)),
blur_radius: px(10.), blur_radius: px(10.),
spread_radius: px(-6.), spread_radius: px(-6.),
inset: false,
}, },
]; ];
this.top(y + y_offset).shadow(shadow) this.top(y + y_offset).shadow(shadow)

View File

@@ -1,16 +1,16 @@
use std::any::TypeId; use std::any::TypeId;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use instant::Duration;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, DismissEvent, Anchor, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
Window, div, px, relative, Subscription, Window, div, px, relative,
}; };
use theme::{ActiveTheme, Anchor}; use theme::ActiveTheme;
use crate::animation::cubic_bezier; use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonVariants as _}; use crate::button::{Button, ButtonVariants as _};
@@ -288,6 +288,7 @@ impl Styled for Notification {
&mut self.style &mut self.style
} }
} }
impl Render for Notification { impl Render for Notification {
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 content = self let content = self
@@ -297,10 +298,10 @@ impl Render for Notification {
let action = self.action_builder.clone().map(|builder| { let action = self.action_builder.clone().map(|builder| {
builder(self, window, cx) builder(self, window, cx)
.xsmall() .small()
.primary() .primary()
.px_3() .px_4()
.font_semibold() .font_medium()
}); });
let icon = match self.kind { let icon = match self.kind {
@@ -363,8 +364,14 @@ impl Render for Notification {
}) })
.when_some(content, |this, content| this.child(content)) .when_some(content, |this, content| this.child(content))
.when_some(action, |this, action| { .when_some(action, |this, action| {
this.gap_2() this.gap_2().child(
.child(h_flex().w_full().flex_1().justify_end().child(action)) h_flex()
.mt_2()
.w_full()
.flex_1()
.justify_end()
.child(action),
)
}), }),
) )
.child( .child(
@@ -423,12 +430,17 @@ impl Render for Notification {
let y_offset = px(0.) + delta * px(45.); let y_offset = px(0.) + delta * px(45.);
that.top(px(0.) + y_offset) that.top(px(0.) + y_offset)
} }
_ => that,
} }
} else { } else {
let opacity = delta; let opacity = delta;
let y_offset = match placement { let y_offset = match placement {
placement if placement.is_top() => px(-45.) + delta * px(45.), Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
placement if placement.is_bottom() => px(45.) - delta * px(45.), px(-45.) + delta * px(45.)
}
Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
px(45.) - delta * px(45.)
}
_ => px(0.), _ => px(0.),
}; };
this.top(px(0.) + y_offset) this.top(px(0.) + y_offset)

View File

@@ -2,15 +2,14 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter, Anchor, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter,
FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton,
ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, Styled, ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, Styled,
Subscription, Window, deferred, div, px, Subscription, Window, anchored, deferred, div, px,
}; };
use theme::Anchor;
use crate::actions::Cancel; use crate::actions::Cancel;
use crate::{ElementExt, Selectable, StyledExt as _, anchored, v_flex}; use crate::{ElementExt, Selectable, StyledExt as _, v_flex};
const CONTEXT: &str = "Popover"; const CONTEXT: &str = "Popover";
@@ -175,19 +174,26 @@ impl Popover {
self self
} }
fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> { pub(crate) fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> {
let offset = if anchor.is_center() { match anchor {
gpui::point(trigger_bounds.size.width.half(), px(0.)) Anchor::TopLeft => trigger_bounds.origin,
} else { Anchor::TopCenter => trigger_bounds.top_center(),
Point::default() Anchor::TopRight => trigger_bounds.top_right(),
}; Anchor::BottomLeft => Point {
x: trigger_bounds.origin.x,
trigger_bounds.corner(anchor.swap_vertical().into()) y: trigger_bounds.origin.y - trigger_bounds.size.height,
+ offset },
+ Point { Anchor::BottomCenter => Point {
x: px(0.), x: trigger_bounds.top_center().x,
y: -trigger_bounds.size.height, y: trigger_bounds.origin.y - trigger_bounds.size.height,
} },
Anchor::BottomRight => Point {
x: trigger_bounds.top_right().x,
y: trigger_bounds.origin.y - trigger_bounds.size.height,
},
// Fallback for LeftCenter/RightCenter adjust as needed.
_ => trigger_bounds.origin,
}
} }
} }
@@ -330,6 +336,7 @@ impl Popover {
.map(|this| match anchor { .map(|this| match anchor {
Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(), Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(),
Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(), Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(),
Anchor::LeftCenter | Anchor::RightCenter => this.top_1(), // Fallback for centered
}) })
} }
} }

View File

@@ -253,7 +253,7 @@ impl RenderOnce for ResizablePanel {
div() div()
.id(("resizable-panel", self.panel_ix)) .id(("resizable-panel", self.panel_ix))
.flex() .flex()
.flex_grow() .flex_grow_1()
.size_full() .size_full()
.relative() .relative()
.when(self.axis.is_vertical(), |this| { .when(self.axis.is_vertical(), |this| {
@@ -265,7 +265,7 @@ impl RenderOnce for ResizablePanel {
// 1. initial_size is None, to use auto size. // 1. initial_size is None, to use auto size.
// 2. initial_size is Some and size is none, to use the initial size of the panel for first time render. // 2. initial_size is Some and size is none, to use the initial size of the panel for first time render.
// 3. initial_size is Some and size is Some, use `size`. // 3. initial_size is Some and size is Some, use `size`.
.when(self.initial_size.is_none(), |this| this.flex_shrink()) .when(self.initial_size.is_none(), |this| this.flex_shrink_1())
.when_some(self.initial_size, |this, initial_size| { .when_some(self.initial_size, |this, initial_size| {
// The `self.size` is None, that mean the initial size for the panel, // The `self.size` is None, that mean the initial size for the panel,
// so we need set `flex_shrink_0` To let it keep the initial size. // so we need set `flex_shrink_0` To let it keep the initial size.

View File

@@ -385,6 +385,7 @@ impl Render for Root {
blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2., blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2.,
spread_radius: px(0.), spread_radius: px(0.),
offset: point(px(0.0), px(0.0)), offset: point(px(0.0), px(0.0)),
inset: false,
}]) }])
}), }),
}) })

View File

@@ -2,15 +2,15 @@ use std::cell::Cell;
use std::ops::Deref; use std::ops::Deref;
use std::panic::Location; use std::panic::Location;
use std::rc::Rc; use std::rc::Rc;
use std::time::{Duration, Instant};
use gpui::{ use gpui::{
App, Axis, BorderStyle, Bounds, ContentMask, Corner, CursorStyle, Edges, Element, ElementId, Anchor, App, Axis, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, ElementId,
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero,
LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill, Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
point, px, relative, size, point, px, relative, size,
}; };
use instant::{Duration, Instant};
use theme::{ActiveTheme, AxisExt, ScrollbarMode}; use theme::{ActiveTheme, AxisExt, ScrollbarMode};
/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH) /// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
@@ -157,7 +157,7 @@ impl ScrollbarStateInner {
let mut state = *self; let mut state = *self;
state.hovered_axis = axis; state.hovered_axis = axis;
if axis.is_some() { if axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now()); state.last_scroll_time = Some(instant::Instant::now());
} }
state state
} }
@@ -166,7 +166,7 @@ impl ScrollbarStateInner {
let mut state = *self; let mut state = *self;
state.hovered_on_thumb = axis; state.hovered_on_thumb = axis;
if self.is_scrollbar_visible() && axis.is_some() { if self.is_scrollbar_visible() && axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now()); state.last_scroll_time = Some(instant::Instant::now());
} }
state state
} }
@@ -648,14 +648,14 @@ impl Element for Scrollbar {
// The clickable area of the thumb // The clickable area of the thumb
let thumb_length = thumb_end - thumb_start - inset * 2; let thumb_length = thumb_end - thumb_start - inset * 2;
let thumb_bounds = if is_vertical { let thumb_bounds = if is_vertical {
Bounds::from_corner_and_size( Bounds::from_anchor_and_size(
Corner::TopRight, Anchor::TopRight,
bounds.top_right() + point(-inset, inset + thumb_start), bounds.top_right() + point(-inset, inset + thumb_start),
size(WIDTH, thumb_length), size(WIDTH, thumb_length),
) )
} else { } else {
Bounds::from_corner_and_size( Bounds::from_anchor_and_size(
Corner::BottomLeft, Anchor::BottomLeft,
bounds.bottom_left() + point(inset + thumb_start, -inset), bounds.bottom_left() + point(inset + thumb_start, -inset),
size(thumb_length, WIDTH), size(thumb_length, WIDTH),
) )
@@ -663,14 +663,14 @@ impl Element for Scrollbar {
// The actual render area of the thumb // The actual render area of the thumb
let thumb_fill_bounds = if is_vertical { let thumb_fill_bounds = if is_vertical {
Bounds::from_corner_and_size( Bounds::from_anchor_and_size(
Corner::TopRight, Anchor::TopRight,
bounds.top_right() + point(-inset, inset + thumb_start), bounds.top_right() + point(-inset, inset + thumb_start),
size(thumb_width, thumb_length), size(thumb_width, thumb_length),
) )
} else { } else {
Bounds::from_corner_and_size( Bounds::from_anchor_and_size(
Corner::BottomLeft, Anchor::BottomLeft,
bounds.bottom_left() + point(inset + thumb_start, -inset), bounds.bottom_left() + point(inset + thumb_start, -inset),
size(thumb_length, thumb_width), size(thumb_length, thumb_width),
) )

View File

@@ -1,4 +1,4 @@
use std::time::Duration; use instant::Duration;
use gpui::{ use gpui::{
bounce, div, ease_in_out, Animation, AnimationExt, IntoElement, RenderOnce, StyleRefinement, bounce, div, ease_in_out, Animation, AnimationExt, IntoElement, RenderOnce, StyleRefinement,

View File

@@ -1,6 +1,6 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use instant::Duration;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{

View File

@@ -2,7 +2,7 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AnyElement, App, Corner, Div, Edges, ElementId, InteractiveElement, IntoElement, ParentElement, Anchor, AnyElement, App, Div, Edges, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, ScrollHandle, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled, RenderOnce, ScrollHandle, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled,
Window, div, px, Window, div, px,
}; };
@@ -282,7 +282,7 @@ impl RenderOnce for TabBar {
this this
}) })
.anchor(Corner::TopRight), .anchor(Anchor::TopRight),
) )
}) })
.when_some(self.suffix, |this, suffix| this.child(suffix)) .when_some(self.suffix, |this, suffix| this.child(suffix))

351
crates/ui/src/title_bar.rs Normal file
View File

@@ -0,0 +1,351 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Context, Decorations, Hsla, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement as _,
StyleRefinement, Styled, TitlebarOptions, Window, WindowControlArea, div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use crate::{Icon, IconName, InteractiveElementExt as _, Sizable as _, StyledExt, h_flex};
pub const TITLE_BAR_HEIGHT: Pixels = px(34.);
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
/// TitleBar used to customize the appearance of the title bar.
///
/// We can put some elements inside the title bar.
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub struct TitleBar {
style: StyleRefinement,
children: SmallVec<[AnyElement; 1]>,
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
}
impl TitleBar {
/// Create a new TitleBar.
pub fn new() -> Self {
Self {
style: StyleRefinement::default(),
children: SmallVec::new(),
on_close_window: None,
}
}
/// Returns the default title bar options for compatible with the [`crate::TitleBar`].
pub fn title_bar_options() -> TitlebarOptions {
TitlebarOptions {
title: None,
appears_transparent: true,
traffic_light_position: Some(gpui::point(px(9.0), px(9.0))),
}
}
/// Add custom for close window event, default is None, then click X button will call `window.remove_window()`.
/// Linux only, this will do nothing on other platforms.
pub fn on_close_window(
mut self,
f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
if cfg!(target_os = "linux") {
self.on_close_window = Some(Rc::new(Box::new(f)));
}
self
}
}
impl Default for TitleBar {
fn default() -> Self {
Self::new()
}
}
// The Windows control buttons have a fixed width of 35px.
//
// We don't need implementation the click event for the control buttons.
// If user clicked in the bounds, the window event will be triggered.
#[derive(IntoElement, Clone)]
#[allow(clippy::type_complexity)]
enum ControlIcon {
Minimize,
Restore,
Maximize,
Close {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
},
}
impl ControlIcon {
fn minimize() -> Self {
Self::Minimize
}
fn restore() -> Self {
Self::Restore
}
fn maximize() -> Self {
Self::Maximize
}
#[allow(clippy::type_complexity)]
fn close(on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>) -> Self {
Self::Close { on_close_window }
}
fn id(&self) -> &'static str {
match self {
Self::Minimize => "minimize",
Self::Restore => "restore",
Self::Maximize => "maximize",
Self::Close { .. } => "close",
}
}
fn icon(&self) -> IconName {
match self {
Self::Minimize => IconName::WindowMinimize,
Self::Restore => IconName::WindowRestore,
Self::Maximize => IconName::WindowMaximize,
Self::Close { .. } => IconName::WindowClose,
}
}
fn window_control_area(&self) -> WindowControlArea {
match self {
Self::Minimize => WindowControlArea::Min,
Self::Restore | Self::Maximize => WindowControlArea::Max,
Self::Close { .. } => WindowControlArea::Close,
}
}
fn is_close(&self) -> bool {
matches!(self, Self::Close { .. })
}
#[inline]
fn hover_fg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger_foreground
} else {
cx.theme().text
}
}
#[inline]
fn hover_bg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger_background
} else {
cx.theme().ghost_element_hover
}
}
#[inline]
fn active_bg(&self, cx: &mut App) -> Hsla {
if self.is_close() {
cx.theme().danger_active
} else {
cx.theme().ghost_element_active
}
}
}
impl RenderOnce for ControlIcon {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let is_linux = cfg!(target_os = "linux");
let is_windows = cfg!(target_os = "windows");
let icon = self.clone();
let hover_fg = self.hover_fg(cx);
let hover_bg = self.hover_bg(cx);
let active_bg = self.active_bg(cx);
let on_close_window = match &self {
ControlIcon::Close { on_close_window } => on_close_window.clone(),
_ => None,
};
div()
.id(self.id())
.flex()
.w(TITLE_BAR_HEIGHT)
.h_full()
.flex_shrink_0()
.justify_center()
.content_center()
.items_center()
.text_color(cx.theme().text)
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
.active(|style| style.bg(active_bg).text_color(hover_fg))
.when(is_windows, |this| {
this.window_control_area(self.window_control_area())
})
.when(is_linux, |this| {
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_click(move |_, window, cx| {
cx.stop_propagation();
match icon {
Self::Minimize => window.minimize_window(),
Self::Restore | Self::Maximize => window.zoom_window(),
Self::Close { .. } => {
if let Some(f) = on_close_window.clone() {
f(&ClickEvent::default(), window, cx);
} else {
window.remove_window();
}
}
}
})
})
.child(Icon::new(self.icon()).small())
}
}
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
struct WindowControls {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
}
impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
return div().id("window-controls");
}
h_flex()
.id("window-controls")
.items_center()
.flex_shrink_0()
.h_full()
.child(ControlIcon::minimize())
.child(if window.is_maximized() {
ControlIcon::restore()
} else {
ControlIcon::maximize()
})
.child(ControlIcon::close(self.on_close_window))
}
}
impl Styled for TitleBar {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl ParentElement for TitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
struct TitleBarState {
should_move: bool,
}
impl Render for TitleBarState {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
impl RenderOnce for TitleBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_client_decorated = matches!(window.window_decorations(), Decorations::Client { .. });
let is_web = cfg!(target_family = "wasm");
let is_linux = cfg!(target_os = "linux");
let is_macos = cfg!(target_os = "macos");
let state = window.use_state(cx, |_, _| TitleBarState { should_move: false });
div().flex_shrink_0().child(
div()
.id("title-bar")
.flex()
.flex_row()
.items_center()
.justify_between()
.h(TITLE_BAR_HEIGHT)
.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()
}
})
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().title_bar)
.refine_style(&self.style)
.when(is_linux, |this| {
this.on_double_click(|_, window, _| window.zoom_window())
})
.when(is_macos, |this| {
this.on_double_click(|_, window, _| window.titlebar_double_click())
})
.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = true;
}),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}),
)
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
if state.should_move {
state.should_move = false;
window.start_window_move();
}
}))
.child(
h_flex()
.id("bar")
.h_full()
.justify_between()
.flex_shrink_0()
.flex_1()
.when(!is_web, |this| {
this.window_control_area(WindowControlArea::Drag)
.when(window.is_fullscreen(), |this| this.pl_3())
.when(is_linux && is_client_decorated, |this| {
this.child(
div()
.top_0()
.left_0()
.absolute()
.size_full()
.h_full()
.on_mouse_down(
MouseButton::Right,
move |ev, window, _| {
window.show_window_menu(ev.position)
},
),
)
})
})
.children(self.children),
)
.child(WindowControls {
on_close_window: self.on_close_window,
}),
)
}
}

View File

@@ -1,21 +1,26 @@
[package] [package]
name = "relay_auth" name = "workspace"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
state = { path = "../state" }
settings = { path = "../settings" }
common = { path = "../common" }
theme = { path = "../theme" }
ui = { path = "../ui" } 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" }
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
smallvec.workspace = true serde.workspace = true
smol.workspace = true
flume.workspace = true
log.workspace = true log.workspace = true
smallvec.workspace = true

View File

@@ -1,4 +1,4 @@
use std::time::Duration; use instant::Duration;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
@@ -7,15 +7,14 @@ use gpui::{
Subscription, Task, Window, div, Subscription, Task, Window, div,
}; };
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use smallvec::{SmallVec, smallvec}; use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
use state::{CoopAuthUrlHandler, NostrRegistry, StateEvent};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, v_flex}; use ui::{Disableable, StyledExt, WindowExtension, v_flex};
#[derive(Debug)] #[derive(Debug)]
pub struct ImportKey { pub struct ImportIdentity {
/// Secret key input /// Secret key input
key_input: Entity<InputState>, key_input: Entity<InputState>,
@@ -25,130 +24,76 @@ pub struct ImportKey {
/// 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>>>,
/// Event subscriptions /// Input subscription
_subscriptions: SmallVec<[Subscription; 2]>, _subscription: Option<Subscription>,
} }
impl ImportKey { impl ImportIdentity {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx); let key_input = cx.new(|cx| InputState::new(window, cx));
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 mut subscriptions = smallvec![]; let input_subscription =
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![],
_subscriptions: subscriptions, _subscription: Some(input_subscription),
} }
} }
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();
if value.starts_with("bunker://") { // Set loading state
self.bunker(&value, window, cx); self.set_loading(true, cx);
return;
}
if value.starts_with("ncryptsec1") { if value.starts_with("ncryptsec1") {
self.ncryptsec(value, password, window, cx); self.ncryptsec(value, password, window, cx);
return; return;
} }
if value.starts_with("bunker://") {
match NostrConnectUri::parse(value) {
Ok(uri) => {
self.bunker(uri, window, cx);
}
Err(e) => {
self.set_error(e.to_string(), cx);
}
}
return;
}
if let Ok(secret) = SecretKey::parse(&value) { if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret); let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
// Update the signer // Update the signer
nostr.update(cx, |this, cx| { nostr.update(cx, |this, cx| {
this.add_key_signer(&keys, cx); this.set_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>,
@@ -179,9 +124,10 @@ impl ImportKey {
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(cx, |this, cx| { nostr.update_in(cx, |this, window, cx| {
this.add_key_signer(&keys, cx); this.set_signer(keys, cx);
}); window.close_modal(cx);
})?;
} }
Err(e) => { Err(e) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@@ -194,16 +140,43 @@ impl ImportKey {
})); }));
} }
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(())
}));
}
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());
@@ -224,47 +197,44 @@ impl ImportKey {
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 ImportKey { impl Render for ImportIdentity {
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_2() .gap_2()
.text_sm()
.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("nsec or bunker://") .child("Continue with existing key or bunker connection")
.child(TextInput::new(&self.key_input)), .child(Input::new(&self.key_input)),
)
.when(
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|this| {
this.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Password:")
.child(TextInput::new(&self.pass_input)),
)
},
) )
.when(require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Decrypt Password:")
.child(Input::new(&self.pass_input)),
)
})
.when(key_warning, |this| {
this.child(
div()
.v_flex()
.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")
@@ -275,18 +245,6 @@ impl Render for ImportKey {
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()

View File

@@ -1,5 +1,3 @@
pub mod accounts;
pub mod connect;
pub mod import; pub mod import;
pub mod restore; pub mod restore;
pub mod screening; pub mod screening;

View File

@@ -1,4 +1,4 @@
use std::time::Duration; use instant::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_connect::prelude::*; use nostr_sdk::prelude::*;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{Input, InputEvent, InputState};
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(TextInput::new(&self.key_input)), .child(Input::new(&self.key_input)),
) )
.child( .child(
Button::new("restore") Button::new("restore")

View File

@@ -1,13 +1,13 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::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, move |this, _window, cx| { cx.defer_in(window, |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,12 +78,13 @@ 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 task: Task<Result<bool, Error>> = cx.background_spawn(async move { let Some(current_user) = nostr.read(cx).current_user() else {
let signer = client.signer().context("Signer not found")?; return;
let signer_pubkey = signer.get_public_key().await?; };
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
// Check if user is in contact list // Check if user is in contact list
let contacts = client.database().contacts_public_keys(signer_pubkey).await; let contacts = client.database().contacts_public_keys(current_user).await;
let followed = contacts.unwrap_or_default().contains(&public_key); let followed = contacts.unwrap_or_default().contains(&public_key);
Ok(followed) Ok(followed)
@@ -105,16 +106,17 @@ 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 task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move { let Some(current_user) = nostr.read(cx).current_user() else {
let signer = client.signer().context("Signer not found")?; return;
let signer_pubkey = signer.get_public_key().await?; };
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
// 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 != signer_pubkey) { for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
mutual_contacts.push(event.pubkey); mutual_contacts.push(event.pubkey);
} }
} }
@@ -222,12 +224,19 @@ 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 = Tag::public_key_report(public_key, Report::Impersonation); let tag = Nip56Tag::PublicKey {
let builder = EventBuilder::report(vec![tag], ""); public_key,
let event = client.sign_event_builder(builder).await?; report: Report::Impersonation,
}
.to_tag();
let event = EventBuilder::report(vec![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?;

View File

@@ -7,7 +7,7 @@ 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::{InputState, TextInput}; use ui::input::{Input, InputState};
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,17 +56,16 @@ 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 = const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
"When opening a request, a popup will appear to help you identify the sender."; const AVATAR: &str = "Hide all avatar pictures to improve performance.";
const AVATAR: &str = const MODE: &str = "Use the selected light or dark theme, or to follow the OS.";
"Hide all avatar pictures to improve performance and protect your privacy."; const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
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 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 auth_mode = AppSettings::get_auth_mode(cx);
let theme_mode = AppSettings::get_theme_mode(cx); let theme_mode = AppSettings::get_theme_mode(cx);
@@ -207,6 +206,21 @@ 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")
@@ -218,7 +232,7 @@ impl Render for Preferences {
.child( .child(
h_flex() h_flex()
.gap_1() .gap_1()
.child(TextInput::new(&self.file_input).text_xs().small()) .child(Input::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)

View File

@@ -1,13 +1,14 @@
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::{CoopImageCache, 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, StatefulInteractiveElement, Styled, Subscription, Window, div, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, div,
image_cache, px, relative, image_cache, px, relative,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -16,38 +17,33 @@ use serde::Deserialize;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent}; use state::{IMAGE_CACHE_SIZE, 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, WindowExtension, h_flex, v_flex}; use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_flex};
use crate::dialogs::import::ImportIdentity;
use crate::dialogs::restore::RestoreEncryption; use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::{accounts, settings}; use crate::dialogs::settings;
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list, trash}; use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list, trash};
use crate::sidebar;
const PREPARE_MSG: &str = "Coop is preparing a new identity for you. This may take a moment..."; mod dialogs;
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \ mod panels;
Your identity is completely decoupled from all encryption processes to protect your privacy."; mod sidebar;
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 SignerNotifcation; struct MsgRelayNotification;
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,
@@ -64,17 +60,17 @@ enum Command {
} }
pub struct Workspace { pub struct Workspace {
/// 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 /// App's Image Cache
image_cache: Entity<CoopImageCache>, image_cache: Entity<CoopImageCache>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 5]>, _subscriptions: SmallVec<[Subscription; 6]>,
} }
impl Workspace { impl Workspace {
@@ -83,10 +79,14 @@ impl Workspace {
let device = DeviceRegistry::global(cx); let device = DeviceRegistry::global(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let titlebar = cx.new(|_| TitleBar::new()); let dock = cx.new(|cx| {
let dock = cx.new(|cx| DockArea::new(window, cx)); let mut this = DockArea::new(window, cx);
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx); let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
this
});
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push( subscriptions.push(
@@ -97,42 +97,14 @@ impl Workspace {
); );
subscriptions.push( subscriptions.push(
// Subscribe to the signer events // Subscribe to the nostr 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::Creating => { StateEvent::SignerChanged => {
let note = Notification::new() window.close_all_modals(cx);
.id::<SignerNotifcation>()
.title("Preparing a new identity")
.message(PREPARE_MSG)
.autohide(false)
.with_kind(NotificationKind::Info);
window.push_notification(note, cx);
} }
StateEvent::Connecting => { StateEvent::NoSigner => {
let note = Notification::new() this.import_identity(window, cx);
.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);
} }
_ => {} _ => {}
}; };
@@ -145,7 +117,7 @@ impl Workspace {
match event { match event {
DeviceEvent::Requesting => { DeviceEvent::Requesting => {
const MSG: &str = const MSG: &str =
"Coop has sent a request for an encryption key. Please open the other client then approve the request."; "Please open other client and approve the request for encryption key.";
let note = Notification::new() let note = Notification::new()
.id::<DeviceNotifcation>() .id::<DeviceNotifcation>()
@@ -156,12 +128,25 @@ impl Workspace {
window.push_notification(note, cx); window.push_notification(note, cx);
} }
DeviceEvent::Creating => { DeviceEvent::NotSet => {
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>()
.autohide(false) .message(MSG)
.message("Creating encryption key") .with_kind(NotificationKind::Info)
.with_kind(NotificationKind::Info); .action(|_this, _window, _cx| {
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);
} }
@@ -184,6 +169,27 @@ 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| {
@@ -227,15 +233,21 @@ impl Workspace {
}), }),
); );
// Set the layout at the end of cycle
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, |this, window, cx| {
this.set_layout(window, cx); let dock = this.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);
this.dock.update(cx, |this, cx| {
this.set_center(center, window, cx);
});
}); });
Self { Self {
titlebar,
dock, dock,
image_cache, image_cache,
tasks: vec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -267,29 +279,6 @@ impl Workspace {
.collect() .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 {
@@ -306,9 +295,8 @@ 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) = signer.public_key() { if let Some(public_key) = nostr.read(cx).current_user() {
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)),
@@ -353,7 +341,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.refresh(window, cx); this.reload(cx);
}); });
} }
Command::ShowRelayList => { Command::ShowRelayList => {
@@ -378,14 +366,11 @@ 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"));
cx.spawn_in(window, async move |_this, cx| { self.tasks.push(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,
@@ -412,9 +397,8 @@ impl Workspace {
cx.open_with_system(output_path.as_path()); cx.open_with_system(output_path.as_path());
})?; })?;
Ok::<_, anyhow::Error>(()) Ok(())
}) }));
.detach();
} }
Command::ImportEncryption => { Command::ImportEncryption => {
self.import_encryption(window, cx); self.import_encryption(window, cx);
@@ -423,6 +407,12 @@ 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();
@@ -457,24 +447,22 @@ 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(520.)) this.width(px(420.))
.title("Restore Encryption") .title("Restore Encryption")
.child(restore.clone()) .child(restore.clone())
}); });
} }
fn account_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn import_identity(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let accounts = accounts::init(window, cx); let import = cx.new(|cx| ImportIdentity::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| { window.open_modal(cx, move |this, _window, _cx| {
this.width(px(520.)) this.width(px(420.))
.title("Continue with")
.show_close(false) .show_close(false)
.keyboard(false)
.overlay_closable(false) .overlay_closable(false)
.child(accounts.clone()) .title("Import Identity")
.child(import.clone())
}); });
} }
@@ -560,8 +548,7 @@ 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 signer = nostr.read(cx).signer(); let current_user = nostr.read(cx).current_user();
let current_user = signer.public_key();
h_flex() h_flex()
.flex_shrink_0() .flex_shrink_0()
@@ -571,7 +558,7 @@ impl Workspace {
div() div()
.text_xs() .text_xs()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child(SharedString::from("Choose an account to continue...")), .child(SharedString::from("Import your identity to continue")),
) )
}) })
.when_some(current_user.as_ref(), |this, public_key| { .when_some(current_user.as_ref(), |this, public_key| {
@@ -622,11 +609,6 @@ 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,
@@ -639,16 +621,12 @@ 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 initializing = chat.read(cx).initializing;
let trash_messages = chat.read(cx).count_trash_messages(cx); let trash_messages = chat.read(cx).count_trash_messages(cx);
let device = DeviceRegistry::global(cx); let is_nip4e_enabled = AppSettings::get_nip4e(cx);
let device_initializing = device.read(cx).initializing;
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let Some(public_key) = signer.public_key() else { let Some(public_key) = nostr.read(cx).current_user() else {
return div(); return div();
}; };
@@ -691,83 +669,75 @@ impl Workspace {
}), }),
) )
}) })
.child( .when(is_nip4e_enabled, |this| {
Button::new("key") this.child(
.icon(IconName::UserKey) Button::new("key")
.tooltip("Decoupled encryption key") .icon(IconName::UserKey)
.small() .tooltip("Decoupled encryption key")
.ghost() .small()
.loading(device_initializing) .ghost()
.when(device_initializing, |this| { .dropdown_menu(move |this, _window, _cx| {
this.label("Dekey") this.min_w(px(260.))
.xsmall() .label("Encryption Key")
.tooltip("Loading decoupled encryption key...") .when_some(announcement.as_ref(), |this, announcement| {
}) let name = announcement.client_name();
.dropdown_menu(move |this, _window, _cx| { let pkey = shorten_pubkey(announcement.public_key(), 8);
this.min_w(px(260.))
.label("Encryption Key")
.when_some(announcement.as_ref(), |this, announcement| {
let name = announcement.client_name();
let pkey = shorten_pubkey(announcement.public_key(), 8);
this.item(PopupMenuItem::element(move |_window, cx| { this.item(PopupMenuItem::element(move |_window, cx| {
h_flex() h_flex()
.gap_1() .gap_1()
.text_sm() .text_sm()
.child( .child(
Icon::new(IconName::Device) Icon::new(IconName::Device)
.small() .small()
.text_color(cx.theme().icon_muted), .text_color(cx.theme().icon_muted),
) )
.child(name.clone()) .child(name.clone())
})) }))
.item(PopupMenuItem::element(move |_window, cx| { .item(
h_flex() PopupMenuItem::element(move |_window, cx| {
.gap_1() h_flex()
.text_sm() .gap_1()
.child( .text_sm()
Icon::new(IconName::UserKey) .child(
.small() Icon::new(IconName::UserKey)
.text_color(cx.theme().icon_muted), .small()
) .text_color(cx.theme().icon_muted),
.child(SharedString::from(pkey.clone())) )
})) .child(SharedString::from(pkey.clone()))
}) }),
.separator() )
.menu_with_icon( })
"Backup", .separator()
IconName::Shield, .menu_with_icon(
Box::new(Command::BackupEncryption), "Backup",
) IconName::Shield,
.menu_with_icon( Box::new(Command::BackupEncryption),
"Restore from secret key", )
IconName::Usb, .menu_with_icon(
Box::new(Command::ImportEncryption), "Restore from secret key",
) IconName::Usb,
.separator() Box::new(Command::ImportEncryption),
.menu_with_icon( )
"Reload", .separator()
IconName::Refresh, .menu_with_icon(
Box::new(Command::RefreshEncryption), "Reload",
) IconName::Refresh,
.menu_with_icon( Box::new(Command::RefreshEncryption),
"Reset", )
IconName::Warning, .menu_with_icon(
Box::new(Command::ResetEncryption), "Reset",
) IconName::Warning,
}), Box::new(Command::ResetEncryption),
) )
}),
)
})
.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()
@@ -838,17 +808,8 @@ 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(SharedString::from("workspace")) .id("workspace")
.on_action(cx.listener(Self::on_command)) .on_action(cx.listener(Self::on_command))
.relative() .relative()
.size_full() .size_full()
@@ -860,7 +821,11 @@ impl Render for Workspace {
v_flex() v_flex()
.size_full() .size_full()
// Title Bar // Title Bar
.child(self.titlebar.clone()) .child(
TitleBar::new()
.child(self.titlebar_left(cx))
.child(self.titlebar_right(cx)),
)
// Dock // Dock
.child(self.dock.clone()), .child(self.dock.clone()),
), ),

View File

@@ -1,4 +1,4 @@
use std::time::Duration; use instant::Duration;
use anyhow::Error; use anyhow::Error;
use gpui::{ use gpui::{
@@ -6,11 +6,11 @@ use gpui::{
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div, Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::KEYRING; use state::USER_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::{InputState, TextInput}; use ui::input::{Input, InputState};
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. \
@@ -40,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).disabled(true)); let npub_input = cx.new(|cx| InputState::new(window, cx));
let nsec_input = cx.new(|cx| InputState::new(window, cx).disabled(true).masked(true)); let nsec_input = cx.new(|cx| InputState::new(window, cx).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| {
@@ -59,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(KEYRING); let keyring = cx.read_credentials(USER_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? {
@@ -156,7 +156,7 @@ impl Render for BackupPanel {
.child(SharedString::from("Public Key:")), .child(SharedString::from("Public Key:")),
) )
.child( .child(
TextInput::new(&self.npub_input) Input::new(&self.npub_input)
.small() .small()
.bordered(false) .bordered(false)
.disabled(true), .disabled(true),
@@ -174,7 +174,7 @@ impl Render for BackupPanel {
.child(SharedString::from("Secret Key:")), .child(SharedString::from("Secret Key:")),
) )
.child( .child(
TextInput::new(&self.nsec_input) Input::new(&self.nsec_input)
.small() .small()
.bordered(false) .bordered(false)
.disabled(true), .disabled(true),

View File

@@ -1,7 +1,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Duration; use instant::Duration;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::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,
@@ -16,7 +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::{InputEvent, InputState, TextInput}; use ui::input::{Input, InputEvent, InputState};
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> {
@@ -82,11 +82,12 @@ 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 task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move { let Some(public_key) = nostr.read(cx).current_user() else {
let signer = client.signer().context("Signer not found")?; return;
let public_key = signer.get_public_key().await?; };
let contact_list = client.database().contacts_public_keys(public_key).await?;
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let contact_list = client.database().contacts_public_keys(public_key).await?;
Ok(contact_list) Ok(contact_list)
}); });
@@ -156,6 +157,7 @@ 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
@@ -169,8 +171,9 @@ 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 builder = EventBuilder::contact_list(contacts); let event = ContactListBuilder::new(contacts)
let event = client.sign_event_builder(builder).await?; .finalize_async(&signer)
.await?;
// Set contact list // Set contact list
client.send_event(&event).to_nip65().await?; client.send_event(&event).to_nip65().await?;
@@ -301,10 +304,10 @@ impl Render for ContactListPanel {
.gap_1() .gap_1()
.w_full() .w_full()
.child( .child(
TextInput::new(&self.input) Input::new(&self.input)
.small() .small()
.bordered(false) .bordered(false)
.cleanable(), .cleanable(true),
) )
.child( .child(
Button::new("add") Button::new("add")

View File

@@ -1,6 +1,7 @@
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, Window, div, svg, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div, svg,
}; };
use state::NostrRegistry; use state::NostrRegistry;
use theme::ActiveTheme; use theme::ActiveTheme;
@@ -8,8 +9,8 @@ use ui::button::{Button, ButtonVariants};
use ui::dock::{DockPlacement, Panel, PanelEvent}; 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::Workspace;
use crate::panels::profile; use crate::panels::profile;
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))
@@ -18,6 +19,7 @@ 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 {
@@ -25,15 +27,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) = signer.public_key() { if let Some(public_key) = nostr.read(cx).current_user() {
cx.spawn_in(window, async move |_this, cx| { self.tasks.push(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),
@@ -43,8 +45,9 @@ impl GreeterPanel {
); );
}) })
.ok(); .ok();
})
.detach(); Ok(())
}));
} }
} }
} }

View File

@@ -1,7 +1,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Duration; use instant::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow}; use anyhow::{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,
@@ -14,7 +14,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::{InputEvent, InputState, TextInput}; use ui::input::{Input, InputEvent, InputState};
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,17 +83,18 @@ 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 task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move { let Some(public_key) = nostr.read(cx).current_user() else {
let signer = client.signer().context("Signer not found")?; return;
let public_key = signer.get_public_key().await?; };
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
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_owned_relay_list(event).collect()) Ok(nip17::extract_relay_list(&event).collect())
} else { } else {
Err(anyhow!("Not found.")) Err(anyhow!("Not found."))
} }
@@ -170,12 +171,13 @@ 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| Tag::relay(relay.clone())) .map(|relay| Nip17Tag::Relay(relay.to_owned()).to_tag())
.collect(); .collect();
// Set updating state // Set updating state
@@ -183,8 +185,10 @@ 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 builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags); let event = EventBuilder::new(Kind::InboxRelays, "")
let event = client.sign_event_builder(builder).await?; .tags(tags)
.finalize_async(&signer)
.await?;
// Set messaging relays // Set messaging relays
client.send_event(&event).to_nip65().await?; client.send_event(&event).to_nip65().await?;
@@ -317,10 +321,10 @@ impl Render for MessagingRelayPanel {
.gap_1() .gap_1()
.w_full() .w_full()
.child( .child(
TextInput::new(&self.input) Input::new(&self.input)
.small() .small()
.bordered(false) .bordered(false)
.cleanable(), .cleanable(true),
) )
.child( .child(
Button::new("add") Button::new("add")

View File

@@ -1,5 +1,4 @@
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::{
@@ -7,6 +6,7 @@ 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::{InputState, TextInput}; use ui::input::{Input, InputState};
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() .multi_line(true)
.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 {
cx.spawn_in(window, async move |this, cx| { self.tasks.push(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,8 +143,9 @@ impl ProfilePanel {
.ok(); .ok();
}) })
.ok(); .ok();
})
.detach(); Ok(())
}));
} }
} }
@@ -207,12 +208,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 builder = EventBuilder::metadata(&metadata); let event = metadata.finalize_async(&signer).await?;
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?;
@@ -352,7 +353,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(TextInput::new(&self.name_input).bordered(false).small()), .child(Input::new(&self.name_input).bordered(false).small()),
) )
.child( .child(
v_flex() v_flex()
@@ -363,7 +364,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(TextInput::new(&self.bio_input).bordered(false).small()), .child(Input::new(&self.bio_input).bordered(false).small()),
) )
.child( .child(
v_flex() v_flex()
@@ -374,7 +375,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(TextInput::new(&self.website_input).bordered(false).small()), .child(Input::new(&self.website_input).bordered(false).small()),
) )
.child( .child(
v_flex() v_flex()

View File

@@ -1,7 +1,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Duration; use instant::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow}; use anyhow::{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,
@@ -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::{InputEvent, InputState, TextInput}; use ui::input::{Input, InputEvent, InputState};
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,18 +100,19 @@ 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_owned_relay_list(event).collect()) Ok(nip65::extract_relay_list(&event).collect())
} else { } else {
Err(anyhow!("Not found.")) Err(anyhow!("Not found."))
} }
@@ -206,6 +207,7 @@ 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();
@@ -214,8 +216,9 @@ 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 builder = EventBuilder::relay_list(relays); let event = EventBuilder::relay_list(relays)
let event = client.sign_event_builder(builder).await?; .finalize_async(&signer)
.await?;
// Set relay list for current user // Set relay list for current user
client.send_event(&event).await?; client.send_event(&event).await?;
@@ -369,10 +372,10 @@ impl Render for RelayListPanel {
.gap_1() .gap_1()
.w_full() .w_full()
.child( .child(
TextInput::new(&self.input) Input::new(&self.input)
.small() .small()
.bordered(false) .bordered(false)
.cleanable(), .cleanable(true),
) )
.child( .child(
Button::new("metadata") Button::new("metadata")

Some files were not shown because too many files have changed in this diff Show More