merged previous stuffs on master
This commit is contained in:
@@ -6,16 +6,17 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
|
||||
semver = "1.0.27"
|
||||
tempfile = "3.23.0"
|
||||
futures.workspace = true
|
||||
|
||||
@@ -4,21 +4,39 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use common::BOOTSTRAP_RELAYS;
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::fs::File;
|
||||
use smol::process::Command;
|
||||
use state::NostrRegistry;
|
||||
|
||||
const APP_PUBKEY: &str = "npub1y9jvl5vznq49eh9f2gj7679v4042kj80lp7p8fte3ql2cr7hty7qsyca8q";
|
||||
const GITHUB_API_URL: &str = "https://api.github.com";
|
||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||
|
||||
fn get_github_repo_owner() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "your-username".to_string())
|
||||
}
|
||||
|
||||
fn get_github_repo_name() -> String {
|
||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "your-repo".to_string())
|
||||
}
|
||||
|
||||
fn is_flatpak_installation() -> bool {
|
||||
// Check if app is installed via Flatpak
|
||||
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
// Skip auto-update initialization if installed via Flatpak
|
||||
if is_flatpak_installation() {
|
||||
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
||||
return;
|
||||
}
|
||||
|
||||
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
|
||||
}
|
||||
|
||||
@@ -109,7 +127,7 @@ impl Drop for MacOsUnmounter<'_> {
|
||||
pub enum AutoUpdateStatus {
|
||||
Idle,
|
||||
Checking,
|
||||
Checked { files: Vec<EventId> },
|
||||
Checked { download_url: String },
|
||||
Installing,
|
||||
Updated,
|
||||
Errored { msg: Box<String> },
|
||||
@@ -130,8 +148,8 @@ impl AutoUpdateStatus {
|
||||
matches!(self, Self::Updated)
|
||||
}
|
||||
|
||||
pub fn checked(files: Vec<EventId>) -> Self {
|
||||
Self::Checked { files }
|
||||
pub fn checked(download_url: String) -> Self {
|
||||
Self::Checked { download_url }
|
||||
}
|
||||
|
||||
pub fn error(e: String) -> Self {
|
||||
@@ -139,6 +157,18 @@ impl AutoUpdateStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GitHubRelease {
|
||||
pub tag_name: String,
|
||||
pub assets: Vec<GitHubAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GitHubAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AutoUpdater {
|
||||
/// Current status of the auto updater
|
||||
@@ -173,36 +203,32 @@ impl AutoUpdater {
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Subscribe to get the new update event in the bootstrap relays
|
||||
Self::subscribe_to_updates(cx),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Subscribe to get the new update event in the bootstrap relays
|
||||
// Check for updates after 2 minutes
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Check for updates after 2 minutes
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_secs(120))
|
||||
.await;
|
||||
|
||||
// Update the status to checking
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Checking, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
|
||||
match Self::check_for_updates(async_version, cx).await {
|
||||
Ok(ids) => {
|
||||
// Update the status to downloading
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::checked(ids), cx);
|
||||
});
|
||||
Ok(download_url) => {
|
||||
// Update the status to checked with download URL
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::checked(download_url), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
log::warn!("Failed to check for updates: {e}");
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Idle, cx);
|
||||
});
|
||||
|
||||
log::warn!("{e}");
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -211,8 +237,8 @@ impl AutoUpdater {
|
||||
subscriptions.push(
|
||||
// Observe the status
|
||||
cx.observe_self(|this, cx| {
|
||||
if let AutoUpdateStatus::Checked { files } = this.status.clone() {
|
||||
this.get_latest_release(&files, cx);
|
||||
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
|
||||
this.download_and_install(&download_url, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -230,118 +256,82 @@ impl AutoUpdater {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn subscribe_to_updates(cx: &App) -> Task<()> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
fn check_for_updates(version: Version, cx: &AsyncApp) -> Task<Result<String, Error>> {
|
||||
cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||
let client = reqwest::Client::new();
|
||||
let repo_owner = get_github_repo_owner();
|
||||
let repo_name = get_github_repo_name();
|
||||
let url = format!(
|
||||
"{}/repos/{}/{}/releases/latest",
|
||||
GITHUB_API_URL, repo_owner, repo_name
|
||||
);
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ReleaseArtifactSet)
|
||||
.author(app_pubkey)
|
||||
.limit(1);
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "Coop-Auto-Updater")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to subscribe to updates: {e}");
|
||||
};
|
||||
})
|
||||
}
|
||||
.context("Failed to fetch GitHub releases")?;
|
||||
|
||||
fn check_for_updates(version: Version, cx: &AsyncApp) -> Task<Result<Vec<EventId>, Error>> {
|
||||
let client = cx.update(|cx| {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
nostr.read(cx).client()
|
||||
});
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("GitHub API returned error: {}", response.status()));
|
||||
}
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||
let release: GitHubRelease = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse GitHub release")?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ReleaseArtifactSet)
|
||||
.author(app_pubkey)
|
||||
.limit(1);
|
||||
// Parse version from tag (remove 'v' prefix if present)
|
||||
let tag_version = release.tag_name.trim_start_matches('v');
|
||||
let new_version = Version::parse(tag_version).context(format!(
|
||||
"Failed to parse version from tag: {}",
|
||||
release.tag_name
|
||||
))?;
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let new_version: Version = event
|
||||
.tags
|
||||
.find(TagKind::d())
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| content.split("@").last())
|
||||
.and_then(|content| Version::parse(content).ok())
|
||||
.context("Failed to parse version")?;
|
||||
if new_version > version {
|
||||
// Find the appropriate asset for the current platform
|
||||
let current_os = std::env::consts::OS;
|
||||
let asset_name = match current_os {
|
||||
"macos" => "Coop.dmg",
|
||||
"linux" => "coop.tar.gz",
|
||||
"windows" => "Coop.exe",
|
||||
_ => return Err(anyhow!("Unsupported OS: {}", current_os)),
|
||||
};
|
||||
|
||||
if new_version > version {
|
||||
// Get all file metadata event ids
|
||||
let ids: Vec<EventId> = event.tags.event_ids().copied().collect();
|
||||
let download_url = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == asset_name)
|
||||
.map(|asset| asset.browser_download_url.clone())
|
||||
.context(format!(
|
||||
"No {} asset found in release {}",
|
||||
asset_name, release.tag_name
|
||||
))?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::FileMetadata)
|
||||
.author(app_pubkey)
|
||||
.ids(ids.clone());
|
||||
|
||||
// Get all files for this release
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(ids)
|
||||
} else {
|
||||
Err(anyhow!("No update available"))
|
||||
}
|
||||
Ok(download_url)
|
||||
} else {
|
||||
Err(anyhow!("No update available"))
|
||||
Err(anyhow!(
|
||||
"No update available. Current: {}, Latest: {}",
|
||||
version,
|
||||
new_version
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn get_latest_release(&mut self, ids: &[EventId], cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
|
||||
let http_client = cx.http_client();
|
||||
let ids = ids.to_vec();
|
||||
let download_url = download_url.to_string();
|
||||
|
||||
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
|
||||
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||
let os = std::env::consts::OS;
|
||||
let installer_dir = InstallerDir::new().await?;
|
||||
let target_path = Self::target_path(&installer_dir).await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::FileMetadata)
|
||||
.author(app_pubkey)
|
||||
.ids(ids);
|
||||
// Download the release
|
||||
download(&download_url, &target_path, http_client).await?;
|
||||
|
||||
// Get all urls for this release
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
for event in events.into_iter() {
|
||||
// Only process events that match current platform
|
||||
if event.content != os {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse the url
|
||||
let url = event
|
||||
.tags
|
||||
.find(TagKind::Url)
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| Url::parse(content).ok())
|
||||
.context("Failed to parse url")?;
|
||||
|
||||
let installer_dir = InstallerDir::new().await?;
|
||||
let target_path = Self::target_path(&installer_dir).await?;
|
||||
|
||||
// Download the release
|
||||
download(url.as_str(), &target_path, http_client).await?;
|
||||
|
||||
return Ok((installer_dir, target_path));
|
||||
}
|
||||
|
||||
Err(anyhow!("Failed to get latest release"))
|
||||
Ok((installer_dir, target_path))
|
||||
});
|
||||
|
||||
self._tasks.push(
|
||||
@@ -374,6 +364,7 @@ impl AutoUpdater {
|
||||
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
|
||||
let filename = match std::env::consts::OS {
|
||||
"macos" => anyhow::Ok("Coop.dmg"),
|
||||
"linux" => Ok("coop.tar.gz"),
|
||||
"windows" => Ok("Coop.exe"),
|
||||
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
|
||||
}?;
|
||||
@@ -388,6 +379,7 @@ impl AutoUpdater {
|
||||
) -> Result<(), Error> {
|
||||
match std::env::consts::OS {
|
||||
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
|
||||
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
|
||||
"windows" => install_release_windows(target_path).await,
|
||||
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
|
||||
}
|
||||
@@ -460,6 +452,75 @@ async fn install_release_macos(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_release_linux(
|
||||
temp_dir: &InstallerDir,
|
||||
downloaded_tar_gz: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
let running_app_path = cx.update(|cx| cx.app_path())?;
|
||||
|
||||
// Extract the tar.gz file
|
||||
let extracted = temp_dir.path().join("coop");
|
||||
smol::fs::create_dir_all(&extracted)
|
||||
.await
|
||||
.context("failed to create directory to extract update")?;
|
||||
|
||||
let output = Command::new("tar")
|
||||
.arg("-xzf")
|
||||
.arg(&downloaded_tar_gz)
|
||||
.arg("-C")
|
||||
.arg(&extracted)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to extract {:?} to {:?}: {:?}",
|
||||
downloaded_tar_gz,
|
||||
extracted,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
// Find the extracted app directory
|
||||
let mut entries = smol::fs::read_dir(&extracted).await?;
|
||||
let mut app_dir = None;
|
||||
|
||||
use smol::stream::StreamExt;
|
||||
|
||||
while let Some(entry) = entries.next().await {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
app_dir = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let from = app_dir.context("No app directory found in archive")?;
|
||||
|
||||
// Copy to the current installation directory
|
||||
let output = Command::new("rsync")
|
||||
.args(["-av", "--delete"])
|
||||
.arg(&from)
|
||||
.arg(
|
||||
running_app_path
|
||||
.parent()
|
||||
.context("No parent directory for app")?,
|
||||
)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to copy app from {:?} to {:?}: {:?}",
|
||||
from,
|
||||
running_app_path.parent(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
|
||||
//const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
|
||||
@@ -7,16 +7,14 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use common::EventUtils;
|
||||
use device::DeviceRegistry;
|
||||
use flume::Sender;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity,
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{tracker, NostrRegistry, GIFTWRAP_SUBSCRIPTION};
|
||||
use state::{NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP};
|
||||
|
||||
mod message;
|
||||
mod room;
|
||||
@@ -24,8 +22,8 @@ mod room;
|
||||
pub use message::*;
|
||||
pub use room::*;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
@@ -45,11 +43,9 @@ pub enum ChatEvent {
|
||||
|
||||
/// Channel signal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum NostrEvent {
|
||||
enum Signal {
|
||||
/// Message received from relay pool
|
||||
Message(NewMessage),
|
||||
/// Unwrapping status
|
||||
Unwrapping(bool),
|
||||
/// Eose received from relay pool
|
||||
Eose,
|
||||
}
|
||||
@@ -57,23 +53,17 @@ enum NostrEvent {
|
||||
/// Chat Registry
|
||||
#[derive(Debug)]
|
||||
pub struct ChatRegistry {
|
||||
/// Relay state for messaging relay list
|
||||
messaging_relay_list: Entity<RelayState>,
|
||||
|
||||
/// Collection of all chat rooms
|
||||
rooms: Vec<Entity<Room>>,
|
||||
|
||||
/// Loading status of the registry
|
||||
loading: bool,
|
||||
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
tracking_flag: Arc<AtomicBool>,
|
||||
|
||||
/// Channel's sender for communication between nostr and gpui
|
||||
sender: Sender<NostrEvent>,
|
||||
|
||||
/// Handle notifications asynchronous task
|
||||
notifications: Option<Task<Result<(), Error>>>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: Vec<Task<()>>,
|
||||
/// Async tasks
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
@@ -93,79 +83,52 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Create a new chat registry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let messaging_relay_list = cx.new(|_| RelayState::default());
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let identity = nostr.read(cx).identity();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let device_signer = device.read(cx).device_signer.clone();
|
||||
|
||||
// A flag to indicate if the registry is loading
|
||||
let tracking_flag = Arc::new(AtomicBool::new(true));
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<NostrEvent>(2048);
|
||||
|
||||
let mut tasks = vec![];
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the identity
|
||||
cx.observe(&identity, |this, state, cx| {
|
||||
if state.read(cx).has_public_key() {
|
||||
// Handle nostr notifications
|
||||
this.handle_notifications(cx);
|
||||
// Track unwrapping progress
|
||||
this.tracking(cx);
|
||||
// Observe the nip65 state and load chat rooms on every state change
|
||||
cx.observe(&nostr, |this, state, cx| {
|
||||
match state.read(cx).relay_list_state() {
|
||||
RelayState::Idle => {
|
||||
this.reset(cx);
|
||||
}
|
||||
RelayState::Configured => {
|
||||
this.ensure_messaging_relays(cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the device signer state
|
||||
cx.observe(&device_signer, |this, state, cx| {
|
||||
if state.read(cx).is_some() {
|
||||
this.handle_notifications(cx);
|
||||
// Observe the nip17 state and load chat rooms on every state change
|
||||
cx.observe(&messaging_relay_list, |this, state, cx| {
|
||||
match state.read(cx) {
|
||||
RelayState::Configured => {
|
||||
this.get_messages(cx);
|
||||
}
|
||||
_ => {
|
||||
this.get_rooms(cx);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Update GPUI states
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Ok(message) = rx.recv_async().await {
|
||||
match message {
|
||||
NostrEvent::Message(message) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_message(message, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
NostrEvent::Eose => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
NostrEvent::Unwrapping(status) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(status, cx);
|
||||
this.get_rooms(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.handle_notifications(cx);
|
||||
this.tracking(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
messaging_relay_list,
|
||||
rooms: vec![],
|
||||
loading: true,
|
||||
tracking_flag,
|
||||
sender: tx.clone(),
|
||||
notifications: None,
|
||||
tasks,
|
||||
tracking_flag: Arc::new(AtomicBool::new(false)),
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -174,22 +137,23 @@ impl ChatRegistry {
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let device_signer = device.read(cx).signer(cx);
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let status = self.tracking_flag.clone();
|
||||
let tx = self.sender.clone();
|
||||
|
||||
let initialized_at = Timestamp::now();
|
||||
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Signal>(1024);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let initialized_at = Timestamp::now();
|
||||
let subscription_id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
|
||||
|
||||
let device_signer = signer.get_encryption_signer().await;
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
let RelayPoolNotification::Message { message, .. } = notification else {
|
||||
while let Some(notification) = notifications.next().await {
|
||||
let ClientNotification::Message { message, .. } = notification else {
|
||||
// Skip non-message notifications
|
||||
continue;
|
||||
};
|
||||
@@ -206,99 +170,187 @@ impl ChatRegistry {
|
||||
continue;
|
||||
}
|
||||
|
||||
log::info!("Received gift wrap event: {:?}", event);
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match Self::extract_rumor(&client, &device_signer, event.as_ref()).await {
|
||||
Ok(rumor) => match rumor.created_at >= initialized_at {
|
||||
true => {
|
||||
// Check if the event is sent by coop
|
||||
let sent_by_coop = {
|
||||
let tracker = tracker().read().await;
|
||||
tracker.is_sent_by_coop(&event.id)
|
||||
};
|
||||
// No need to emit if sent by coop
|
||||
// the event is already emitted
|
||||
if !sent_by_coop {
|
||||
let new_message = NewMessage::new(event.id, rumor);
|
||||
let signal = NostrEvent::Message(new_message);
|
||||
let new_message = NewMessage::new(event.id, rumor);
|
||||
let signal = Signal::Message(new_message);
|
||||
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
tx.send_async(signal).await?;
|
||||
}
|
||||
false => {
|
||||
status.store(true, Ordering::Release);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to unwrap: {e}");
|
||||
log::warn!("Failed to unwrap the gift wrap event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id) => {
|
||||
if id.as_ref() == &subscription_id {
|
||||
tx.send_async(NostrEvent::Eose).await.ok();
|
||||
if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 {
|
||||
tx.send_async(Signal::Eose).await?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(message) = rx.recv_async().await {
|
||||
match message {
|
||||
Signal::Message(message) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_message(message, cx);
|
||||
})?;
|
||||
}
|
||||
Signal::Eose => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
})?;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
fn tracking(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let status = self.tracking_flag.clone();
|
||||
let tx = self.sender.clone();
|
||||
|
||||
self.notifications = Some(cx.background_spawn(async move {
|
||||
let loop_duration = Duration::from_secs(12);
|
||||
|
||||
let mut is_start_processing = false;
|
||||
let mut total_loops = 0;
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let loop_duration = Duration::from_secs(10);
|
||||
|
||||
loop {
|
||||
if client.has_signer().await {
|
||||
total_loops += 1;
|
||||
|
||||
if status.load(Ordering::Acquire) {
|
||||
is_start_processing = true;
|
||||
// Reset gift wrap processing flag
|
||||
_ = status.compare_exchange(
|
||||
true,
|
||||
false,
|
||||
Ordering::Release,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
tx.send_async(NostrEvent::Unwrapping(true)).await.ok();
|
||||
} else {
|
||||
// Only run further if we are already processing
|
||||
// Wait until after 2 loops to prevent exiting early while events are still being processed
|
||||
if is_start_processing && total_loops >= 2 {
|
||||
tx.send_async(NostrEvent::Unwrapping(false)).await.ok();
|
||||
|
||||
// Reset the counter
|
||||
is_start_processing = false;
|
||||
total_loops = 0;
|
||||
}
|
||||
}
|
||||
if status.load(Ordering::Acquire) {
|
||||
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
|
||||
}
|
||||
smol::Timer::after(loop_duration).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the loading status of the chat registry
|
||||
pub fn loading(&self) -> bool {
|
||||
self.loading
|
||||
fn ensure_messaging_relays(&mut self, cx: &mut Context<Self>) {
|
||||
let state = self.messaging_relay_list.downgrade();
|
||||
let task = self.verify_relays(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, cx| {
|
||||
let result = task.await?;
|
||||
|
||||
// Update state
|
||||
state.update(cx, |this, cx| {
|
||||
*this = result;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set the loading status of the chat registry
|
||||
pub fn set_loading(&mut self, loading: bool, cx: &mut Context<Self>) {
|
||||
self.loading = loading;
|
||||
cx.notify();
|
||||
// Verify messaging relay list for current user
|
||||
fn verify_relays(&mut self, cx: &mut Context<Self>) -> Task<Result<RelayState, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
|
||||
// Construct filter for inbox relays
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&RelayUrl, Filter> =
|
||||
urls.iter().map(|relay| (relay, filter.clone())).collect();
|
||||
|
||||
// Stream events from user's write relays
|
||||
let mut stream = client
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
log::info!("Received relay list event: {event:?}");
|
||||
return Ok(RelayState::Configured);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to receive relay list event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RelayState::NotConfigured)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all messages for current user
|
||||
fn get_messages(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.subscribe_to_giftwrap_events(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
||||
task.await?;
|
||||
|
||||
// Update state
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Continuously get gift wrap events for the current user in their messaging relays
|
||||
fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let messaging_relays = nostr.read(cx).messaging_relays(&public_key, cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = messaging_relays.await;
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(USER_GIFTWRAP);
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&RelayUrl, Filter> =
|
||||
urls.iter().map(|relay| (relay, filter.clone())).collect();
|
||||
|
||||
let output = client.subscribe(target).with_id(id).await?;
|
||||
|
||||
log::info!(
|
||||
"Successfully subscribed to gift-wrap messages on: {:?}",
|
||||
output.success
|
||||
);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the relay state
|
||||
pub fn relay_state(&self, cx: &App) -> RelayState {
|
||||
self.messaging_relay_list.read(cx).clone()
|
||||
}
|
||||
|
||||
/// Get the loading status of the chat registry
|
||||
pub fn loading(&self) -> bool {
|
||||
self.tracking_flag.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Get a weak reference to a room by its ID.
|
||||
@@ -309,47 +361,60 @@ impl ChatRegistry {
|
||||
.map(|this| this.downgrade())
|
||||
}
|
||||
|
||||
/// Get all ongoing rooms.
|
||||
pub fn ongoing_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
|
||||
/// Get all rooms based on the filter.
|
||||
pub fn rooms(&self, filter: &RoomKind, cx: &App) -> Vec<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).kind == RoomKind::Ongoing)
|
||||
.filter(|room| &room.read(cx).kind == filter)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all request rooms.
|
||||
pub fn request_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
|
||||
/// Count the number of rooms based on the filter.
|
||||
pub fn count(&self, filter: &RoomKind, cx: &App) -> usize {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).kind != RoomKind::Ongoing)
|
||||
.cloned()
|
||||
.collect()
|
||||
.filter(|room| &room.read(cx).kind == filter)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Add a new room to the start of list.
|
||||
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: Into<Room>,
|
||||
I: Into<Room> + 'static,
|
||||
{
|
||||
self.rooms.insert(0, cx.new(|_| room.into()));
|
||||
cx.notify();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let signer = client.signer()?;
|
||||
let public_key = signer.get_public_key().await.ok()?;
|
||||
let room: Room = room.into().organize(&public_key);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.rooms.insert(0, cx.new(|_| room));
|
||||
cx.emit(ChatEvent::Ping);
|
||||
cx.notify();
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Emit an open room event.
|
||||
///
|
||||
/// If the room is new, add it to the registry.
|
||||
pub fn emit_room(&mut self, room: WeakEntity<Room>, cx: &mut Context<Self>) {
|
||||
if let Some(room) = room.upgrade() {
|
||||
let id = room.read(cx).id;
|
||||
pub fn emit_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
|
||||
// Get the room's ID.
|
||||
let id = room.read(cx).id;
|
||||
|
||||
// If the room is new, add it to the registry.
|
||||
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||
self.rooms.insert(0, room);
|
||||
}
|
||||
|
||||
// Emit the open room event.
|
||||
cx.emit(ChatEvent::OpenRoom(id));
|
||||
// If the room is new, add it to the registry.
|
||||
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||
self.rooms.insert(0, room.to_owned());
|
||||
}
|
||||
|
||||
// Emit the open room event.
|
||||
cx.emit(ChatEvent::OpenRoom(id));
|
||||
}
|
||||
|
||||
/// Close a room.
|
||||
@@ -365,28 +430,27 @@ impl ChatRegistry {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Search rooms by their name.
|
||||
pub fn search(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
||||
/// Finding rooms based on a query.
|
||||
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
||||
let matcher = SkimMatcherV2::default();
|
||||
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| {
|
||||
matcher
|
||||
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
|
||||
.is_some()
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Search rooms by public keys.
|
||||
pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).members.contains(&public_key))
|
||||
.cloned()
|
||||
.collect()
|
||||
if let Ok(public_key) = PublicKey::parse(query) {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).members.contains(&public_key))
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| {
|
||||
matcher
|
||||
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
|
||||
.is_some()
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the registry.
|
||||
@@ -427,23 +491,16 @@ impl ChatRegistry {
|
||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.get_rooms_from_database(cx);
|
||||
|
||||
self.tasks.push(
|
||||
// Run and finished in the background
|
||||
cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(rooms) => {
|
||||
this.update(cx, move |this, cx| {
|
||||
this.extend_rooms(rooms, cx);
|
||||
this.sort(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load rooms: {e}")
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
cx.spawn(async move |this, cx| {
|
||||
let rooms = task.await.ok()?;
|
||||
|
||||
this.update(cx, move |this, cx| {
|
||||
this.extend_rooms(rooms, cx);
|
||||
this.sort(cx);
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Create a task to load rooms from the database
|
||||
@@ -452,10 +509,13 @@ impl ChatRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Get contacts
|
||||
let contacts = client.database().contacts_public_keys(public_key).await?;
|
||||
|
||||
// Construct authored filter
|
||||
let authored_filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
|
||||
@@ -463,6 +523,7 @@ impl ChatRegistry {
|
||||
// Get all authored events
|
||||
let authored = client.database().query(authored_filter).await?;
|
||||
|
||||
// Construct addressed filter
|
||||
let addressed_filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
|
||||
@@ -473,6 +534,7 @@ impl ChatRegistry {
|
||||
// Merge authored and addressed events
|
||||
let events = authored.merge(addressed);
|
||||
|
||||
// Collect results
|
||||
let mut rooms: HashSet<Room> = HashSet::new();
|
||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||
|
||||
@@ -488,24 +550,21 @@ impl ChatRegistry {
|
||||
for (_id, mut messages) in grouped.into_iter() {
|
||||
messages.sort_by_key(|m| Reverse(m.created_at));
|
||||
|
||||
// Always use the latest message
|
||||
let Some(latest) = messages.first() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut room = Room::from(latest);
|
||||
|
||||
if rooms.iter().any(|r| r.id == room.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut public_keys = room.members();
|
||||
public_keys.retain(|pk| pk != &public_key);
|
||||
// Construct the room from the latest message.
|
||||
//
|
||||
// Call `.organize` to ensure the current user is at the end of the list.
|
||||
let mut room = Room::from(latest).organize(&public_key);
|
||||
|
||||
// Check if the user has responded to the room
|
||||
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
||||
|
||||
// Check if public keys are from the user's contacts
|
||||
let is_contact = public_keys.iter().any(|k| contacts.contains(k));
|
||||
let is_contact = room.members.iter().any(|k| contacts.contains(k));
|
||||
|
||||
// Set the room's kind based on status
|
||||
if user_sent || is_contact {
|
||||
@@ -519,6 +578,24 @@ impl ChatRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a nostr event into a message and push it to the belonging room
|
||||
///
|
||||
/// If the room doesn't exist, it will be created.
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
|
||||
Some(room) => {
|
||||
room.update(cx, |this, cx| {
|
||||
this.push_message(message, cx);
|
||||
});
|
||||
}
|
||||
None => {
|
||||
// Push the new room to the front of the list
|
||||
self.add_room(message.rumor, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a refresh of the opened chat rooms by their IDs
|
||||
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
|
||||
if let Some(ids) = ids {
|
||||
@@ -532,54 +609,7 @@ impl ChatRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Nostr event into a Coop Message and push it to the belonging room
|
||||
///
|
||||
/// If the room doesn't exist, it will be created.
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
// Get the unique id
|
||||
let id = message.rumor.uniq_id();
|
||||
// Get the author
|
||||
let author = message.rumor.pubkey;
|
||||
|
||||
match self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
Some(room) => {
|
||||
let new_message = message.rumor.created_at > room.read(cx).created_at;
|
||||
let created_at = message.rumor.created_at;
|
||||
|
||||
// Update room
|
||||
room.update(cx, |this, cx| {
|
||||
// Update the last timestamp if the new message is newer
|
||||
if new_message {
|
||||
this.set_created_at(created_at, cx);
|
||||
}
|
||||
|
||||
// Set this room is ongoing if the new message is from current user
|
||||
if author == nostr.read(cx).identity().read(cx).public_key() {
|
||||
this.set_ongoing(cx);
|
||||
}
|
||||
|
||||
// Emit the new message to the room
|
||||
this.emit_message(message, cx);
|
||||
});
|
||||
|
||||
// Resort all rooms in the registry by their created at (after updated)
|
||||
if new_message {
|
||||
self.sort(cx);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Push the new room to the front of the list
|
||||
self.add_room(&message.rumor, cx);
|
||||
|
||||
// Notify the UI about the new room
|
||||
cx.emit(ChatEvent::Ping);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unwraps a gift-wrapped event and processes its contents.
|
||||
/// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn extract_rumor(
|
||||
client: &Client,
|
||||
device_signer: &Option<Arc<dyn NostrSigner>>,
|
||||
@@ -603,35 +633,50 @@ impl ChatRegistry {
|
||||
Ok(rumor_unsigned)
|
||||
}
|
||||
|
||||
// Helper method to try unwrapping with different signers
|
||||
/// Helper method to try unwrapping with different signers
|
||||
async fn try_unwrap(
|
||||
client: &Client,
|
||||
device_signer: &Option<Arc<dyn NostrSigner>>,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
if let Some(signer) = device_signer.as_ref() {
|
||||
let seal = signer
|
||||
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
|
||||
.await?;
|
||||
// Try with the device signer first
|
||||
if let Some(signer) = device_signer {
|
||||
if let Ok(unwrapped) = Self::try_unwrap_with(gift_wrap, signer).await {
|
||||
return Ok(unwrapped);
|
||||
};
|
||||
};
|
||||
|
||||
let seal: Event = Event::from_json(seal)?;
|
||||
seal.verify_with_ctx(&SECP256K1)?;
|
||||
|
||||
let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?;
|
||||
let rumor = UnsignedEvent::from_json(rumor)?;
|
||||
|
||||
return Ok(UnwrappedGift {
|
||||
sender: seal.pubkey,
|
||||
rumor,
|
||||
});
|
||||
}
|
||||
|
||||
let signer = client.signer().await?;
|
||||
let unwrapped = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?;
|
||||
// Try with the user's signer
|
||||
let user_signer = client.signer().context("Signer not found")?;
|
||||
let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?;
|
||||
|
||||
Ok(unwrapped)
|
||||
}
|
||||
|
||||
/// Attempts to unwrap a gift wrap event with a given signer.
|
||||
async fn try_unwrap_with(
|
||||
gift_wrap: &Event,
|
||||
signer: &Arc<dyn NostrSigner>,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
// Get the sealed event
|
||||
let seal = signer
|
||||
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
|
||||
.await?;
|
||||
|
||||
// Verify the sealed event
|
||||
let seal: Event = Event::from_json(seal)?;
|
||||
seal.verify_with_ctx(&SECP256K1)?;
|
||||
|
||||
// Get the rumor event
|
||||
let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?;
|
||||
let rumor = UnsignedEvent::from_json(rumor)?;
|
||||
|
||||
Ok(UnwrappedGift {
|
||||
sender: seal.pubkey,
|
||||
rumor,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stores an unwrapped event in local database with reference to original
|
||||
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
||||
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
use std::hash::Hash;
|
||||
|
||||
use common::EventUtils;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// New message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NewMessage {
|
||||
pub room: u64,
|
||||
pub gift_wrap: EventId,
|
||||
pub rumor: UnsignedEvent,
|
||||
}
|
||||
|
||||
impl NewMessage {
|
||||
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||
Self { gift_wrap, rumor }
|
||||
let room = rumor.uniq_id();
|
||||
|
||||
Self {
|
||||
room,
|
||||
gift_wrap,
|
||||
rumor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +1,66 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use common::EventUtils;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use state::{tracker, NostrRegistry};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
use state::{NostrRegistry, TIMEOUT};
|
||||
|
||||
use crate::NewMessage;
|
||||
|
||||
const SEND_RETRY: usize = 10;
|
||||
use crate::{ChatRegistry, NewMessage};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SendReport {
|
||||
pub receiver: PublicKey,
|
||||
pub status: Option<Output<EventId>>,
|
||||
pub gift_wrap_id: Option<EventId>,
|
||||
pub error: Option<SharedString>,
|
||||
pub on_hold: Option<Event>,
|
||||
pub encryption: bool,
|
||||
pub relays_not_found: bool,
|
||||
pub device_not_found: bool,
|
||||
pub output: Option<Output<EventId>>,
|
||||
}
|
||||
|
||||
impl SendReport {
|
||||
pub fn new(receiver: PublicKey) -> Self {
|
||||
Self {
|
||||
receiver,
|
||||
status: None,
|
||||
gift_wrap_id: None,
|
||||
error: None,
|
||||
on_hold: None,
|
||||
encryption: false,
|
||||
relays_not_found: false,
|
||||
device_not_found: false,
|
||||
output: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(mut self, output: Output<EventId>) -> Self {
|
||||
self.status = Some(output);
|
||||
/// Set the gift wrap ID.
|
||||
pub fn gift_wrap_id(mut self, gift_wrap_id: EventId) -> Self {
|
||||
self.gift_wrap_id = Some(gift_wrap_id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
|
||||
/// Set the output.
|
||||
pub fn output(mut self, output: Output<EventId>) -> Self {
|
||||
self.output = Some(output);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the error message.
|
||||
pub fn error<T>(mut self, error: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_hold(mut self, event: Event) -> Self {
|
||||
self.on_hold = Some(event);
|
||||
self
|
||||
/// Returns true if the send is pending.
|
||||
pub fn pending(&self) -> bool {
|
||||
self.output.is_none() && self.error.is_none()
|
||||
}
|
||||
|
||||
pub fn encryption(mut self) -> Self {
|
||||
self.encryption = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn relays_not_found(mut self) -> Self {
|
||||
self.relays_not_found = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn device_not_found(mut self) -> Self {
|
||||
self.device_not_found = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_relay_error(&self) -> bool {
|
||||
self.error.is_some() || self.relays_not_found
|
||||
}
|
||||
|
||||
pub fn is_sent_success(&self) -> bool {
|
||||
if let Some(output) = self.status.as_ref() {
|
||||
!output.success.is_empty()
|
||||
/// Returns true if the send was successful.
|
||||
pub fn success(&self) -> bool {
|
||||
if let Some(output) = self.output.as_ref() {
|
||||
!output.failed.is_empty()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -99,18 +84,25 @@ pub enum RoomKind {
|
||||
Ongoing,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Room {
|
||||
/// Conversation ID
|
||||
pub id: u64,
|
||||
|
||||
/// The timestamp of the last message in the room
|
||||
pub created_at: Timestamp,
|
||||
|
||||
/// Subject of the room
|
||||
pub subject: Option<SharedString>,
|
||||
|
||||
/// All members of the room
|
||||
pub members: Vec<PublicKey>,
|
||||
pub(super) members: Vec<PublicKey>,
|
||||
|
||||
/// Kind
|
||||
pub kind: RoomKind,
|
||||
|
||||
/// Configuration
|
||||
config: RoomConfig,
|
||||
}
|
||||
|
||||
impl Ord for Room {
|
||||
@@ -145,11 +137,7 @@ impl From<&UnsignedEvent> for Room {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
let id = val.uniq_id();
|
||||
let created_at = val.created_at;
|
||||
|
||||
// Get the members from the event's tags and event's pubkey
|
||||
let members = val.extract_public_keys();
|
||||
|
||||
// Get subject from tags
|
||||
let subject = val
|
||||
.tags
|
||||
.find(TagKind::Subject)
|
||||
@@ -161,38 +149,50 @@ impl From<&UnsignedEvent> for Room {
|
||||
subject,
|
||||
members,
|
||||
kind: RoomKind::default(),
|
||||
config: RoomConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UnsignedEvent> for Room {
|
||||
fn from(val: UnsignedEvent) -> Self {
|
||||
Room::from(&val)
|
||||
}
|
||||
}
|
||||
|
||||
impl Room {
|
||||
/// Constructs a new room with the given receiver and tags.
|
||||
pub fn new(subject: Option<String>, author: PublicKey, receivers: Vec<PublicKey>) -> Self {
|
||||
// Convert receiver's public keys into tags
|
||||
let mut tags: Tags = Tags::from_list(
|
||||
receivers
|
||||
.iter()
|
||||
.map(|pubkey| Tag::public_key(pubkey.to_owned()))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
// Add subject if it is present
|
||||
if let Some(subject) = subject {
|
||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
||||
subject,
|
||||
)));
|
||||
}
|
||||
pub fn new<T>(author: PublicKey, receivers: T) -> Self
|
||||
where
|
||||
T: IntoIterator<Item = PublicKey>,
|
||||
{
|
||||
// Map receiver public keys to tags
|
||||
let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect());
|
||||
|
||||
// Construct an unsigned event for a direct message
|
||||
//
|
||||
// WARNING: never sign this event
|
||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
||||
.tags(tags)
|
||||
.build(author);
|
||||
|
||||
// Generate event ID
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Room::from(&event)
|
||||
}
|
||||
|
||||
/// Organizes the members of the room by moving the target member to the end.
|
||||
///
|
||||
/// Always call this function to ensure the current user is at the end of the list.
|
||||
pub fn organize(mut self, target: &PublicKey) -> Self {
|
||||
if let Some(index) = self.members.iter().position(|member| member == target) {
|
||||
let member = self.members.remove(index);
|
||||
self.members.push(member);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the kind of the room and returns the modified room
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = kind;
|
||||
@@ -227,28 +227,6 @@ impl Room {
|
||||
self.members.clone()
|
||||
}
|
||||
|
||||
/// Returns the members of the room with their messaging relays
|
||||
pub fn members_with_relays(&self, cx: &App) -> Task<Vec<(PublicKey, Vec<RelayUrl>)>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let mut tasks = vec![];
|
||||
|
||||
for member in self.members.iter() {
|
||||
let task = nostr.read(cx).messaging_relays(member, cx);
|
||||
tasks.push((*member, task));
|
||||
}
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut results = vec![];
|
||||
|
||||
for (public_key, task) in tasks.into_iter() {
|
||||
let urls = task.await;
|
||||
results.push((public_key, urls));
|
||||
}
|
||||
|
||||
results
|
||||
})
|
||||
}
|
||||
|
||||
/// Checks if the room has more than two members (group)
|
||||
pub fn is_group(&self) -> bool {
|
||||
self.members.len() > 2
|
||||
@@ -277,17 +255,7 @@ impl Room {
|
||||
/// Display member is always different from the current user.
|
||||
pub fn display_member(&self, cx: &App) -> Person {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
|
||||
let target_member = self
|
||||
.members
|
||||
.iter()
|
||||
.find(|&member| member != &public_key)
|
||||
.or_else(|| self.members.first())
|
||||
.expect("Room should have at least one member");
|
||||
|
||||
persons.read(cx).get(target_member, cx)
|
||||
persons.read(cx).get(&self.members[0], cx)
|
||||
}
|
||||
|
||||
/// Merge the names of the first two members of the room.
|
||||
@@ -308,7 +276,7 @@ impl Room {
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
if profiles.len() > 2 {
|
||||
if profiles.len() > 3 {
|
||||
name = format!("{}, +{}", name, profiles.len() - 2);
|
||||
}
|
||||
|
||||
@@ -318,9 +286,21 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a new message signal to the current room
|
||||
pub fn emit_message(&self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
/// Push a new message to the current room
|
||||
pub fn push_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let created_at = message.rumor.created_at;
|
||||
let new_message = created_at > self.created_at;
|
||||
|
||||
// Emit the incoming message event
|
||||
cx.emit(RoomEvent::Incoming(message));
|
||||
|
||||
if new_message {
|
||||
self.set_created_at(created_at, cx);
|
||||
// Sort chats after emitting a new message
|
||||
ChatRegistry::global(cx).update(cx, |this, cx| {
|
||||
this.sort(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a signal to reload the current room's messages.
|
||||
@@ -329,32 +309,43 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Get gossip relays for each member
|
||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
pub fn early_connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let members = self.members();
|
||||
let id = SubscriptionId::new(format!("room-{}", self.id));
|
||||
let subscription_id = SubscriptionId::new(format!("room-{}", self.id));
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Subscription options
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.timeout(Some(Duration::from_secs(2)))
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
for member in members.into_iter() {
|
||||
if member == public_key {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Construct a filter for gossip relays
|
||||
let filter = Filter::new().kind(Kind::RelayList).author(member).limit(1);
|
||||
// Construct a filter for messaging relays
|
||||
let inbox = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(member)
|
||||
.limit(1);
|
||||
|
||||
// Construct a filter for announcement
|
||||
let announcement = Filter::new()
|
||||
.kind(Kind::Custom(10044))
|
||||
.author(member)
|
||||
.limit(1);
|
||||
|
||||
// Subscribe to get member's gossip relays
|
||||
client
|
||||
.subscribe_with_id(id.clone(), filter, Some(opts))
|
||||
.subscribe(vec![inbox, announcement])
|
||||
.with_id(subscription_id.clone())
|
||||
.close_on(
|
||||
SubscribeAutoCloseOptions::default()
|
||||
.timeout(Some(Duration::from_secs(TIMEOUT)))
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -386,68 +377,265 @@ impl Room {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new message event (unsigned)
|
||||
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent {
|
||||
// Construct a rumor event for direct message
|
||||
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
|
||||
where
|
||||
S: Into<String>,
|
||||
I: IntoIterator<Item = EventId>,
|
||||
{
|
||||
let kind = Kind::PrivateDirectMessage;
|
||||
let content: String = content.into();
|
||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
// Get current user
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
// Get current user's public key
|
||||
let sender = nostr.read(cx).signer().public_key()?;
|
||||
|
||||
// Get room's subject
|
||||
let subject = self.subject.clone();
|
||||
// Get all members
|
||||
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
|
||||
let mut tags = vec![];
|
||||
|
||||
// Add receivers
|
||||
//
|
||||
// NOTE: current user will be removed from the list of receivers
|
||||
for member in self.members.iter() {
|
||||
// Get relay hint if available
|
||||
let relay_url = nostr.read(cx).relay_hint(member, cx);
|
||||
|
||||
// Construct a public key tag with relay hint
|
||||
let tag = TagStandard::PublicKey {
|
||||
public_key: member.to_owned(),
|
||||
relay_url,
|
||||
alias: None,
|
||||
uppercase: false,
|
||||
};
|
||||
|
||||
tags.push(Tag::from_standardized_without_cell(tag));
|
||||
}
|
||||
|
||||
// Add subject tag if it's present
|
||||
if let Some(value) = subject {
|
||||
// Add subject tag if present
|
||||
if let Some(value) = self.subject.as_ref() {
|
||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
||||
value.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
// Add reply/quote tag
|
||||
if replies.len() == 1 {
|
||||
tags.push(Tag::event(replies[0]))
|
||||
} else {
|
||||
for id in replies {
|
||||
let tag = TagStandard::Quote {
|
||||
event_id: id.to_owned(),
|
||||
relay_url: None,
|
||||
public_key: None,
|
||||
};
|
||||
tags.push(Tag::from_standardized_without_cell(tag))
|
||||
}
|
||||
// Add all reply tags
|
||||
for id in replies.into_iter() {
|
||||
tags.push(Tag::event(id))
|
||||
}
|
||||
|
||||
// Construct a direct message event
|
||||
//
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
||||
.tags(tags)
|
||||
.build(public_key);
|
||||
// Add all receiver tags
|
||||
for member in members.into_iter() {
|
||||
// Skip current user
|
||||
if member.public_key() == sender {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure the event id has been generated
|
||||
tags.push(Tag::from_standardized_without_cell(
|
||||
TagStandard::PublicKey {
|
||||
public_key: member.public_key(),
|
||||
relay_url: member.messaging_relay_hint(),
|
||||
alias: None,
|
||||
uppercase: false,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// Construct a direct message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(kind, content).tags(tags).build(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
event
|
||||
Some(event)
|
||||
}
|
||||
|
||||
/// Send rumor event to all members's messaging relays
|
||||
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
// Get room's config
|
||||
let config = self.config.clone();
|
||||
|
||||
// Get current user's public key
|
||||
let sender = nostr.read(cx).signer().public_key()?;
|
||||
|
||||
// Get all members (excluding sender)
|
||||
let members: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|public_key| public_key != &&sender)
|
||||
.map(|member| persons.read(cx).get(member, cx))
|
||||
.collect();
|
||||
|
||||
Some(cx.background_spawn(async move {
|
||||
let signer_kind = config.signer_kind();
|
||||
let user_signer = signer.get().await;
|
||||
let encryption_signer = signer.get_encryption_signer().await;
|
||||
|
||||
let mut reports = Vec::new();
|
||||
|
||||
for member in members {
|
||||
let relays = member.messaging_relays();
|
||||
let announcement = member.announcement();
|
||||
|
||||
// Skip if member has no messaging relays
|
||||
if relays.is_empty() {
|
||||
reports.push(SendReport::new(member.public_key()).error("No messaging relays"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure relay connections
|
||||
for url in relays.iter() {
|
||||
client
|
||||
.add_relay(url)
|
||||
.and_connect()
|
||||
.capabilities(RelayCapabilities::GOSSIP)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
// When forced to use encryption signer, skip if receiver has no announcement
|
||||
if signer_kind.encryption() && announcement.is_none() {
|
||||
reports
|
||||
.push(SendReport::new(member.public_key()).error("Encryption not found"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine receiver and signer based on signer kind
|
||||
let (receiver, signer_to_use) = match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if let Some(announcement) = announcement {
|
||||
if let Some(enc_signer) = encryption_signer.as_ref() {
|
||||
(announcement.public_key(), enc_signer.clone())
|
||||
} else {
|
||||
(member.public_key(), user_signer.clone())
|
||||
}
|
||||
} else {
|
||||
(member.public_key(), user_signer.clone())
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => {
|
||||
let Some(encryption_signer) = encryption_signer.as_ref() else {
|
||||
reports.push(
|
||||
SendReport::new(member.public_key()).error("Encryption not found"),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let Some(announcement) = announcement else {
|
||||
reports.push(
|
||||
SendReport::new(member.public_key())
|
||||
.error("Announcement not found"),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
(announcement.public_key(), encryption_signer.clone())
|
||||
}
|
||||
SignerKind::User => (member.public_key(), user_signer.clone()),
|
||||
};
|
||||
|
||||
// Create and send gift-wrapped event
|
||||
match EventBuilder::gift_wrap(&signer_to_use, &receiver, rumor.clone(), []).await {
|
||||
Ok(event) => {
|
||||
match client
|
||||
.send_event(&event)
|
||||
.to(relays)
|
||||
.ack_policy(AckPolicy::none())
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
reports.push(
|
||||
SendReport::new(member.public_key())
|
||||
.gift_wrap_id(event.id)
|
||||
.output(output),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
reports.push(
|
||||
SendReport::new(member.public_key()).error(e.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
reports.push(SendReport::new(member.public_key()).error(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reports
|
||||
}))
|
||||
}
|
||||
|
||||
/*
|
||||
* /// Create a new unsigned message event
|
||||
pub fn create_message(
|
||||
&self,
|
||||
content: &str,
|
||||
replies: Vec<EventId>,
|
||||
cx: &App,
|
||||
) -> Task<Result<UnsignedEvent, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let subject = self.subject.clone();
|
||||
let content = content.to_string();
|
||||
|
||||
let mut member_and_relay_hints = HashMap::new();
|
||||
|
||||
// Populate the hashmap with member and relay hint tasks
|
||||
for member in self.members.iter() {
|
||||
let hint = nostr.read(cx).relay_hint(member, cx);
|
||||
member_and_relay_hints.insert(member.to_owned(), hint);
|
||||
}
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// List of event tags for each receiver
|
||||
let mut tags = vec![];
|
||||
|
||||
for (member, task) in member_and_relay_hints.into_iter() {
|
||||
// Skip current user
|
||||
if member == public_key {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get relay hint if available
|
||||
let relay_url = task.await;
|
||||
|
||||
// Construct a public key tag with relay hint
|
||||
let tag = TagStandard::PublicKey {
|
||||
public_key: member,
|
||||
relay_url,
|
||||
alias: None,
|
||||
uppercase: false,
|
||||
};
|
||||
|
||||
tags.push(Tag::from_standardized_without_cell(tag));
|
||||
}
|
||||
|
||||
// Add subject tag if present
|
||||
if let Some(value) = subject {
|
||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
||||
value.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
// Add all reply tags
|
||||
for id in replies {
|
||||
tags.push(Tag::event(id))
|
||||
}
|
||||
|
||||
// Construct a direct message event
|
||||
//
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
||||
.tags(tags)
|
||||
.build(public_key);
|
||||
|
||||
// Ensure the event ID has been generated
|
||||
event.ensure_id();
|
||||
|
||||
Ok(event)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a task to send a message to all room members
|
||||
@@ -459,46 +647,27 @@ impl Room {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get current user's public key and relays
|
||||
let current_user = nostr.read(cx).identity().read(cx).public_key();
|
||||
let current_user_relays = nostr.read(cx).messaging_relays(¤t_user, cx);
|
||||
|
||||
let mut members = self.members();
|
||||
let rumor = rumor.to_owned();
|
||||
|
||||
// Get all members and their messaging relays
|
||||
let task = self.members_with_relays(cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let current_user_relays = current_user_relays.await;
|
||||
let mut members = task.await;
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let current_user = signer.get_public_key().await?;
|
||||
|
||||
// Remove the current user's public key from the list of receivers
|
||||
// the current user will be handled separately
|
||||
members.retain(|(this, _)| this != ¤t_user);
|
||||
members.retain(|this| this != ¤t_user);
|
||||
|
||||
// Collect the send reports
|
||||
let mut reports: Vec<SendReport> = vec![];
|
||||
|
||||
for (receiver, relays) in members.into_iter() {
|
||||
// Check if there are any relays to send the message to
|
||||
if relays.is_empty() {
|
||||
reports.push(SendReport::new(receiver).relays_not_found());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure relay connection
|
||||
for url in relays.iter() {
|
||||
client.add_relay(url).await?;
|
||||
client.connect_relay(url).await?;
|
||||
}
|
||||
|
||||
for receiver in members.into_iter() {
|
||||
// Construct the gift wrap event
|
||||
let event =
|
||||
EventBuilder::gift_wrap(&signer, &receiver, rumor.clone(), vec![]).await?;
|
||||
EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), vec![]).await?;
|
||||
|
||||
// Send the gift wrap event to the messaging relays
|
||||
match client.send_event_to(relays, &event).await {
|
||||
match client.send_event(&event).to_nip17().await {
|
||||
Ok(output) => {
|
||||
let id = output.id().to_owned();
|
||||
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
|
||||
@@ -536,24 +705,12 @@ impl Room {
|
||||
|
||||
// Construct the gift-wrapped event
|
||||
let event =
|
||||
EventBuilder::gift_wrap(&signer, ¤t_user, rumor.clone(), vec![]).await?;
|
||||
EventBuilder::gift_wrap(signer, ¤t_user, rumor.clone(), vec![]).await?;
|
||||
|
||||
// Only send a backup message to current user if sent successfully to others
|
||||
if reports.iter().all(|r| r.is_sent_success()) {
|
||||
// Check if there are any relays to send the event to
|
||||
if current_user_relays.is_empty() {
|
||||
reports.push(SendReport::new(current_user).relays_not_found());
|
||||
return Ok(reports);
|
||||
}
|
||||
|
||||
// Ensure relay connection
|
||||
for url in current_user_relays.iter() {
|
||||
client.add_relay(url).await?;
|
||||
client.connect_relay(url).await?;
|
||||
}
|
||||
|
||||
// Send the event to the messaging relays
|
||||
match client.send_event_to(current_user_relays, &event).await {
|
||||
match client.send_event(&event).to_nip17().await {
|
||||
Ok(output) => {
|
||||
reports.push(SendReport::new(current_user).status(output));
|
||||
}
|
||||
@@ -591,7 +748,7 @@ impl Room {
|
||||
|
||||
if let Some(event) = client.database().event_by_id(id).await? {
|
||||
for url in urls.into_iter() {
|
||||
let relay = client.pool().relay(url).await?;
|
||||
let relay = client.relay(url).await?.context("Relay not found")?;
|
||||
let id = relay.send_event(&event).await?;
|
||||
|
||||
let resent: Output<EventId> = Output {
|
||||
@@ -622,4 +779,5 @@ impl Room {
|
||||
Ok(resend_reports)
|
||||
})
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -21,11 +21,10 @@ anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
indexset = "0.12.3"
|
||||
emojis = "0.6.4"
|
||||
once_cell = "1.19.0"
|
||||
regex = "1"
|
||||
|
||||
@@ -2,6 +2,13 @@ use gpui::Action;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub enum Command {
|
||||
Insert(&'static str),
|
||||
ChangeSubject(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub struct SeenOn(pub EventId);
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, App, AppContext, Corner, Element, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::InputState;
|
||||
use ui::popover::{Popover, PopoverContent};
|
||||
use ui::{Icon, Sizable, Size};
|
||||
|
||||
static EMOJIS: OnceLock<Vec<SharedString>> = OnceLock::new();
|
||||
|
||||
fn get_emojis() -> &'static Vec<SharedString> {
|
||||
EMOJIS.get_or_init(|| {
|
||||
let mut emojis: Vec<SharedString> = vec![];
|
||||
|
||||
emojis.extend(
|
||||
emojis::Group::SmileysAndEmotion
|
||||
.emojis()
|
||||
.map(|e| SharedString::from(e.as_str()))
|
||||
.collect::<Vec<SharedString>>(),
|
||||
);
|
||||
|
||||
emojis
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct EmojiPicker {
|
||||
target: Option<WeakEntity<InputState>>,
|
||||
icon: Option<Icon>,
|
||||
anchor: Option<Corner>,
|
||||
size: Size,
|
||||
}
|
||||
|
||||
impl EmojiPicker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
size: Size::default(),
|
||||
target: None,
|
||||
anchor: None,
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target(mut self, target: WeakEntity<InputState>) -> Self {
|
||||
self.target = Some(target);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn anchor(mut self, corner: Corner) -> Self {
|
||||
self.anchor = Some(corner);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for EmojiPicker {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for EmojiPicker {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
Popover::new("emojis")
|
||||
.map(|this| {
|
||||
if let Some(corner) = self.anchor {
|
||||
this.anchor(corner)
|
||||
} else {
|
||||
this.anchor(gpui::Corner::BottomLeft)
|
||||
}
|
||||
})
|
||||
.trigger(
|
||||
Button::new("emojis-trigger")
|
||||
.when_some(self.icon, |this, icon| this.icon(icon))
|
||||
.ghost()
|
||||
.with_size(self.size),
|
||||
)
|
||||
.content(move |window, cx| {
|
||||
let input = self.target.clone();
|
||||
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, move |_window, cx| {
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.children(get_emojis().iter().map(|e| {
|
||||
div()
|
||||
.id(e.clone())
|
||||
.flex_auto()
|
||||
.size_10()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(e.clone())
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.on_click({
|
||||
let item = e.clone();
|
||||
let input = input.clone();
|
||||
|
||||
move |_, window, cx| {
|
||||
if let Some(input) = input.as_ref() {
|
||||
_ = input.update(cx, |this, cx| {
|
||||
let value = this.value();
|
||||
let new_text = if value.is_empty() {
|
||||
format!("{item}")
|
||||
} else if value.ends_with(" ") {
|
||||
format!("{value}{item}")
|
||||
} else {
|
||||
format!("{value} {item}")
|
||||
};
|
||||
this.set_value(new_text, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
.into_any()
|
||||
})
|
||||
.scrollable()
|
||||
.max_h(px(300.))
|
||||
.max_w(px(300.))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,44 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use actions::*;
|
||||
use chat::{Message, RenderedMessage, Room, RoomEvent, RoomKind, SendReport};
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use chat::{Message, RenderedMessage, Room, RoomEvent, SendReport};
|
||||
use common::{nip96_upload, RenderedTimestamp};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext,
|
||||
deferred, div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext,
|
||||
ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement,
|
||||
PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement,
|
||||
Styled, StyledImage, Subscription, Task, WeakEntity, Window,
|
||||
PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage,
|
||||
Subscription, Task, WeakEntity, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use indexset::{BTreeMap, BTreeSet};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::fs;
|
||||
use smol::lock::RwLock;
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::context_menu::ContextMenuExt;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::menu::{ContextMenuExt, DropdownMenu};
|
||||
use ui::notification::Notification;
|
||||
use ui::popup_menu::PopupMenuExt;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::{
|
||||
h_flex, v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable,
|
||||
StyledExt,
|
||||
h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
|
||||
WindowExtension,
|
||||
};
|
||||
|
||||
use crate::emoji::EmojiPicker;
|
||||
use crate::text::RenderedText;
|
||||
|
||||
mod actions;
|
||||
mod emoji;
|
||||
mod text;
|
||||
|
||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||
@@ -49,7 +49,6 @@ pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity
|
||||
pub struct ChatPanel {
|
||||
id: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
|
||||
/// Chat Room
|
||||
room: WeakEntity<Room>,
|
||||
@@ -63,12 +62,15 @@ pub struct ChatPanel {
|
||||
/// Mapping message ids to their rendered texts
|
||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||
|
||||
/// Mapping message ids to their reports
|
||||
reports_by_id: BTreeMap<EventId, Vec<SendReport>>,
|
||||
/// Mapping message (rumor event) ids to their reports
|
||||
reports_by_id: Entity<BTreeMap<EventId, Vec<SendReport>>>,
|
||||
|
||||
/// Input state
|
||||
input: Entity<InputState>,
|
||||
|
||||
/// Sent message ids
|
||||
sent_ids: Arc<RwLock<Vec<EventId>>>,
|
||||
|
||||
/// Replies to
|
||||
replies_to: Entity<HashSet<EventId>>,
|
||||
|
||||
@@ -79,97 +81,63 @@ pub struct ChatPanel {
|
||||
uploading: bool,
|
||||
|
||||
/// Async operations
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl ChatPanel {
|
||||
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
// Define attachments and replies_to entities
|
||||
let attachments = cx.new(|_| vec![]);
|
||||
let replies_to = cx.new(|_| HashSet::new());
|
||||
let reports_by_id = cx.new(|_| BTreeMap::new());
|
||||
|
||||
// Define list of messages
|
||||
let messages = BTreeSet::from([Message::system()]);
|
||||
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
||||
|
||||
// Get room id and name
|
||||
let (id, name) = room
|
||||
.read_with(cx, |this, _cx| {
|
||||
let id = this.id.to_string().into();
|
||||
let name = this.display_name(cx);
|
||||
|
||||
(id, name)
|
||||
})
|
||||
.unwrap_or(("Unknown".into(), "Message...".into()));
|
||||
|
||||
// Define input state
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Message...")
|
||||
.placeholder(format!("Message {}", name))
|
||||
.auto_grow(1, 20)
|
||||
.prevent_new_line_on_enter()
|
||||
.clean_on_escape()
|
||||
});
|
||||
|
||||
let attachments = cx.new(|_| vec![]);
|
||||
let replies_to = cx.new(|_| HashSet::new());
|
||||
|
||||
let messages = BTreeSet::from([Message::system()]);
|
||||
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
||||
|
||||
let id: SharedString = room
|
||||
.read_with(cx, |this, _cx| this.id.to_string().into())
|
||||
.unwrap_or("Unknown".into());
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
if let Ok(connect) = room.read_with(cx, |this, cx| this.connect(cx)) {
|
||||
tasks.push(
|
||||
// Get messaging relays and encryption keys announcement for each member
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = connect.await {
|
||||
log::error!("Failed to initialize room: {}", e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if let Ok(get_messages) = room.read_with(cx, |this, cx| this.get_messages(cx)) {
|
||||
tasks.push(
|
||||
// Load all messages belonging to this room
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = get_messages.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(events) => {
|
||||
this.insert_messages(&events, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(room) = room.upgrade() {
|
||||
subscriptions.push(
|
||||
// Subscribe to room events
|
||||
cx.subscribe_in(&room, window, move |this, _room, event, window, cx| {
|
||||
match event {
|
||||
RoomEvent::Incoming(message) => {
|
||||
this.insert_message(message, false, cx);
|
||||
}
|
||||
RoomEvent::Reload => {
|
||||
this.load_messages(window, cx);
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to input events
|
||||
cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Self, _input, event, window, cx| {
|
||||
// Define subscriptions
|
||||
let subscriptions =
|
||||
smallvec![
|
||||
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.send_message(window, cx);
|
||||
this.send_text_message(window, cx);
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
})
|
||||
];
|
||||
|
||||
// Define all functions that will run after the current cycle
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.connect(window, cx);
|
||||
this.handle_notifications(cx);
|
||||
|
||||
this.subscribe_room_events(window, cx);
|
||||
this.get_messages(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
id,
|
||||
messages,
|
||||
room,
|
||||
@@ -178,38 +146,113 @@ impl ChatPanel {
|
||||
replies_to,
|
||||
attachments,
|
||||
rendered_texts_by_id: BTreeMap::new(),
|
||||
reports_by_id: BTreeMap::new(),
|
||||
reports_by_id,
|
||||
sent_ids: Arc::new(RwLock::new(Vec::new())),
|
||||
uploading: false,
|
||||
image_cache: RetainAllImageCache::new(cx),
|
||||
focus_handle: cx.focus_handle(),
|
||||
_subscriptions: subscriptions,
|
||||
tasks,
|
||||
subscriptions,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle nostr notifications
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let sent_ids = self.sent_ids.clone();
|
||||
|
||||
let (tx, rx) = flume::bounded::<(EventId, RelayUrl)>(256);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
if let ClientNotification::Message {
|
||||
message: RelayMessage::Ok { event_id, .. },
|
||||
relay_url,
|
||||
} = notification
|
||||
{
|
||||
let sent_ids = sent_ids.read().await;
|
||||
|
||||
if sent_ids.contains(&event_id) {
|
||||
tx.send_async((event_id, relay_url)).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok((event_id, relay_url)) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.reports_by_id.update(cx, |this, cx| {
|
||||
for reports in this.values_mut() {
|
||||
for report in reports.iter_mut() {
|
||||
if let Some(output) = report.output.as_mut() {
|
||||
if output.id() == &event_id {
|
||||
output.success.insert(relay_url.clone());
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.subscriptions.push(
|
||||
// Subscribe to room events
|
||||
cx.subscribe_in(&room, window, move |this, _room, event, window, cx| {
|
||||
match event {
|
||||
RoomEvent::Incoming(message) => {
|
||||
this.insert_message(message, false, cx);
|
||||
}
|
||||
RoomEvent::Reload => {
|
||||
this.get_messages(window, cx);
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all necessary data for each member
|
||||
fn connect(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(connect) = self.room.read_with(cx, |this, cx| this.early_connect(cx)) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.tasks.push(cx.background_spawn(connect));
|
||||
}
|
||||
|
||||
/// Load all messages belonging to this room
|
||||
fn load_messages(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = get_messages.await;
|
||||
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(events) => {
|
||||
this.insert_messages(&events, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let events = get_messages.await?;
|
||||
|
||||
// Update message list
|
||||
this.update(cx, |this, cx| {
|
||||
this.insert_messages(&events, cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get user input content and merged all attachments
|
||||
fn input_content(&self, cx: &Context<Self>) -> String {
|
||||
/// Get user input content and merged all attachments if available
|
||||
fn get_input_value(&self, cx: &Context<Self>) -> String {
|
||||
// Get input's value
|
||||
let mut content = self.input.read(cx).value().trim().to_string();
|
||||
|
||||
@@ -233,10 +276,9 @@ impl ChatPanel {
|
||||
content
|
||||
}
|
||||
|
||||
/// Send a message to all members of the chat
|
||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Get the message which includes all attachments
|
||||
let content = self.input_content(cx);
|
||||
let content = self.get_input_value(cx);
|
||||
|
||||
// Return if message is empty
|
||||
if content.trim().is_empty() {
|
||||
@@ -244,79 +286,97 @@ impl ChatPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current room entity
|
||||
let Some(room) = self.room.upgrade().map(|this| this.read(cx)) else {
|
||||
self.send_message(&content, window, cx);
|
||||
}
|
||||
|
||||
/// Send a message to all members of the chat
|
||||
fn send_message(&mut self, value: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if value.trim().is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get room entity
|
||||
let room = self.room.clone();
|
||||
|
||||
// Get content and replies
|
||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||
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();
|
||||
// This can't fail, because we already ensured that the ID is set
|
||||
let id = rumor.id.unwrap();
|
||||
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get replies_to if it's present
|
||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||
|
||||
// Create a temporary message for optimistic update
|
||||
let rumor = room.create_message(&content, replies.as_ref(), cx);
|
||||
let rumor_id = rumor.id.unwrap();
|
||||
|
||||
// Create a task for sending the message in the background
|
||||
let send_message = room.send_message(&rumor, cx);
|
||||
|
||||
// Optimistically update message list
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
// Wait for the delay
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(100))
|
||||
.await;
|
||||
|
||||
// Update the message list and reset the states
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.remove_all_replies(cx);
|
||||
this.remove_all_attachments(cx);
|
||||
|
||||
// Reset the input to its default state
|
||||
this.input.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
this.set_disabled(false, cx);
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
|
||||
// Update the message list
|
||||
this.insert_message(&rumor, true, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
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| {
|
||||
let result = send_message.await;
|
||||
let outputs = task.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(reports) => {
|
||||
// Update room's status
|
||||
this.room
|
||||
.update(cx, |this, cx| {
|
||||
if this.kind != RoomKind::Ongoing {
|
||||
// Update the room kind to ongoing,
|
||||
// but keep the room kind if send failed
|
||||
if reports.iter().all(|r| !r.is_sent_success()) {
|
||||
this.kind = RoomKind::Ongoing;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
// Add sent IDs to the list
|
||||
let mut sent_ids = sent_ids.write().await;
|
||||
sent_ids.extend(outputs.iter().filter_map(|output| output.gift_wrap_id));
|
||||
|
||||
// Insert the sent reports
|
||||
this.reports_by_id.insert(rumor_id, reports);
|
||||
// Update the state
|
||||
this.update(cx, |this, cx| {
|
||||
this.insert_reports(id, outputs, cx);
|
||||
})?;
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
Ok(())
|
||||
}))
|
||||
}
|
||||
|
||||
/// Clear the input field, attachments, and replies
|
||||
///
|
||||
/// Only run after sending a message
|
||||
fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
self.attachments.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
self.replies_to.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert reports
|
||||
fn insert_reports(&mut self, id: EventId, reports: Vec<SendReport>, cx: &mut Context<Self>) {
|
||||
self.reports_by_id.update(cx, |this, cx| {
|
||||
this.insert(id, reports);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Insert a message into the chat panel
|
||||
@@ -349,23 +409,33 @@ impl ChatPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a message failed to send by its ID
|
||||
fn is_sent_failed(&self, id: &EventId) -> bool {
|
||||
/// Check if a message is pending
|
||||
fn sent_pending(&self, id: &EventId, cx: &App) -> bool {
|
||||
self.reports_by_id
|
||||
.read(cx)
|
||||
.get(id)
|
||||
.is_some_and(|reports| reports.iter().all(|r| !r.is_sent_success()))
|
||||
.is_some_and(|reports| reports.iter().any(|r| r.pending()))
|
||||
}
|
||||
|
||||
/// Check if a message was sent successfully by its ID
|
||||
fn is_sent_success(&self, id: &EventId) -> Option<bool> {
|
||||
fn sent_success(&self, id: &EventId, cx: &App) -> bool {
|
||||
self.reports_by_id
|
||||
.read(cx)
|
||||
.get(id)
|
||||
.map(|reports| reports.iter().all(|r| r.is_sent_success()))
|
||||
.is_some_and(|reports| reports.iter().any(|r| r.success()))
|
||||
}
|
||||
|
||||
/// Get the sent reports for a message by its ID
|
||||
fn sent_reports(&self, id: &EventId) -> Option<&Vec<SendReport>> {
|
||||
self.reports_by_id.get(id)
|
||||
/// Check if a message failed to send by its ID
|
||||
fn sent_failed(&self, id: &EventId, cx: &App) -> Option<bool> {
|
||||
self.reports_by_id
|
||||
.read(cx)
|
||||
.get(id)
|
||||
.map(|reports| reports.iter().all(|r| !r.success()))
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -414,13 +484,6 @@ impl ChatPanel {
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_all_replies(&mut self, cx: &mut Context<Self>) {
|
||||
self.replies_to.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
@@ -435,9 +498,9 @@ impl ChatPanel {
|
||||
prompt: None,
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let mut paths = path.await.ok()?.ok()??;
|
||||
let path = paths.pop()?;
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let mut paths = path.await??.context("Not found")?;
|
||||
let path = paths.pop().context("No path")?;
|
||||
|
||||
let upload = Tokio::spawn(cx, async move {
|
||||
let file = fs::read(path).await.ok()?;
|
||||
@@ -466,9 +529,8 @@ impl ChatPanel {
|
||||
.ok();
|
||||
}
|
||||
|
||||
Some(())
|
||||
})
|
||||
.detach();
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
||||
@@ -492,28 +554,21 @@ impl ChatPanel {
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_all_attachments(&mut self, cx: &mut Context<Self>) {
|
||||
self.attachments.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn profile(&self, public_key: &PublicKey, cx: &Context<Self>) -> Person {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
persons.read(cx).get(public_key, cx)
|
||||
}
|
||||
|
||||
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
|
||||
const MSG: &str =
|
||||
"This conversation is private. Only members can see each other's messages.";
|
||||
|
||||
v_flex()
|
||||
.id(ix)
|
||||
.group("")
|
||||
.h_32()
|
||||
.h_40()
|
||||
.w_full()
|
||||
.relative()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.p_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
@@ -523,12 +578,10 @@ impl ChatPanel {
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_10()
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
.size_12()
|
||||
.text_color(cx.theme().ghost_element_active),
|
||||
)
|
||||
.child(SharedString::from(
|
||||
"This conversation is private. Only members can see each other's messages.",
|
||||
))
|
||||
.child(SharedString::from(MSG))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -566,7 +619,7 @@ impl ChatPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
if let Some(message) = self.messages.get_index(ix) {
|
||||
if let Some(message) = self.messages.iter().nth(ix) {
|
||||
match message {
|
||||
Message::User(rendered) => {
|
||||
let text = self
|
||||
@@ -591,7 +644,7 @@ impl ChatPanel {
|
||||
&self,
|
||||
ix: usize,
|
||||
message: &RenderedMessage,
|
||||
text: AnyElement,
|
||||
rendered_text: AnyElement,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let id = message.id;
|
||||
@@ -602,10 +655,13 @@ impl ChatPanel {
|
||||
let has_replies = !replies.is_empty();
|
||||
|
||||
// Check if message is sent failed
|
||||
let is_sent_failed = self.is_sent_failed(&id);
|
||||
let sent_pending = self.sent_pending(&id, cx);
|
||||
|
||||
// Check if message is sent successfully
|
||||
let is_sent_success = self.is_sent_success(&id);
|
||||
let sent_success = self.sent_success(&id, cx);
|
||||
|
||||
// Check if message is sent failed
|
||||
let sent_failed = self.sent_failed(&id, cx);
|
||||
|
||||
// Hide avatar setting
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
@@ -653,18 +709,21 @@ impl ChatPanel {
|
||||
.child(author.name()),
|
||||
)
|
||||
.child(message.created_at.to_human_time())
|
||||
.when_some(is_sent_success, |this, status| {
|
||||
this.when(status, |this| {
|
||||
this.child(self.render_message_sent(&id, cx))
|
||||
})
|
||||
.when(sent_pending, |this| {
|
||||
this.child(deferred(Indicator::new().small()))
|
||||
})
|
||||
.when(sent_success, |this| {
|
||||
this.child(deferred(self.render_sent_indicator(&id, cx)))
|
||||
}),
|
||||
)
|
||||
.when(has_replies, |this| {
|
||||
this.children(self.render_message_replies(replies, cx))
|
||||
})
|
||||
.child(text)
|
||||
.when(is_sent_failed, |this| {
|
||||
this.child(self.render_message_reports(&id, cx))
|
||||
.child(rendered_text)
|
||||
.when_some(sent_failed, |this, failed| {
|
||||
this.when(failed, |this| {
|
||||
this.child(deferred(self.render_message_reports(&id, cx)))
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -729,11 +788,11 @@ impl ChatPanel {
|
||||
items
|
||||
}
|
||||
|
||||
fn render_message_sent(&self, id: &EventId, _cx: &Context<Self>) -> impl IntoElement {
|
||||
fn render_sent_indicator(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.id(SharedString::from(id.to_hex()))
|
||||
.child(SharedString::from("• Sent"))
|
||||
.when_some(self.sent_reports(id).cloned(), |this, reports| {
|
||||
.when_some(self.sent_reports(id, cx), |this, reports| {
|
||||
this.on_click(move |_e, window, cx| {
|
||||
let reports = reports.clone();
|
||||
|
||||
@@ -765,7 +824,7 @@ impl ChatPanel {
|
||||
.child(SharedString::from(
|
||||
"Failed to send message. Click to see details.",
|
||||
))
|
||||
.when_some(self.sent_reports(id).cloned(), |this, reports| {
|
||||
.when_some(self.sent_reports(id, cx), |this, reports| {
|
||||
this.on_click(move |_e, window, cx| {
|
||||
let reports = reports.clone();
|
||||
|
||||
@@ -808,48 +867,6 @@ impl ChatPanel {
|
||||
.child(name.clone()),
|
||||
),
|
||||
)
|
||||
.when(report.relays_not_found, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.h_20()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().danger_background)
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.text_center()
|
||||
.child(SharedString::from("Messaging Relays not found")),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(report.device_not_found, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.h_20()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().danger_background)
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.text_center()
|
||||
.child(SharedString::from("Encryption Key not found")),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when_some(report.error.clone(), |this, error| {
|
||||
this.child(
|
||||
h_flex()
|
||||
@@ -865,7 +882,7 @@ impl ChatPanel {
|
||||
.child(div().flex_1().w_full().text_center().child(error)),
|
||||
)
|
||||
})
|
||||
.when_some(report.status.clone(), |this, output| {
|
||||
.when_some(report.output.clone(), |this, output| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
@@ -992,9 +1009,9 @@ impl ChatPanel {
|
||||
.icon(IconName::Ellipsis)
|
||||
.small()
|
||||
.ghost()
|
||||
.popup_menu({
|
||||
.dropdown_menu({
|
||||
let id = id.to_owned();
|
||||
move |this, _, _| this.menu("Seen on", Box::new(SeenOn(id)))
|
||||
move |this, _window, _cx| this.menu("Seen on", Box::new(SeenOn(id)))
|
||||
}),
|
||||
)
|
||||
.group_hover("", |this| this.visible())
|
||||
@@ -1115,6 +1132,25 @@ impl ChatPanel {
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match command {
|
||||
Command::Insert(content) => {
|
||||
self.send_message(content, window, cx);
|
||||
}
|
||||
Command::ChangeSubject(subject) => {
|
||||
if self
|
||||
.room
|
||||
.update(cx, |this, cx| {
|
||||
this.set_subject(*subject, cx);
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
window.push_notification(Notification::error("Failed to change subject"), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for ChatPanel {
|
||||
@@ -1149,61 +1185,86 @@ impl Focusable for ChatPanel {
|
||||
impl Render for ChatPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.image_cache(self.image_cache.clone())
|
||||
.on_action(cx.listener(Self::on_command))
|
||||
.size_full()
|
||||
.child(
|
||||
list(
|
||||
self.list_state.clone(),
|
||||
cx.processor(|this, ix, window, cx| {
|
||||
// Get and render message by index
|
||||
this.render_message(ix, window, cx)
|
||||
}),
|
||||
)
|
||||
.flex_1(),
|
||||
div()
|
||||
.flex_1()
|
||||
.size_full()
|
||||
.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(
|
||||
div()
|
||||
v_flex()
|
||||
.flex_shrink_0()
|
||||
.p_2()
|
||||
.w_full()
|
||||
.relative()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.gap_1p5()
|
||||
.children(self.render_attachment_list(window, cx))
|
||||
.children(self.render_reply_list(window, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.children(self.render_attachment_list(window, cx))
|
||||
.children(self.render_reply_list(window, cx))
|
||||
h_flex()
|
||||
.items_end()
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_end()
|
||||
.gap_2p5()
|
||||
Button::new("upload")
|
||||
.icon(IconName::Plus)
|
||||
.tooltip("Upload media")
|
||||
.loading(self.uploading)
|
||||
.disabled(self.uploading)
|
||||
.ghost()
|
||||
.large()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
TextInput::new(&self.input)
|
||||
.appearance(false)
|
||||
.flex_1()
|
||||
.text_sm(),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.pl_1()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::Upload)
|
||||
.loading(self.uploading)
|
||||
.disabled(self.uploading)
|
||||
.ghost()
|
||||
.large()
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
EmojiPicker::new()
|
||||
.target(self.input.downgrade())
|
||||
.icon(IconName::EmojiFill)
|
||||
.large(),
|
||||
Button::new("emoji")
|
||||
.icon(IconName::Emoji)
|
||||
.ghost()
|
||||
.large()
|
||||
.dropdown_menu_with_anchor(
|
||||
gpui::Corner::BottomLeft,
|
||||
move |this, _window, _cx| {
|
||||
this.horizontal()
|
||||
.menu("👍", Box::new(Command::Insert("👍")))
|
||||
.menu("👎", Box::new(Command::Insert("👎")))
|
||||
.menu("😄", Box::new(Command::Insert("😄")))
|
||||
.menu("🎉", Box::new(Command::Insert("🎉")))
|
||||
.menu("😕", Box::new(Command::Insert("😕")))
|
||||
.menu("❤️", Box::new(Command::Insert("❤️")))
|
||||
.menu("🚀", Box::new(Command::Insert("🚀")))
|
||||
.menu("👀", Box::new(Command::Insert("👀")))
|
||||
},
|
||||
),
|
||||
)
|
||||
.child(TextInput::new(&self.input)),
|
||||
.child(
|
||||
Button::new("send")
|
||||
.icon(IconName::PaperPlaneFill)
|
||||
.disabled(self.uploading)
|
||||
.ghost()
|
||||
.large()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.send_text_message(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
@@ -19,5 +20,3 @@ log.workspace = true
|
||||
|
||||
dirs = "5.0"
|
||||
qrcode = "0.14.1"
|
||||
whoami = "1.6.1"
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
pub const CLIENT_NAME: &str = "Coop";
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
|
||||
/// Bootstrap Relays.
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nos.social",
|
||||
"wss://user.kindpag.es",
|
||||
];
|
||||
|
||||
/// Search Relays.
|
||||
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.noswhere.com"];
|
||||
|
||||
/// Default relay for Nostr Connect
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
||||
|
||||
/// Default retry count for fetching NIP-17 relays
|
||||
pub const RELAY_RETRY: u64 = 2;
|
||||
|
||||
/// Default retry count for sending messages
|
||||
pub const SEND_RETRY: u64 = 10;
|
||||
|
||||
/// Default timeout (in seconds) for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
||||
|
||||
/// Default timeout (in seconds) for Nostr Connect (Bunker)
|
||||
pub const BUNKER_TIMEOUT: u64 = 30;
|
||||
|
||||
/// Default width of the sidebar.
|
||||
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.;
|
||||
@@ -1,68 +1,11 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use constants::*;
|
||||
pub use debounced_delay::*;
|
||||
pub use display::*;
|
||||
pub use event::*;
|
||||
pub use nip05::*;
|
||||
pub use nip96::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
pub use paths::*;
|
||||
|
||||
mod constants;
|
||||
mod debounced_delay;
|
||||
mod display;
|
||||
mod event;
|
||||
mod nip05;
|
||||
mod nip96;
|
||||
mod paths;
|
||||
|
||||
static APP_NAME: OnceLock<String> = OnceLock::new();
|
||||
static NIP65_RELAYS: OnceLock<Vec<(RelayUrl, Option<RelayMetadata>)>> = OnceLock::new();
|
||||
static NIP17_RELAYS: OnceLock<Vec<RelayUrl>> = 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})")
|
||||
})
|
||||
}
|
||||
|
||||
/// Default NIP-65 Relays. Used for new account
|
||||
pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option<RelayMetadata>)> {
|
||||
NIP65_RELAYS.get_or_init(|| {
|
||||
vec![
|
||||
(
|
||||
RelayUrl::parse("wss://nostr.mom").unwrap(),
|
||||
Some(RelayMetadata::Read),
|
||||
),
|
||||
(
|
||||
RelayUrl::parse("wss://nostr.bitcoiner.social").unwrap(),
|
||||
Some(RelayMetadata::Read),
|
||||
),
|
||||
(
|
||||
RelayUrl::parse("wss://nos.lol").unwrap(),
|
||||
Some(RelayMetadata::Write),
|
||||
),
|
||||
(
|
||||
RelayUrl::parse("wss://relay.snort.social").unwrap(),
|
||||
Some(RelayMetadata::Write),
|
||||
),
|
||||
(RelayUrl::parse("wss://relay.primal.net").unwrap(), None),
|
||||
(RelayUrl::parse("wss://relay.damus.io").unwrap(), None),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// Default NIP-17 Relays. Used for new account
|
||||
pub fn default_nip17_relays() -> &'static Vec<RelayUrl> {
|
||||
NIP17_RELAYS.get_or_init(|| {
|
||||
vec![
|
||||
RelayUrl::parse("wss://nip17.com").unwrap(),
|
||||
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,11 +72,10 @@ pub async fn nip96_upload(
|
||||
let json: Value = res.json().await?;
|
||||
|
||||
let config = nip96::ServerConfig::from_json(json.to_string())?;
|
||||
let signer = if client.has_signer().await {
|
||||
client.signer().await?
|
||||
} else {
|
||||
Keys::generate().into_nostr_signer()
|
||||
};
|
||||
let signer = client
|
||||
.signer()
|
||||
.cloned()
|
||||
.unwrap_or(Keys::generate().into_nostr_signer());
|
||||
|
||||
let url = upload(&signer, &config, file, None).await?;
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ person = { path = "../person" }
|
||||
relay_auth = { path = "../relay_auth" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
|
||||
@@ -1,677 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use auto_update::{AutoUpdateStatus, AutoUpdater};
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use chat_ui::{CopyPublicKey, OpenPublicKey};
|
||||
use common::DEFAULT_SIDEBAR_WIDTH;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
deferred, div, px, relative, rems, App, AppContext, Axis, ClipboardItem, Context, Entity,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Window,
|
||||
};
|
||||
use key_store::{Credential, KeyItem, KeyStore};
|
||||
use nostr_connect::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use relay_auth::RelayAuth;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::{ActiveTheme, Theme, ThemeMode, ThemeRegistry};
|
||||
use title_bar::TitleBar;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::PanelView;
|
||||
use ui::dock_area::{ClosePanel, DockArea, DockItem};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::popup_menu::PopupMenuExt;
|
||||
use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt};
|
||||
|
||||
use crate::actions::{
|
||||
reset, DarkMode, KeyringPopup, Logout, Settings, Themes, ViewProfile, ViewRelays,
|
||||
};
|
||||
use crate::user::viewer;
|
||||
use crate::views::compose::compose_button;
|
||||
use crate::views::{onboarding, preferences, setup_relay, startup, welcome};
|
||||
use crate::{login, new_identity, sidebar, user};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
|
||||
cx.new(|cx| ChatSpace::new(window, cx))
|
||||
}
|
||||
|
||||
pub fn login(window: &mut Window, cx: &mut App) {
|
||||
let panel = login::init(window, cx);
|
||||
ChatSpace::set_center_panel(panel, window, cx);
|
||||
}
|
||||
|
||||
pub fn new_account(window: &mut Window, cx: &mut App) {
|
||||
let panel = new_identity::init(window, cx);
|
||||
ChatSpace::set_center_panel(panel, window, cx);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ChatSpace {
|
||||
/// App's Title Bar
|
||||
title_bar: Entity<TitleBar>,
|
||||
|
||||
/// App's Dock Area
|
||||
dock: Entity<DockArea>,
|
||||
|
||||
/// Determines if the chat space is ready to use
|
||||
ready: bool,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
}
|
||||
|
||||
impl ChatSpace {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let keystore = KeyStore::global(cx);
|
||||
|
||||
let title_bar = cx.new(|_| TitleBar::new());
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
|
||||
let identity = nostr.read(cx).identity();
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Automatically sync theme with system appearance
|
||||
window.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe account entity changes
|
||||
cx.observe_in(&identity, window, move |this, state, window, cx| {
|
||||
if !this.ready && state.read(cx).has_public_key() {
|
||||
this.set_default_layout(window, cx);
|
||||
|
||||
// Load all chat room in the database if available
|
||||
let chat = ChatRegistry::global(cx);
|
||||
chat.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
});
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe keystore entity changes
|
||||
cx.observe_in(&keystore, window, move |_this, state, window, cx| {
|
||||
if state.read(cx).initialized {
|
||||
let backend = state.read(cx).backend();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = backend
|
||||
.read_credentials(&KeyItem::User.to_string(), cx)
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(Some((user, secret))) => {
|
||||
let credential = Credential::new(user, secret);
|
||||
this.set_startup_layout(credential, window, cx);
|
||||
}
|
||||
_ => {
|
||||
this.set_onboarding_layout(window, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe all events emitted by the chat registry
|
||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
||||
match ev {
|
||||
ChatEvent::OpenRoom(id) => {
|
||||
if let Some(room) = chat.read(cx).room(id, cx) {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.add_panel(
|
||||
Arc::new(chat_ui::init(room, window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
ChatEvent::CloseRoom(..) => {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
// Force focus to the tab panel
|
||||
this.focus_tab_panel(window, cx);
|
||||
// Dispatch the close panel action
|
||||
cx.defer_in(window, |_, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the chat registry
|
||||
cx.observe(&chat, move |this, chat, cx| {
|
||||
let ids = this.get_all_panels(cx);
|
||||
|
||||
chat.update(cx, |this, cx| {
|
||||
this.refresh_rooms(ids, cx);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
dock,
|
||||
title_bar,
|
||||
ready: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_onboarding_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let panel = Arc::new(onboarding::init(window, cx));
|
||||
let center = DockItem::panel(panel);
|
||||
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.reset(window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn set_startup_layout(&mut self, cre: Credential, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let panel = Arc::new(startup::init(cre, window, cx));
|
||||
let center = DockItem::panel(panel);
|
||||
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.reset(window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn set_default_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let weak_dock = self.dock.downgrade();
|
||||
|
||||
let sidebar = Arc::new(sidebar::init(window, cx));
|
||||
let center = Arc::new(welcome::init(window, cx));
|
||||
|
||||
let left = DockItem::panel(sidebar);
|
||||
let center = DockItem::split_with_sizes(
|
||||
Axis::Vertical,
|
||||
vec![DockItem::tabs(vec![center], None, &weak_dock, window, cx)],
|
||||
vec![None],
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
self.ready = true;
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.set_left_dock(left, Some(px(DEFAULT_SIDEBAR_WIDTH)), true, window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn on_settings(&mut self, _ev: &Settings, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = preferences::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
modal
|
||||
.title(SharedString::from("Preferences"))
|
||||
.width(px(520.))
|
||||
.child(view.clone())
|
||||
});
|
||||
}
|
||||
|
||||
fn on_profile(&mut self, _ev: &ViewProfile, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = user::init(window, cx);
|
||||
let entity = view.downgrade();
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
let entity = entity.clone();
|
||||
|
||||
modal
|
||||
.title("Profile")
|
||||
.confirm()
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text("Update"))
|
||||
.on_ok(move |_, window, cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let set_metadata = this.set_metadata(cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = set_metadata.await;
|
||||
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
match result {
|
||||
Ok(person) => {
|
||||
persons.update(cx, |this, cx| {
|
||||
this.insert(person, cx);
|
||||
// Close the edit profile modal
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
|
||||
// false to keep the modal open
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn on_relays(&mut self, _ev: &ViewRelays, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = setup_relay::init(window, cx);
|
||||
let entity = view.downgrade();
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
let entity = entity.clone();
|
||||
|
||||
this.confirm()
|
||||
.title(SharedString::from("Set Up Messaging Relays"))
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text("Update"))
|
||||
.on_ok(move |_, window, cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.set_relays(window, cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
// false to keep the modal open
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn on_dark_mode(&mut self, _ev: &DarkMode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if cx.theme().mode.is_dark() {
|
||||
Theme::change(ThemeMode::Light, Some(window), cx);
|
||||
} else {
|
||||
Theme::change(ThemeMode::Dark, Some(window), cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_themes(&mut self, _ev: &Themes, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
let registry = ThemeRegistry::global(cx);
|
||||
let themes = registry.read(cx).themes();
|
||||
|
||||
this.title("Select theme")
|
||||
.show_close(true)
|
||||
.overlay_closable(true)
|
||||
.child(v_flex().gap_2().pb_4().children({
|
||||
let mut items = Vec::with_capacity(themes.len());
|
||||
|
||||
for (name, theme) in themes.iter() {
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_10()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text)
|
||||
.line_height(relative(1.3))
|
||||
.child(theme.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(theme.author.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("change-{name}"))
|
||||
.label("Set")
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click({
|
||||
let theme = theme.clone();
|
||||
move |_ev, window, cx| {
|
||||
Theme::apply_theme(theme.clone(), Some(window), cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn on_sign_out(&mut self, _e: &Logout, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
reset(cx);
|
||||
}
|
||||
|
||||
fn on_open_pubkey(&mut self, ev: &OpenPublicKey, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let public_key = ev.0;
|
||||
let view = viewer::init(public_key, window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.alert()
|
||||
.show_close(true)
|
||||
.overlay_closable(true)
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text("View on njump.me"))
|
||||
.on_ok(move |_, _window, cx| {
|
||||
let bech32 = public_key.to_bech32().unwrap();
|
||||
let url = format!("https://njump.me/{bech32}");
|
||||
|
||||
// Open the URL in the default browser
|
||||
cx.open_url(&url);
|
||||
|
||||
// false to keep the modal open
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn on_copy_pubkey(&mut self, ev: &CopyPublicKey, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(bech32) = ev.0.to_bech32();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(bech32));
|
||||
window.push_notification("Copied", cx);
|
||||
}
|
||||
|
||||
fn on_keyring(&mut self, _ev: &KeyringPopup, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.show_close(true)
|
||||
.title(SharedString::from("Keyring is disabled"))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.pb_4()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Coop cannot access the Keyring Service on your system. By design, Coop uses Keyring to store your credentials."))
|
||||
.child(SharedString::from("Without access to Keyring, Coop will store your credentials as plain text."))
|
||||
.child(SharedString::from("If you want to store your credentials in the Keyring, please enable Keyring and allow Coop to access it.")),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn get_all_panels(&self, cx: &App) -> Option<Vec<u64>> {
|
||||
let ids: Vec<u64> = self
|
||||
.dock
|
||||
.read(cx)
|
||||
.items
|
||||
.panel_ids(cx)
|
||||
.into_iter()
|
||||
.filter_map(|panel| panel.parse::<u64>().ok())
|
||||
.collect();
|
||||
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
fn set_center_panel<P>(panel: P, window: &mut Window, cx: &mut App)
|
||||
where
|
||||
P: PanelView,
|
||||
{
|
||||
if let Some(Some(root)) = window.root::<Root>() {
|
||||
if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() {
|
||||
let panel = Arc::new(panel);
|
||||
let center = DockItem::panel(panel);
|
||||
|
||||
chatspace.update(cx, |this, cx| {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let status = chat.read(cx).loading();
|
||||
|
||||
if !nostr.read(cx).identity().read(cx).has_public_key() {
|
||||
return div();
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.h_6()
|
||||
.w_full()
|
||||
.child(compose_button())
|
||||
.when(status, |this| {
|
||||
this.child(deferred(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.h_6()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().surface_background)
|
||||
.child(SharedString::from(
|
||||
"Getting messages. This may take a while...",
|
||||
)),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let auto_update = AutoUpdater::global(cx);
|
||||
|
||||
let relay_auth = RelayAuth::global(cx);
|
||||
let pending_requests = relay_auth.read(cx).pending_requests(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let identity = nostr.read(cx).identity();
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.map(|this| match auto_update.read(cx).status.as_ref() {
|
||||
AutoUpdateStatus::Checking => this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Checking for Coop updates...")),
|
||||
),
|
||||
AutoUpdateStatus::Installing => this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Installing updates...")),
|
||||
),
|
||||
AutoUpdateStatus::Errored { msg } => this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(msg.as_ref())),
|
||||
),
|
||||
AutoUpdateStatus::Updated => this.child(
|
||||
div()
|
||||
.id("restart")
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Updated. Click to restart"))
|
||||
.on_click(|_ev, _window, cx| {
|
||||
cx.restart();
|
||||
}),
|
||||
),
|
||||
_ => this.child(div()),
|
||||
})
|
||||
.when(pending_requests > 0, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.id("requests")
|
||||
.h_6()
|
||||
.px_2()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().warning_background)
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.hover(|this| this.bg(cx.theme().warning_hover))
|
||||
.active(|this| this.bg(cx.theme().warning_active))
|
||||
.child(SharedString::from(format!(
|
||||
"You have {} pending authentication requests",
|
||||
pending_requests
|
||||
)))
|
||||
.on_click(move |_ev, window, cx| {
|
||||
relay_auth.update(cx, |this, cx| {
|
||||
this.re_ask(window, cx);
|
||||
});
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when_some(identity.read(cx).public_key, |this, public_key| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
|
||||
let keystore = KeyStore::global(cx);
|
||||
let is_using_file_keystore = keystore.read(cx).is_using_file_keystore();
|
||||
|
||||
let keyring_label = if is_using_file_keystore {
|
||||
SharedString::from("Disabled")
|
||||
} else {
|
||||
SharedString::from("Enabled")
|
||||
};
|
||||
|
||||
this.child(
|
||||
Button::new("user")
|
||||
.small()
|
||||
.reverse()
|
||||
.transparent()
|
||||
.icon(IconName::CaretDown)
|
||||
.child(Avatar::new(profile.avatar()).size(rems(1.45)))
|
||||
.popup_menu(move |this, _window, _cx| {
|
||||
this.label(profile.name())
|
||||
.menu_with_icon(
|
||||
"Profile",
|
||||
IconName::EmojiFill,
|
||||
Box::new(ViewProfile),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Messaging Relays",
|
||||
IconName::Server,
|
||||
Box::new(ViewRelays),
|
||||
)
|
||||
.separator()
|
||||
.label(SharedString::from("Keyring Service"))
|
||||
.menu_with_icon_and_disabled(
|
||||
keyring_label.clone(),
|
||||
IconName::Encryption,
|
||||
Box::new(KeyringPopup),
|
||||
!is_using_file_keystore,
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon("Dark Mode", IconName::Sun, Box::new(DarkMode))
|
||||
.menu_with_icon("Themes", IconName::Moon, Box::new(Themes))
|
||||
.menu_with_icon("Settings", IconName::Settings, Box::new(Settings))
|
||||
.menu_with_icon("Sign Out", IconName::Logout, Box::new(Logout))
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_center(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let entity = cx.entity().downgrade();
|
||||
let panel = self.dock.read(cx).items.view();
|
||||
let title = panel.title(cx);
|
||||
let id = panel.panel_id(cx);
|
||||
|
||||
if id == "Onboarding" {
|
||||
return div();
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.text_sm()
|
||||
.child(
|
||||
div().flex_1().child(
|
||||
Button::new("back")
|
||||
.icon(IconName::ArrowLeft)
|
||||
.small()
|
||||
.ghost_alt()
|
||||
.rounded()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.set_onboarding_layout(window, cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1().child(title))
|
||||
.child(div().flex_1())
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ChatSpace {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
let left = self.titlebar_left(window, cx).into_any_element();
|
||||
let right = self.titlebar_right(window, cx).into_any_element();
|
||||
let center = self.titlebar_center(cx).into_any_element();
|
||||
let single_panel = self.dock.read(cx).items.panel_ids(cx).is_empty();
|
||||
|
||||
// Update title bar children
|
||||
self.title_bar.update(cx, |this, _cx| {
|
||||
if single_panel {
|
||||
this.set_children(vec![center]);
|
||||
} else {
|
||||
this.set_children(vec![left, right]);
|
||||
}
|
||||
});
|
||||
|
||||
div()
|
||||
.id(SharedString::from("chatspace"))
|
||||
.on_action(cx.listener(Self::on_settings))
|
||||
.on_action(cx.listener(Self::on_profile))
|
||||
.on_action(cx.listener(Self::on_relays))
|
||||
.on_action(cx.listener(Self::on_dark_mode))
|
||||
.on_action(cx.listener(Self::on_themes))
|
||||
.on_action(cx.listener(Self::on_sign_out))
|
||||
.on_action(cx.listener(Self::on_open_pubkey))
|
||||
.on_action(cx.listener(Self::on_copy_pubkey))
|
||||
.on_action(cx.listener(Self::on_keyring))
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(self.title_bar.clone())
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
)
|
||||
// Notifications
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
.children(modal_layer)
|
||||
}
|
||||
}
|
||||
1
crates/coop/src/dialogs/mod.rs
Normal file
1
crates/coop/src/dialogs/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod screening;
|
||||
@@ -1,454 +1,511 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{nip05_verify, shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
|
||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
pub struct Screening {
|
||||
profile: Person,
|
||||
verified: bool,
|
||||
followed: bool,
|
||||
last_active: Option<Timestamp>,
|
||||
mutual_contacts: Vec<Profile>,
|
||||
_tasks: SmallVec<[Task<()>; 3]>,
|
||||
}
|
||||
|
||||
impl Screening {
|
||||
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
let contact_check: Task<Result<(bool, Vec<Profile>), Error>> = cx.background_spawn({
|
||||
let client = nostr.read(cx).client();
|
||||
async move {
|
||||
let signer = client.signer().await?;
|
||||
let signer_pubkey = signer.get_public_key().await?;
|
||||
|
||||
// Check if user is in contact list
|
||||
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
|
||||
let followed = contacts.unwrap_or_default().contains(&public_key);
|
||||
|
||||
// Check mutual contacts
|
||||
let contact_list = Filter::new().kind(Kind::ContactList).pubkey(public_key);
|
||||
let mut mutual_contacts = vec![];
|
||||
|
||||
if let Ok(events) = client.database().query(contact_list).await {
|
||||
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
|
||||
if let Ok(metadata) = client.database().metadata(event.pubkey).await {
|
||||
let profile = Profile::new(event.pubkey, metadata.unwrap_or_default());
|
||||
mutual_contacts.push(profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((followed, mutual_contacts))
|
||||
}
|
||||
});
|
||||
|
||||
let activity_check = cx.background_spawn(async move {
|
||||
let filter = Filter::new().author(public_key).limit(1);
|
||||
let mut activity: Option<Timestamp> = None;
|
||||
|
||||
if let Ok(mut stream) = client
|
||||
.stream_events_from(BOOTSTRAP_RELAYS, filter, Duration::from_secs(2))
|
||||
.await
|
||||
{
|
||||
while let Some((_url, event)) = stream.next().await {
|
||||
if let Ok(event) = event {
|
||||
activity = Some(event.created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activity
|
||||
});
|
||||
|
||||
let addr_check = if let Some(address) = profile.metadata().nip05 {
|
||||
Some(Tokio::spawn(cx, async move {
|
||||
nip05_verify(public_key, &address).await.unwrap_or(false)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tasks.push(
|
||||
// Run the contact check in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok((followed, mutual_contacts)) = contact_check.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.followed = followed;
|
||||
this.mutual_contacts = mutual_contacts;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Run the activity check in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let active = activity_check.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.last_active = active;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Run the NIP-05 verification in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Some(task) = addr_check {
|
||||
if let Ok(verified) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.verified = verified;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
profile,
|
||||
verified: false,
|
||||
followed: false,
|
||||
last_active: None,
|
||||
mutual_contacts: vec![],
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn address(&self, _cx: &Context<Self>) -> Option<String> {
|
||||
self.profile.metadata().nip05
|
||||
}
|
||||
|
||||
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
|
||||
let Ok(bech32) = self.profile.public_key().to_bech32();
|
||||
cx.open_url(&format!("https://njump.me/{bech32}"));
|
||||
}
|
||||
|
||||
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.profile.public_key();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let tag = Tag::public_key_report(public_key, Report::Impersonation);
|
||||
let event = EventBuilder::report(vec![tag], "").sign(&signer).await?;
|
||||
|
||||
// Send the report to the public relays
|
||||
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
window.close_modal(cx);
|
||||
window.push_notification("Report submitted successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let contacts = self.mutual_contacts.clone();
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
let contacts = contacts.clone();
|
||||
let total = contacts.len();
|
||||
|
||||
this.title(SharedString::from("Mutual contacts")).child(
|
||||
v_flex().gap_1().pb_4().child(
|
||||
uniform_list("contacts", total, move |range, _window, cx| {
|
||||
let mut items = Vec::with_capacity(total);
|
||||
|
||||
for ix in range {
|
||||
if let Some(contact) = contacts.get(ix) {
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_11()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_1p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().elevated_surface_background)
|
||||
})
|
||||
.child(Avatar::new(contact.avatar()).size(rems(1.75)))
|
||||
.child(contact.display_name()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
.h(px(300.)),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Screening {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let shorten_pubkey = shorten_pubkey(self.profile.public_key(), 8);
|
||||
let total_mutuals = self.mutual_contacts.len();
|
||||
let last_active = self.last_active.map(|_| true);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(self.profile.avatar()).size(rems(4.)))
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(self.profile.name()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.p_1()
|
||||
.flex_1()
|
||||
.h_7()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().surface_background)
|
||||
.text_sm()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_center()
|
||||
.line_height(relative(1.))
|
||||
.child(shorten_pubkey),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("njump")
|
||||
.label("View on njump.me")
|
||||
.secondary()
|
||||
.small()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.open_njump(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("report")
|
||||
.tooltip("Report as a scam or impostor")
|
||||
.icon(IconName::Report)
|
||||
.danger()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.report(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(status_badge(Some(self.followed), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Contact"))
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.followed {
|
||||
SharedString::from("This person is one of your contacts.")
|
||||
} else {
|
||||
SharedString::from("This person is not one of your contacts.")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(status_badge(last_active, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(SharedString::from("Activity on Public Relays"))
|
||||
.child(
|
||||
Button::new("active")
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.tooltip("This may be inaccurate if the user only publishes to their private relays."),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.map(|this| {
|
||||
if let Some(date) = self.last_active {
|
||||
this.child(SharedString::from(format!(
|
||||
"Last active: {}.",
|
||||
date.to_human_time()
|
||||
)))
|
||||
} else {
|
||||
this.child(SharedString::from("This person hasn't had any activity."))
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(self.verified), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child({
|
||||
if let Some(addr) = self.address(cx) {
|
||||
SharedString::from(format!("{} validation", addr))
|
||||
} else {
|
||||
SharedString::from("Friendly Address (NIP-05) validation")
|
||||
}
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.address(cx).is_some() {
|
||||
if self.verified {
|
||||
SharedString::from("The address matches the user's public key.")
|
||||
} else {
|
||||
SharedString::from("The address does not match the user's public key.")
|
||||
}
|
||||
} else {
|
||||
SharedString::from("This person has not set up their friendly address")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(total_mutuals > 0), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(SharedString::from("Mutual contacts"))
|
||||
.child(
|
||||
Button::new("mutuals")
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.mutual_contacts(window, cx);
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if total_mutuals > 0 {
|
||||
SharedString::from(format!(
|
||||
"You have {} mutual contacts with this person.",
|
||||
total_mutuals
|
||||
))
|
||||
} else {
|
||||
SharedString::from("You don't have any mutual contacts with this person.")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_badge(status: Option<bool>, cx: &App) -> Div {
|
||||
h_flex()
|
||||
.size_6()
|
||||
.justify_center()
|
||||
.flex_shrink_0()
|
||||
.map(|this| {
|
||||
if let Some(status) = status {
|
||||
this.child(Icon::new(IconName::CheckCircleFill).small().text_color({
|
||||
if status {
|
||||
cx.theme().icon_accent
|
||||
} else {
|
||||
cx.theme().icon_muted
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
this.child(Indicator::new().small())
|
||||
}
|
||||
})
|
||||
}
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use common::{shorten_pubkey, RenderedTimestamp};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrAddress, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt, WindowExtension};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
|
||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
/// Screening
|
||||
pub struct Screening {
|
||||
/// Public Key of the person being screened.
|
||||
public_key: PublicKey,
|
||||
|
||||
/// Whether the person's address is verified.
|
||||
verified: bool,
|
||||
|
||||
/// Whether the person is followed by current user.
|
||||
followed: bool,
|
||||
|
||||
/// Last time the person was active.
|
||||
last_active: Option<Timestamp>,
|
||||
|
||||
/// All mutual contacts of the person being screened.
|
||||
mutual_contacts: Vec<PublicKey>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: SmallVec<[Task<()>; 3]>,
|
||||
}
|
||||
|
||||
impl Screening {
|
||||
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
cx.defer_in(window, move |this, _window, cx| {
|
||||
this.check_contact(cx);
|
||||
this.check_wot(cx);
|
||||
this.check_last_activity(cx);
|
||||
this.verify_identifier(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
public_key,
|
||||
verified: false,
|
||||
followed: false,
|
||||
last_active: None,
|
||||
mutual_contacts: vec![],
|
||||
tasks: smallvec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn check_contact(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let signer_pubkey = signer.get_public_key().await?;
|
||||
|
||||
// Check if user is in contact list
|
||||
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
|
||||
let followed = contacts.unwrap_or_default().contains(&public_key);
|
||||
|
||||
Ok(followed)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await.unwrap_or(false);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.followed = result;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn check_wot(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let signer_pubkey = signer.get_public_key().await?;
|
||||
|
||||
// Check mutual contacts
|
||||
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
|
||||
let mut mutual_contacts = vec![];
|
||||
|
||||
if let Ok(events) = client.database().query(filter).await {
|
||||
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
|
||||
mutual_contacts.push(event.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mutual_contacts)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.mutual_contacts = contacts;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch mutual contacts: {}", e);
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
fn check_last_activity(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Option<Timestamp>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new().author(public_key).limit(1);
|
||||
let mut activity: Option<Timestamp> = None;
|
||||
|
||||
// Construct target for subscription
|
||||
let target = BOOTSTRAP_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, vec![filter.clone()]))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
if let Ok(mut stream) = client
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await
|
||||
{
|
||||
while let Some((_url, event)) = stream.next().await {
|
||||
if let Ok(event) = event {
|
||||
activity = Some(event.created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activity
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.last_active = result;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn verify_identifier(&mut self, cx: &mut Context<Self>) {
|
||||
let http_client = cx.http_client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
// Skip if the user doesn't have a NIP-05 identifier
|
||||
let Some(address) = self.address(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<bool, Error>> =
|
||||
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await.unwrap_or(false);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.verified = result;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn profile(&self, cx: &Context<Self>) -> Person {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
persons.read(cx).get(&self.public_key, cx)
|
||||
}
|
||||
|
||||
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
|
||||
self.profile(cx)
|
||||
.metadata()
|
||||
.nip05
|
||||
.and_then(|addr| Nip05Address::parse(&addr).ok())
|
||||
}
|
||||
|
||||
fn open_njump(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(bech32) = self.profile(cx).public_key().to_bech32();
|
||||
cx.open_url(&format!("https://njump.me/{bech32}"));
|
||||
}
|
||||
|
||||
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let tag = Tag::public_key_report(public_key, Report::Impersonation);
|
||||
let builder = EventBuilder::report(vec![tag], "");
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Send the report to the public relays
|
||||
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
window.close_modal(cx);
|
||||
window.push_notification("Report submitted successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let contacts = self.mutual_contacts.clone();
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
let contacts = contacts.clone();
|
||||
let total = contacts.len();
|
||||
|
||||
this.title(SharedString::from("Mutual contacts")).child(
|
||||
v_flex().gap_1().pb_4().child(
|
||||
uniform_list("contacts", total, move |range, _window, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let mut items = Vec::with_capacity(total);
|
||||
|
||||
for ix in range {
|
||||
let Some(contact) = contacts.get(ix) else {
|
||||
continue;
|
||||
};
|
||||
let profile = persons.read(cx).get(contact, cx);
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_11()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_1p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.child(Avatar::new(profile.avatar()).size(rems(1.75)))
|
||||
.child(profile.name()),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
.h(px(300.)),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Screening {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let profile = self.profile(cx);
|
||||
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
|
||||
|
||||
let total_mutuals = self.mutual_contacts.len();
|
||||
let last_active = self.last_active.map(|_| true);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(profile.avatar()).size(rems(4.)))
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(profile.name()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.p_1()
|
||||
.flex_1()
|
||||
.h_7()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().surface_background)
|
||||
.text_sm()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_center()
|
||||
.line_height(relative(1.))
|
||||
.child(shorten_pubkey),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("njump")
|
||||
.label("View on njump.me")
|
||||
.secondary()
|
||||
.small()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.open_njump(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("report")
|
||||
.tooltip("Report as a scam or impostor")
|
||||
.icon(IconName::Boom)
|
||||
.danger()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.report(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(status_badge(Some(self.followed), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Contact"))
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.followed {
|
||||
SharedString::from("This person is one of your contacts.")
|
||||
} else {
|
||||
SharedString::from("This person is not one of your contacts.")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(status_badge(last_active, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(SharedString::from("Activity on Public Relays"))
|
||||
.child(
|
||||
Button::new("active")
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.tooltip("This may be inaccurate if the user only publishes to their private relays."),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.map(|this| {
|
||||
if let Some(date) = self.last_active {
|
||||
this.child(SharedString::from(format!(
|
||||
"Last active: {}.",
|
||||
date.to_human_time()
|
||||
)))
|
||||
} else {
|
||||
this.child(SharedString::from("This person hasn't had any activity."))
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(self.verified), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child({
|
||||
if let Some(addr) = self.address(cx) {
|
||||
SharedString::from(format!("{} validation", addr))
|
||||
} else {
|
||||
SharedString::from("Friendly Address (NIP-05) validation")
|
||||
}
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.address(cx).is_some() {
|
||||
if self.verified {
|
||||
SharedString::from("The address matches the user's public key.")
|
||||
} else {
|
||||
SharedString::from("The address does not match the user's public key.")
|
||||
}
|
||||
} else {
|
||||
SharedString::from("This person has not set up their friendly address")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(total_mutuals > 0), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(SharedString::from("Mutual contacts"))
|
||||
.child(
|
||||
Button::new("mutuals")
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.mutual_contacts(window, cx);
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if total_mutuals > 0 {
|
||||
SharedString::from(format!(
|
||||
"You have {} mutual contacts with this person.",
|
||||
total_mutuals
|
||||
))
|
||||
} else {
|
||||
SharedString::from("You don't have any mutual contacts with this person.")
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_badge(status: Option<bool>, cx: &App) -> Div {
|
||||
h_flex()
|
||||
.size_6()
|
||||
.justify_center()
|
||||
.flex_shrink_0()
|
||||
.map(|this| {
|
||||
if let Some(status) = status {
|
||||
this.child(Icon::new(IconName::CheckCircle).small().text_color({
|
||||
if status {
|
||||
cx.theme().icon_accent
|
||||
} else {
|
||||
cx.theme().icon_muted
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
this.child(Indicator::new().small())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use common::BUNKER_TIMEOUT;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||
};
|
||||
use key_store::{KeyItem, KeyStore};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::notification::Notification;
|
||||
use ui::{v_flex, ContextModal, Disableable, StyledExt};
|
||||
|
||||
use crate::actions::CoopAuthUrlHandler;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
|
||||
cx.new(|cx| Login::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Login {
|
||||
key_input: Entity<InputState>,
|
||||
pass_input: Entity<InputState>,
|
||||
error: Entity<Option<SharedString>>,
|
||||
countdown: Entity<Option<u64>>,
|
||||
require_password: bool,
|
||||
logging_in: bool,
|
||||
|
||||
/// Panel
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Login {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let key_input = cx.new(|cx| InputState::new(window, cx));
|
||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
|
||||
let error = cx.new(|_| None);
|
||||
let countdown = cx.new(|_| None);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to key input events and process login when the user presses enter
|
||||
cx.subscribe_in(&key_input, window, |this, input, event, window, cx| {
|
||||
match event {
|
||||
InputEvent::PressEnter { .. } => {
|
||||
this.login(window, cx);
|
||||
}
|
||||
InputEvent::Change => {
|
||||
if input.read(cx).value().starts_with("ncryptsec1") {
|
||||
this.require_password = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
key_input,
|
||||
pass_input,
|
||||
error,
|
||||
countdown,
|
||||
name: "Welcome Back".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
logging_in: false,
|
||||
require_password: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.logging_in {
|
||||
return;
|
||||
};
|
||||
|
||||
// Prevent duplicate login requests
|
||||
self.set_logging_in(true, cx);
|
||||
|
||||
let value = self.key_input.read(cx).value();
|
||||
let password = self.pass_input.read(cx).value();
|
||||
|
||||
if value.starts_with("bunker://") {
|
||||
self.login_with_bunker(&value, window, cx);
|
||||
} else if value.starts_with("ncryptsec1") {
|
||||
self.login_with_password(&value, &password, cx);
|
||||
} else if value.starts_with("nsec1") {
|
||||
if let Ok(secret) = SecretKey::parse(&value) {
|
||||
let keys = Keys::new(secret);
|
||||
self.login_with_keys(keys, cx);
|
||||
} else {
|
||||
self.set_error("Invalid", cx);
|
||||
}
|
||||
} else {
|
||||
self.set_error("Invalid", cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(uri) = NostrConnectUri::parse(content) else {
|
||||
self.set_error("Bunker is not valid", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let app_keys = Keys::generate();
|
||||
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
|
||||
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
// Start countdown
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
for i in (0..=BUNKER_TIMEOUT).rev() {
|
||||
if i == 0 {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(None, cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(Some(i), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle connection
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = signer.bunker_uri().await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(uri) => {
|
||||
this.save_connection(&app_keys, &uri, window, cx);
|
||||
this.connect(signer, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn save_connection(
|
||||
&mut self,
|
||||
keys: &Keys,
|
||||
uri: &NostrConnectUri,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||
let username = keys.public_key().to_hex();
|
||||
let secret = keys.secret_key().to_secret_bytes();
|
||||
let mut clean_uri = uri.to_string();
|
||||
|
||||
// Clear the secret parameter in the URI if it exists
|
||||
if let Some(s) = uri.secret() {
|
||||
clean_uri = clean_uri.replace(s, "");
|
||||
}
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let user_url = KeyItem::User.to_string();
|
||||
let bunker_url = KeyItem::Bunker.to_string();
|
||||
let user_password = clean_uri.into_bytes();
|
||||
|
||||
// Write bunker uri to keyring for further connection
|
||||
if let Err(e) = keystore
|
||||
.write_credentials(&user_url, "bunker", &user_password, cx)
|
||||
.await
|
||||
{
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Write the app keys for further connection
|
||||
if let Err(e) = keystore
|
||||
.write_credentials(&bunker_url, &username, &secret, cx)
|
||||
.await
|
||||
{
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn connect(&mut self, signer: NostrConnect, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context<Self>) {
|
||||
if pwd.is_empty() {
|
||||
self.set_error("Password is required", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
|
||||
self.set_error("Secret Key is invalid", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let password = pwd.to_owned();
|
||||
|
||||
// Decrypt in the background to ensure it doesn't block the UI
|
||||
let task = cx.background_spawn(async move {
|
||||
if let Ok(content) = enc.decrypt(&password) {
|
||||
Ok(Keys::new(content))
|
||||
} else {
|
||||
Err(anyhow!("Invalid password"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
match result {
|
||||
Ok(keys) => {
|
||||
this.login_with_keys(keys, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
this.set_error(e.to_string(), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context<Self>) {
|
||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||
let username = keys.public_key().to_hex();
|
||||
let secret = keys.secret_key().to_secret_hex().into_bytes();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let bunker_url = KeyItem::User.to_string();
|
||||
|
||||
// Write the app keys for further connection
|
||||
if let Err(e) = keystore
|
||||
.write_credentials(&bunker_url, &username, &secret, cx)
|
||||
.await
|
||||
{
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
this.update(cx, |_this, cx| {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||
where
|
||||
S: Into<SharedString>,
|
||||
{
|
||||
// Reset the log in state
|
||||
self.set_logging_in(false, cx);
|
||||
|
||||
// Reset the countdown
|
||||
self.set_countdown(None, cx);
|
||||
|
||||
// Update error message
|
||||
self.error.update(cx, |this, cx| {
|
||||
*this = Some(message.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Clear the error message after 3 secs
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.error.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.logging_in = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
||||
self.countdown.update(cx, |this, cx| {
|
||||
*this = i;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Login {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Login {}
|
||||
|
||||
impl Focusable for Login {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Login {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.relative()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
v_flex()
|
||||
.w_96()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.text_xl()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child(SharedString::from("Continue with Private Key or Bunker")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("nsec or bunker://")
|
||||
.child(TextInput::new(&self.key_input)),
|
||||
)
|
||||
.when(self.require_password, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Password:")
|
||||
.child(TextInput::new(&self.pass_input)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Continue")
|
||||
.primary()
|
||||
.loading(self.logging_in)
|
||||
.disabled(self.logging_in)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.login(window, cx);
|
||||
})),
|
||||
)
|
||||
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(format!(
|
||||
"Approve connection request from your signer in {} seconds",
|
||||
i
|
||||
))),
|
||||
)
|
||||
})
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,118 +1,143 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use assets::Assets;
|
||||
use common::{APP_ID, CLIENT_NAME};
|
||||
use gpui::{
|
||||
point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString,
|
||||
actions, point, px, size, App, AppContext, Bounds, KeyBinding, Menu, MenuItem, SharedString,
|
||||
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind,
|
||||
WindowOptions,
|
||||
};
|
||||
use gpui_platform::application;
|
||||
use state::{APP_ID, CLIENT_NAME};
|
||||
use ui::Root;
|
||||
|
||||
use crate::actions::{load_embedded_fonts, quit, Quit};
|
||||
|
||||
mod actions;
|
||||
mod chatspace;
|
||||
mod login;
|
||||
mod new_identity;
|
||||
mod dialogs;
|
||||
mod panels;
|
||||
mod sidebar;
|
||||
mod user;
|
||||
mod views;
|
||||
mod workspace;
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
|
||||
fn main() {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Initialize the Application
|
||||
let app = Application::new()
|
||||
.with_assets(Assets)
|
||||
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
|
||||
|
||||
// Run application
|
||||
app.run(move |cx| {
|
||||
// Load embedded fonts in assets/fonts
|
||||
load_embedded_fonts(cx);
|
||||
application()
|
||||
.with_assets(Assets)
|
||||
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()))
|
||||
.run(move |cx| {
|
||||
// Load embedded fonts in assets/fonts
|
||||
load_embedded_fonts(cx);
|
||||
|
||||
// Register the `quit` function
|
||||
cx.on_action(quit);
|
||||
// Register the `quit` function
|
||||
cx.on_action(quit);
|
||||
|
||||
// Register the `quit` function with CMD+Q (macOS)
|
||||
#[cfg(target_os = "macos")]
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
// Register the `quit` function with CMD+Q (macOS)
|
||||
#[cfg(target_os = "macos")]
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
|
||||
// Register the `quit` function with Super+Q (others)
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
|
||||
// Register the `quit` function with Super+Q (others)
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
|
||||
|
||||
// Set menu items
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Coop".into(),
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
// Set menu items
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Coop".into(),
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
|
||||
// Set up the window bounds
|
||||
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
|
||||
// Set up the window bounds
|
||||
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
|
||||
|
||||
// Set up the window options
|
||||
let opts = WindowOptions {
|
||||
window_background: WindowBackgroundAppearance::Opaque,
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
kind: WindowKind::Normal,
|
||||
app_id: Some(APP_ID.to_owned()),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(CLIENT_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
// Set up the window options
|
||||
let opts = WindowOptions {
|
||||
window_background: WindowBackgroundAppearance::Opaque,
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
kind: WindowKind::Normal,
|
||||
app_id: Some(APP_ID.to_owned()),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(CLIENT_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Open a window with default options
|
||||
cx.open_window(opts, |window, cx| {
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
// Open a window with default options
|
||||
cx.open_window(opts, |window, cx| {
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
|
||||
cx.new(|cx| {
|
||||
// Initialize the tokio runtime
|
||||
gpui_tokio::init(cx);
|
||||
cx.new(|cx| {
|
||||
// Initialize the tokio runtime
|
||||
gpui_tokio::init(cx);
|
||||
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
// Initialize theme registry
|
||||
theme::init(cx);
|
||||
// Initialize theme registry
|
||||
theme::init(cx);
|
||||
|
||||
// Initialize backend for keys storage
|
||||
key_store::init(cx);
|
||||
// Initialize backend for keys storage
|
||||
key_store::init(cx);
|
||||
|
||||
// Initialize the nostr client
|
||||
state::init(cx);
|
||||
// Initialize the nostr client
|
||||
state::init(window, cx);
|
||||
|
||||
// Initialize device signer
|
||||
//
|
||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
device::init(cx);
|
||||
// Initialize device signer
|
||||
//
|
||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
device::init(window, cx);
|
||||
|
||||
// Initialize settings
|
||||
settings::init(cx);
|
||||
// Initialize settings
|
||||
settings::init(cx);
|
||||
|
||||
// Initialize relay auth registry
|
||||
relay_auth::init(window, cx);
|
||||
// Initialize relay auth registry
|
||||
relay_auth::init(window, cx);
|
||||
|
||||
// Initialize app registry
|
||||
chat::init(cx);
|
||||
// Initialize app registry
|
||||
chat::init(window, cx);
|
||||
|
||||
// Initialize person registry
|
||||
person::init(cx);
|
||||
// Initialize person registry
|
||||
person::init(cx);
|
||||
|
||||
// Initialize auto update
|
||||
auto_update::init(cx);
|
||||
// Initialize auto update
|
||||
auto_update::init(cx);
|
||||
|
||||
// Root Entity
|
||||
Root::new(chatspace::init(window, cx).into(), window, cx)
|
||||
// Root Entity
|
||||
Root::new(workspace::init(window, cx).into(), window, cx)
|
||||
})
|
||||
})
|
||||
})
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
});
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
});
|
||||
}
|
||||
|
||||
fn load_embedded_fonts(cx: &App) {
|
||||
let asset_source = cx.asset_source();
|
||||
let font_paths = asset_source.list("fonts").unwrap();
|
||||
let embedded_fonts = Mutex::new(vec![]);
|
||||
let executor = cx.background_executor();
|
||||
|
||||
cx.foreground_executor().block_on(executor.scoped(|scope| {
|
||||
for font_path in &font_paths {
|
||||
if !font_path.ends_with(".ttf") {
|
||||
continue;
|
||||
}
|
||||
|
||||
scope.spawn(async {
|
||||
let font_bytes = asset_source.load(font_path.as_str()).unwrap().unwrap();
|
||||
embedded_fonts.lock().unwrap().push(font_bytes);
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
cx.text_system()
|
||||
.add_fonts(embedded_fonts.into_inner().unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn quit(_ev: &Quit, cx: &mut App) {
|
||||
log::info!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
}
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::home_dir;
|
||||
use gpui::{
|
||||
div, App, AppContext, ClipboardItem, Context, Entity, IntoElement, ParentElement, Render,
|
||||
SharedString, Styled, Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(keys: &Keys, window: &mut Window, cx: &mut App) -> Entity<Backup> {
|
||||
cx.new(|cx| Backup::new(keys, window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Backup {
|
||||
pubkey_input: Entity<InputState>,
|
||||
secret_input: Entity<InputState>,
|
||||
error: Option<SharedString>,
|
||||
copied: bool,
|
||||
|
||||
// Async operations
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl Backup {
|
||||
pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let Ok(npub) = keys.public_key.to_bech32();
|
||||
let Ok(nsec) = keys.secret_key().to_bech32();
|
||||
|
||||
let pubkey_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.disabled(true)
|
||||
.default_value(npub)
|
||||
});
|
||||
|
||||
let secret_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.disabled(true)
|
||||
.default_value(nsec)
|
||||
});
|
||||
|
||||
Self {
|
||||
pubkey_input,
|
||||
secret_input,
|
||||
error: None,
|
||||
copied: false,
|
||||
_tasks: smallvec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backup(&self, window: &Window, cx: &Context<Self>) -> Task<Result<(), Error>> {
|
||||
let dir = home_dir();
|
||||
let path = cx.prompt_for_new_path(dir, Some("My Nostr Account"));
|
||||
let nsec = self.secret_input.read(cx).value().to_string();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path.await {
|
||||
Ok(Ok(Some(path))) => {
|
||||
if let Err(e) = smol::fs::write(&path, nsec).await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_error(e.to_string(), window, cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::error!("Failed to save backup keys");
|
||||
}
|
||||
};
|
||||
|
||||
Err(anyhow!("Failed to backup keys"))
|
||||
})
|
||||
}
|
||||
|
||||
fn copy(&mut self, value: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let item = ClipboardItem::new_string(value.into());
|
||||
cx.write_to_clipboard(item);
|
||||
|
||||
self.set_copied(true, window, cx);
|
||||
}
|
||||
|
||||
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.copied = status;
|
||||
cx.notify();
|
||||
|
||||
// Reset the copied state after a delay
|
||||
if status {
|
||||
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_copied(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
E: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
cx.notify();
|
||||
|
||||
// Clear the error message after a delay
|
||||
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Backup {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const DESCRIPTION: &str = "In Nostr, your account is defined by a KEY PAIR. These keys are used to sign your messages and identify you.";
|
||||
const WARN: &str = "You must keep the Secret Key in a safe place. If you lose it, you will lose access to your account.";
|
||||
const PK: &str = "Public Key is the address that others will use to find you.";
|
||||
const SK: &str = "Secret Key provides access to your account.";
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(SharedString::from(DESCRIPTION))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Public Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(TextInput::new(&self.pubkey_input).small())
|
||||
.child(
|
||||
Button::new("copy-pubkey")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.ghost_alt()
|
||||
.disabled(self.copied)
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.copy(this.pubkey_input.read(cx).value(), window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(PK)),
|
||||
),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Secret Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(TextInput::new(&self.secret_input).small())
|
||||
.child(
|
||||
Button::new("copy-secret")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.ghost_alt()
|
||||
.disabled(self.copied)
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.copy(this.secret_input.read(cx).value(), window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(SK)),
|
||||
),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(SharedString::from(WARN)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS};
|
||||
use gpui::{
|
||||
rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use key_store::{KeyItem, KeyStore};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use smol::fs;
|
||||
use state::NostrRegistry;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable};
|
||||
|
||||
mod backup;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
||||
cx.new(|cx| NewAccount::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NewAccount {
|
||||
name_input: Entity<InputState>,
|
||||
avatar_input: Entity<InputState>,
|
||||
temp_keys: Entity<Keys>,
|
||||
uploading: bool,
|
||||
submitting: bool,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl NewAccount {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let temp_keys = cx.new(|_| Keys::generate());
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||
let avatar_input = cx.new(|cx| InputState::new(window, cx));
|
||||
|
||||
Self {
|
||||
name_input,
|
||||
avatar_input,
|
||||
temp_keys,
|
||||
uploading: false,
|
||||
submitting: false,
|
||||
name: "Create a new identity".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.submitting(true, cx);
|
||||
|
||||
let keys = self.temp_keys.read(cx).clone();
|
||||
let view = backup::init(&keys, window, cx);
|
||||
let weak_view = view.downgrade();
|
||||
let current_view = cx.entity().downgrade();
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
let weak_view = weak_view.clone();
|
||||
let current_view = current_view.clone();
|
||||
|
||||
modal
|
||||
.alert()
|
||||
.title(SharedString::from(
|
||||
"Backup to avoid losing access to your account",
|
||||
))
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text("Download"))
|
||||
.on_ok(move |_, window, cx| {
|
||||
weak_view
|
||||
.update(cx, |this, cx| {
|
||||
let view = current_view.clone();
|
||||
let task = this.backup(window, cx);
|
||||
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
view.update_in(cx, |this, window, cx| {
|
||||
this.set_signer(window, cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to backup: {e}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
// true to close the modal
|
||||
false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_signer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let keys = self.temp_keys.read(cx).clone();
|
||||
let username = keys.public_key().to_hex();
|
||||
let secret = keys.secret_key().to_secret_hex().into_bytes();
|
||||
|
||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||
let name = self.name_input.read(cx).value().to_string();
|
||||
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
|
||||
|
||||
if let Ok(url) = Url::parse(&avatar) {
|
||||
metadata = metadata.picture(url);
|
||||
};
|
||||
|
||||
// Close all modals if available
|
||||
window.close_all_modals(cx);
|
||||
|
||||
// Set the client's signer with the current keys
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = keys.clone();
|
||||
let nip65_relays = default_nip65_relays();
|
||||
let nip17_relays = default_nip17_relays();
|
||||
|
||||
// Construct a NIP-65 event
|
||||
let event = EventBuilder::new(Kind::RelayList, "")
|
||||
.tags(
|
||||
nip65_relays
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|(url, metadata)| Tag::relay_metadata(url, metadata)),
|
||||
)
|
||||
.sign(&signer)
|
||||
.await?;
|
||||
|
||||
// Set NIP-65 relays
|
||||
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
||||
|
||||
// Extract only write relays
|
||||
let write_relays: Vec<RelayUrl> = nip65_relays
|
||||
.iter()
|
||||
.filter_map(|(url, metadata)| {
|
||||
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Ensure relays are connected
|
||||
for url in write_relays.iter() {
|
||||
client.add_relay(url).await?;
|
||||
client.connect_relay(url).await?;
|
||||
}
|
||||
|
||||
// Construct a NIP-17 event
|
||||
let event = EventBuilder::new(Kind::InboxRelays, "")
|
||||
.tags(nip17_relays.iter().cloned().map(Tag::relay))
|
||||
.sign(&signer)
|
||||
.await?;
|
||||
|
||||
// Set NIP-17 relays
|
||||
client.send_event_to(&write_relays, &event).await?;
|
||||
|
||||
// Construct a metadata event
|
||||
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
|
||||
|
||||
// Send metadata event to both write relays and bootstrap relays
|
||||
client.send_event_to(&write_relays, &event).await?;
|
||||
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
||||
|
||||
// Update the client's signer with the current keys
|
||||
client.set_signer(keys).await;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let url = KeyItem::User.to_string();
|
||||
|
||||
// Write the app keys for further connection
|
||||
keystore
|
||||
.write_credentials(&url, &username, &secret, cx)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
if let Err(e) = task.await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.submitting(false, cx);
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.uploading(true, cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get the user's configured NIP96 server
|
||||
let nip96_server = AppSettings::get_file_server(cx);
|
||||
|
||||
// Open native file dialog
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: None,
|
||||
});
|
||||
|
||||
let task = Tokio::spawn(cx, async move {
|
||||
match paths.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(path) = paths.pop() {
|
||||
let file = fs::read(path).await?;
|
||||
let url = nip96_upload(&client, &nip96_server, file).await?;
|
||||
|
||||
Ok(url)
|
||||
} else {
|
||||
Err(anyhow!("Path not found"))
|
||||
}
|
||||
}
|
||||
_ => Err(anyhow!("Error")),
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(Ok(url)) => {
|
||||
this.avatar_input.update(cx, |this, cx| {
|
||||
this.set_value(url.to_string(), window, cx);
|
||||
});
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to upload avatar: {e}");
|
||||
}
|
||||
};
|
||||
this.uploading(false, cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.submitting = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for NewAccount {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for NewAccount {}
|
||||
|
||||
impl Focusable for NewAccount {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NewAccount {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = self.avatar_input.read(cx).value();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
v_flex()
|
||||
.w_96()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.h_40()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_4()
|
||||
.child(Avatar::new(avatar).size(rems(4.25)))
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::PlusCircleFill)
|
||||
.label("Add an avatar")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.disabled(self.uploading)
|
||||
//.loading(self.uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::from("What should people call you?"))
|
||||
.child(
|
||||
TextInput::new(&self.name_input)
|
||||
.disabled(self.submitting)
|
||||
.small(),
|
||||
),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Continue")
|
||||
.primary()
|
||||
.loading(self.submitting)
|
||||
.disabled(self.submitting || self.uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.create(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
127
crates/coop/src/panels/connect.rs
Normal file
127
crates/coop/src/panels/connect.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::TextUtils;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, px, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString, Styled, Task,
|
||||
Window,
|
||||
};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::dock_area::ClosePanel;
|
||||
use ui::notification::Notification;
|
||||
use ui::{v_flex, StyledExt, WindowExtension};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ConnectPanel> {
|
||||
cx.new(|cx| ConnectPanel::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct ConnectPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// QR Code
|
||||
qr_code: Option<Arc<Image>>,
|
||||
|
||||
/// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl ConnectPanel {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let weak_state = nostr.downgrade();
|
||||
let (signer, uri) = nostr.read(cx).client_connect(None);
|
||||
|
||||
// Generate a QR code for quick connection
|
||||
let qr_code = uri.to_string().to_qr();
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Wait for nostr connect
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
let result = signer.bunker_uri().await;
|
||||
|
||||
weak_state
|
||||
.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(uri) => {
|
||||
this.persist_bunker(uri, cx);
|
||||
this.set_signer(signer, true, cx);
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Nostr Connect".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
qr_code,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for ConnectPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for ConnectPanel {}
|
||||
|
||||
impl Focusable for ConnectPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ConnectPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.gap_10()
|
||||
.child(
|
||||
v_flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from("Continue with Nostr Connect")),
|
||||
)
|
||||
.child(div().text_sm().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from("Use Nostr Connect apps to scan the code"),
|
||||
)),
|
||||
)
|
||||
.when_some(self.qr_code.as_ref(), |this, qr| {
|
||||
this.child(
|
||||
img(qr.clone())
|
||||
.size(px(256.))
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
297
crates/coop/src/panels/greeter.rs
Normal file
297
crates/coop/src/panels/greeter.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use chat::ChatRegistry;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use state::{NostrRegistry, RelayState};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
use crate::panels::{connect, import, messaging_relays, profile, relay_list};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
||||
cx.new(|cx| GreeterPanel::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct GreeterPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl GreeterPanel {
|
||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
||||
Self {
|
||||
name: "Onboarding".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
if let Some(public_key) = signer.public_key() {
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
cx.update(|window, cx| {
|
||||
Workspace::add_panel(
|
||||
profile::init(public_key, window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for GreeterPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_4()
|
||||
.text_color(cx.theme().text_muted),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for GreeterPanel {}
|
||||
|
||||
impl Focusable for GreeterPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for GreeterPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
||||
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nip17_state = chat.read(cx).relay_state(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let nip65_state = nostr.read(cx).relay_list_state();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let owned = signer.owned();
|
||||
|
||||
let required_actions =
|
||||
nip65_state == RelayState::NotConfigured || nip17_state == RelayState::NotConfigured;
|
||||
|
||||
h_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.h_full()
|
||||
.w_112()
|
||||
.gap_6()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.mb_4()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(TITLE)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(DESCRIPTION)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(required_actions, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Required Actions"))
|
||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.when(nip65_state.not_configured(), |this| {
|
||||
this.child(
|
||||
Button::new("relaylist")
|
||||
.icon(Icon::new(IconName::Relay))
|
||||
.label("Set up relay list")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
relay_list::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(nip17_state.not_configured(), |this| {
|
||||
this.child(
|
||||
Button::new("import")
|
||||
.icon(Icon::new(IconName::Relay))
|
||||
.label("Set up messaging relays")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
messaging_relays::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(!owned, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Use your own identity"))
|
||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("connect")
|
||||
.icon(Icon::new(IconName::Door))
|
||||
.label("Connect account via Nostr Connect")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
connect::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("import")
|
||||
.icon(Icon::new(IconName::Usb))
|
||||
.label("Import a secret key or bunker")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
Workspace::add_panel(
|
||||
import::init(window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Get Started"))
|
||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("backup")
|
||||
.icon(Icon::new(IconName::Shield))
|
||||
.label("Backup account")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start(),
|
||||
)
|
||||
.child(
|
||||
Button::new("profile")
|
||||
.icon(Icon::new(IconName::Profile))
|
||||
.label("Update profile")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.add_profile_panel(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("invite")
|
||||
.icon(Icon::new(IconName::Invite))
|
||||
.label("Invite friends")
|
||||
.ghost()
|
||||
.small()
|
||||
.justify_start(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
371
crates/coop/src/panels/import.rs
Normal file
371
crates/coop/src/panels/import.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{CoopAuthUrlHandler, NostrRegistry};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::dock_area::ClosePanel;
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::notification::Notification;
|
||||
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ImportPanel> {
|
||||
cx.new(|cx| ImportPanel::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Secret key input
|
||||
key_input: Entity<InputState>,
|
||||
|
||||
/// Password input (if required)
|
||||
pass_input: Entity<InputState>,
|
||||
|
||||
/// Error message
|
||||
error: Entity<Option<SharedString>>,
|
||||
|
||||
/// Countdown timer for nostr connect
|
||||
countdown: Entity<Option<u64>>,
|
||||
|
||||
/// Whether the user is currently logging in
|
||||
logging_in: bool,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl ImportPanel {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
|
||||
let error = cx.new(|_| None);
|
||||
let countdown = cx.new(|_| None);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to key input events and process login when the user presses enter
|
||||
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.login(window, cx);
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
key_input,
|
||||
pass_input,
|
||||
error,
|
||||
countdown,
|
||||
name: "Import".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
logging_in: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.logging_in {
|
||||
return;
|
||||
};
|
||||
// Prevent duplicate login requests
|
||||
self.set_logging_in(true, cx);
|
||||
|
||||
let value = self.key_input.read(cx).value();
|
||||
let password = self.pass_input.read(cx).value();
|
||||
|
||||
if value.starts_with("bunker://") {
|
||||
self.login_with_bunker(&value, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if value.starts_with("ncryptsec1") {
|
||||
self.login_with_password(&value, &password, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(secret) = SecretKey::parse(&value) {
|
||||
let keys = Keys::new(secret);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
// Update the signer
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.set_signer(keys, true, cx);
|
||||
});
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
} else {
|
||||
self.set_error("Invalid", cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(uri) = NostrConnectUri::parse(content) else {
|
||||
self.set_error("Bunker is not valid", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let weak_state = nostr.downgrade();
|
||||
|
||||
let app_keys = nostr.read(cx).app_keys();
|
||||
let timeout = Duration::from_secs(30);
|
||||
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
// Start countdown
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
for i in (0..=30).rev() {
|
||||
if i == 0 {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(None, cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(Some(i), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle connection
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
let result = signer.bunker_uri().await;
|
||||
|
||||
weak_state
|
||||
.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(uri) => {
|
||||
this.persist_bunker(uri, cx);
|
||||
this.set_signer(signer, true, cx);
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn login_with_password(
|
||||
&mut self,
|
||||
content: &str,
|
||||
pwd: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if pwd.is_empty() {
|
||||
self.set_error("Password is required", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
|
||||
self.set_error("Secret Key is invalid", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let password = pwd.to_owned();
|
||||
|
||||
// Decrypt in the background to ensure it doesn't block the UI
|
||||
let task = cx.background_spawn(async move {
|
||||
if let Ok(content) = enc.decrypt(&password) {
|
||||
Ok(Keys::new(content))
|
||||
} else {
|
||||
Err(anyhow!("Invalid password"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(keys) => {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
// Update the signer
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.set_signer(keys, true, cx);
|
||||
});
|
||||
// Close the current panel after setting the signer
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
}
|
||||
Err(e) => {
|
||||
this.set_error(e.to_string(), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||
where
|
||||
S: Into<SharedString>,
|
||||
{
|
||||
// Reset the log in state
|
||||
self.set_logging_in(false, cx);
|
||||
|
||||
// Reset the countdown
|
||||
self.set_countdown(None, cx);
|
||||
|
||||
// Update error message
|
||||
self.error.update(cx, |this, cx| {
|
||||
*this = Some(message.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Clear the error message after 3 secs
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.error.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.logging_in = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
||||
self.countdown.update(cx, |this, cx| {
|
||||
*this = i;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for ImportPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for ImportPanel {}
|
||||
|
||||
impl Focusable for ImportPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ImportPanel {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const SECRET_WARN: &str = "* Coop doesn't store your secret key. \
|
||||
It will be cleared when you close the app. \
|
||||
To persist your identity, please connect via Nostr Connect.";
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from("Import a Secret Key or Bunker")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_112()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("nsec or bunker://")
|
||||
.child(TextInput::new(&self.key_input)),
|
||||
)
|
||||
.when(
|
||||
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|
||||
|this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Password:")
|
||||
.child(TextInput::new(&self.pass_input)),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Continue")
|
||||
.primary()
|
||||
.loading(self.logging_in)
|
||||
.disabled(self.logging_in)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.login(window, cx);
|
||||
})),
|
||||
)
|
||||
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(format!(
|
||||
"Approve connection request from your signer in {} seconds",
|
||||
i
|
||||
))),
|
||||
)
|
||||
})
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.mt_2()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(SECRET_WARN)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
348
crates/coop/src/panels/messaging_relays.rs
Normal file
348
crates/coop/src/panels/messaging_relays.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Subscription, Task, TextAlign, UniformList, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<MessagingRelayPanel> {
|
||||
cx.new(|cx| MessagingRelayPanel::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MessagingRelayPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Relay URL input
|
||||
input: Entity<InputState>,
|
||||
|
||||
/// Error message
|
||||
error: Option<SharedString>,
|
||||
|
||||
// All relays
|
||||
relays: HashSet<RelayUrl>,
|
||||
|
||||
// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl MessagingRelayPanel {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Load user's relays in the local database
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move { Self::load(&client).await })
|
||||
.await;
|
||||
|
||||
if let Ok(relays) = result {
|
||||
this.update(cx, |this, cx| {
|
||||
this.relays.extend(relays);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to user's input events
|
||||
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Update Messaging Relays".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
input,
|
||||
relays: HashSet::new(),
|
||||
error: None,
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
Ok(nip17::extract_owned_relay_list(event).collect())
|
||||
} else {
|
||||
Err(anyhow!("Not found."))
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(url) = RelayUrl::parse(&value) {
|
||||
if !self.relays.insert(url) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
} else {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||
self.relays.remove(url);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
E: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
cx.notify();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.relays.is_empty() {
|
||||
self.set_error("You need to add at least 1 relay", window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let tags: Vec<Tag> = self
|
||||
.relays
|
||||
.iter()
|
||||
.map(|relay| Tag::relay(relay.clone()))
|
||||
.collect();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
// Construct nip17 event builder
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Set messaging relays
|
||||
client.send_event(&event).to_nip65().await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
// TODO
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_error(e.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
|
||||
let relays = self.relays.clone();
|
||||
let total = relays.len();
|
||||
|
||||
uniform_list(
|
||||
"relays",
|
||||
total,
|
||||
cx.processor(move |_v, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let Some(url) = relays.iter().nth(ix) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
items.push(
|
||||
div()
|
||||
.id(SharedString::from(url.to_string()))
|
||||
.group("")
|
||||
.w_full()
|
||||
.h_9()
|
||||
.py_0p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
div().text_sm().child(SharedString::from(url.to_string())),
|
||||
)
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click({
|
||||
let url = url.to_owned();
|
||||
cx.listener(move |this, _ev, _window, cx| {
|
||||
this.remove(&url, cx);
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
.h_full()
|
||||
}
|
||||
|
||||
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.h_20()
|
||||
.justify_center()
|
||||
.border_2()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_sm()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(SharedString::from("Please add some relays."))
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for MessagingRelayPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for MessagingRelayPanel {}
|
||||
|
||||
impl Focusable for MessagingRelayPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for MessagingRelayPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from("Update Messaging Relays")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_112()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::Plus)
|
||||
.label("Add")
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.map(|this| {
|
||||
if !self.relays.is_empty() {
|
||||
this.child(self.render_list(window, cx))
|
||||
} else {
|
||||
this.child(self.render_empty(window, cx))
|
||||
}
|
||||
})
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.set_relays(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
6
crates/coop/src/panels/mod.rs
Normal file
6
crates/coop/src/panels/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod connect;
|
||||
pub mod greeter;
|
||||
pub mod import;
|
||||
pub mod messaging_relays;
|
||||
pub mod profile;
|
||||
pub mod relay_list;
|
||||
@@ -3,33 +3,36 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{nip96_upload, shorten_pubkey};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, App, AppContext, ClipboardItem, Context, Entity, IntoElement, ParentElement,
|
||||
PathPromptOptions, Render, SharedString, Styled, Task, Window,
|
||||
div, rems, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
|
||||
Styled, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::Person;
|
||||
use person::{Person, PersonRegistry};
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::fs;
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::{h_flex, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
|
||||
use ui::notification::Notification;
|
||||
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
||||
|
||||
pub mod viewer;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
|
||||
cx.new(|cx| UserProfile::new(window, cx))
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfilePanel> {
|
||||
cx.new(|cx| ProfilePanel::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UserProfile {
|
||||
/// User profile
|
||||
profile: Option<Profile>,
|
||||
pub struct ProfilePanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// User's public key
|
||||
public_key: PublicKey,
|
||||
|
||||
/// User's name text input
|
||||
name_input: Entity<InputState>,
|
||||
@@ -48,17 +51,13 @@ pub struct UserProfile {
|
||||
|
||||
/// Copied states
|
||||
copied: bool,
|
||||
|
||||
/// Async operations
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl UserProfile {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
impl ProfilePanel {
|
||||
fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
|
||||
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
|
||||
|
||||
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
|
||||
// Use multi-line input for bio
|
||||
let bio_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
@@ -67,53 +66,29 @@ impl UserProfile {
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
|
||||
let get_profile = Self::get_profile(cx);
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Get metadata in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(profile) = get_profile.await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_profile(profile, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Get user's profile and update inputs
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
// Set all input's values with current profile
|
||||
this.set_profile(profile, window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
profile: None,
|
||||
name: "Update Profile".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
public_key,
|
||||
name_input,
|
||||
avatar_input,
|
||||
bio_input,
|
||||
website_input,
|
||||
uploading: false,
|
||||
copied: false,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profile(cx: &App) -> Task<Result<Profile, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let metadata = client
|
||||
.database()
|
||||
.metadata(public_key)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
})
|
||||
}
|
||||
|
||||
fn set_profile(&mut self, profile: Profile, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let metadata = profile.metadata();
|
||||
fn set_profile(&mut self, person: Person, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let metadata = person.metadata();
|
||||
|
||||
self.avatar_input.update(cx, |this, cx| {
|
||||
if let Some(avatar) = metadata.picture.as_ref() {
|
||||
@@ -138,9 +113,6 @@ impl UserProfile {
|
||||
this.set_value(website, window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
self.profile = Some(profile);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -155,19 +127,19 @@ impl UserProfile {
|
||||
cx.notify();
|
||||
|
||||
if status {
|
||||
self._tasks.push(
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
// Reset the copied state after a delay
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_copied(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_copied(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,149 +205,197 @@ impl UserProfile {
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Person, Error>> {
|
||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||
let name = self.name_input.read(cx).value().to_string();
|
||||
let bio = self.bio_input.read(cx).value().to_string();
|
||||
let website = self.website_input.read(cx).value().to_string();
|
||||
/// Set the metadata for the current user
|
||||
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let metadata = metadata.clone();
|
||||
|
||||
// Get the current profile metadata
|
||||
let old_metadata = self
|
||||
.profile
|
||||
.as_ref()
|
||||
.map(|profile| profile.metadata())
|
||||
.unwrap_or_default();
|
||||
cx.background_spawn(async move {
|
||||
// Build and sign the metadata event
|
||||
let builder = EventBuilder::metadata(&metadata);
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Send event to user's relays
|
||||
client.send_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let public_key = self.public_key;
|
||||
let old_metadata = persons.read(cx).get(&public_key, cx).metadata();
|
||||
|
||||
// Extract all new metadata fields
|
||||
let avatar = self.avatar_input.read(cx).value();
|
||||
let name = self.name_input.read(cx).value();
|
||||
let bio = self.bio_input.read(cx).value();
|
||||
let website = self.website_input.read(cx).value();
|
||||
|
||||
// Construct the new metadata
|
||||
let mut new_metadata = old_metadata.display_name(name).about(bio);
|
||||
let mut new_metadata = old_metadata
|
||||
.display_name(name.as_ref())
|
||||
.name(name.as_ref())
|
||||
.about(bio.as_ref());
|
||||
|
||||
// Verify the avatar URL before adding it
|
||||
if let Ok(url) = Url::from_str(&avatar) {
|
||||
new_metadata = new_metadata.picture(url);
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the website URL before adding it
|
||||
if let Ok(url) = Url::from_str(&website) {
|
||||
new_metadata = new_metadata.website(url);
|
||||
}
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
// Set the metadata
|
||||
let task = self.publish(&new_metadata, cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
let signer = client.signer().await?;
|
||||
|
||||
// Sign the new metadata event
|
||||
let event = EventBuilder::metadata(&new_metadata).sign(&signer).await?;
|
||||
|
||||
// Send event to user's write relayss
|
||||
client.send_event_to(urls, &event).await?;
|
||||
|
||||
// Return the updated profile
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let profile = Person::new(event.pubkey, metadata);
|
||||
|
||||
Ok(profile)
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(|window, cx| {
|
||||
persons.update(cx, |this, cx| {
|
||||
this.insert(Person::new(public_key, new_metadata), cx);
|
||||
});
|
||||
window.push_notification("Profile updated successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for UserProfile {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.w_full()
|
||||
.h_32()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.map(|this| {
|
||||
let picture = self.avatar_input.read(cx).value();
|
||||
let source = if picture.is_empty() {
|
||||
"brand/avatar.png"
|
||||
} else {
|
||||
picture.as_str()
|
||||
};
|
||||
this.child(img(source).rounded_full().size_10().flex_shrink_0())
|
||||
})
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::Upload)
|
||||
.label("Change")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Name:"))
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Bio:"))
|
||||
.child(TextInput::new(&self.bio_input).small()),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Website:"))
|
||||
.child(TextInput::new(&self.website_input).small()),
|
||||
)
|
||||
.when_some(self.profile.as_ref(), |this, profile| {
|
||||
let public_key = profile.public_key();
|
||||
let display = SharedString::from(shorten_pubkey(profile.public_key(), 8));
|
||||
impl Panel for ProfilePanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
this.child(div().my_1().h_px().w_full().bg(cx.theme().border))
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for ProfilePanel {}
|
||||
|
||||
impl Focusable for ProfilePanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProfilePanel {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||
|
||||
// Get the avatar
|
||||
let avatar_input = self.avatar_input.read(cx).value();
|
||||
let avatar = if avatar_input.is_empty() {
|
||||
"brand/avatar.png"
|
||||
} else {
|
||||
avatar_input.as_str()
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_112()
|
||||
.child(
|
||||
v_flex()
|
||||
.h_40()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_4()
|
||||
.child(Avatar::new(avatar).size(rems(4.25)))
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::PlusCircle)
|
||||
.label("Add an avatar")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.disabled(self.uploading)
|
||||
.loading(self.uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("What should people call you?"))
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("A short introduction about you:"))
|
||||
.child(TextInput::new(&self.bio_input).small()),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Website:"))
|
||||
.child(TextInput::new(&self.website_input).small()),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Public Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.h_12()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.child(display)
|
||||
.child(shorten_pkey)
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
IconName::CheckCircle
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.copy(
|
||||
public_key.to_bech32().unwrap(),
|
||||
this.public_key.to_bech32().unwrap(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -383,6 +403,16 @@ impl Render for UserProfile {
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.disabled(self.uploading)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
365
crates/coop/src/panels/relay_list.rs
Normal file
365
crates/coop/src/panels/relay_list.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Subscription, Task, TextAlign, UniformList, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrRegistry, BOOTSTRAP_RELAYS};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::{divider, h_flex, v_flex, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<RelayListPanel> {
|
||||
cx.new(|cx| RelayListPanel::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RelayListPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Relay URL input
|
||||
input: Entity<InputState>,
|
||||
|
||||
/// Relay metadata input
|
||||
metadata: Entity<Option<RelayMetadata>>,
|
||||
|
||||
/// Error message
|
||||
error: Option<SharedString>,
|
||||
|
||||
// All relays
|
||||
relays: HashSet<(RelayUrl, Option<RelayMetadata>)>,
|
||||
|
||||
// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl RelayListPanel {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||
let metadata = cx.new(|_| None);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Load user's relays in the local database
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move { Self::load(&client).await })
|
||||
.await;
|
||||
|
||||
if let Ok(relays) = result {
|
||||
this.update(cx, |this, cx| {
|
||||
this.relays.extend(relays);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to user's input events
|
||||
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Update Relay List".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
input,
|
||||
metadata,
|
||||
relays: HashSet::new(),
|
||||
error: None,
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(client: &Client) -> Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error> {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::RelayList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
Ok(nip65::extract_owned_relay_list(event).collect())
|
||||
} else {
|
||||
Err(anyhow!("Not found."))
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
let metadata = self.metadata.read(cx);
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(url) = RelayUrl::parse(&value) {
|
||||
if !self.relays.insert((url, metadata.to_owned())) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
} else {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||
self.relays.retain(|(relay, _)| relay != url);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
E: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
cx.notify();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.relays.is_empty() {
|
||||
self.set_error("You need to add at least 1 relay", window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let relays = self.relays.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let builder = EventBuilder::relay_list(relays);
|
||||
let event = client.sign_event_builder(builder).await?;
|
||||
|
||||
// Set relay list for current user
|
||||
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
// TODO
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_error(e.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
|
||||
let relays = self.relays.clone();
|
||||
let total = relays.len();
|
||||
|
||||
uniform_list(
|
||||
"relays",
|
||||
total,
|
||||
cx.processor(move |_v, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let Some((url, metadata)) = relays.iter().nth(ix) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
items.push(
|
||||
div()
|
||||
.id(SharedString::from(url.to_string()))
|
||||
.group("")
|
||||
.w_full()
|
||||
.h_9()
|
||||
.py_0p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
div().text_sm().child(SharedString::from(url.to_string())),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.map(|this| {
|
||||
if let Some(metadata) = metadata {
|
||||
this.child(SharedString::from(
|
||||
metadata.to_string(),
|
||||
))
|
||||
} else {
|
||||
this.child(SharedString::from("Read+Write"))
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click({
|
||||
let url = url.to_owned();
|
||||
cx.listener(
|
||||
move |this, _ev, _window, cx| {
|
||||
this.remove(&url, cx);
|
||||
},
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
.h_full()
|
||||
}
|
||||
|
||||
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.h_20()
|
||||
.justify_center()
|
||||
.border_2()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_sm()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(SharedString::from("Please add some relays."))
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for RelayListPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for RelayListPanel {}
|
||||
|
||||
impl Focusable for RelayListPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RelayListPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_2()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from("Update Relay List")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_112()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::Plus)
|
||||
.label("Add")
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.map(|this| {
|
||||
if !self.relays.is_empty() {
|
||||
this.child(self.render_list(window, cx))
|
||||
} else {
|
||||
this.child(self.render_empty(window, cx))
|
||||
}
|
||||
})
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.set_relays(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
182
crates/coop/src/sidebar/entry.rs
Normal file
182
crates/coop/src/sidebar/entry.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use chat::RoomKind;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::dock_area::ClosePanel;
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension};
|
||||
|
||||
use crate::dialogs::screening;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct RoomEntry {
|
||||
ix: usize,
|
||||
public_key: Option<PublicKey>,
|
||||
name: Option<SharedString>,
|
||||
avatar: Option<SharedString>,
|
||||
created_at: Option<SharedString>,
|
||||
kind: Option<RoomKind>,
|
||||
selected: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl RoomEntry {
|
||||
pub fn new(ix: usize) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
public_key: None,
|
||||
name: None,
|
||||
avatar: None,
|
||||
created_at: None,
|
||||
kind: None,
|
||||
handler: None,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(mut self, public_key: PublicKey) -> Self {
|
||||
self.public_key = Some(public_key);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
|
||||
self.name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
|
||||
self.avatar = Some(avatar.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
|
||||
self.created_at = Some(created_at.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for RoomEntry {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RoomEntry {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let screening = AppSettings::get_screening(cx);
|
||||
|
||||
let public_key = self.public_key;
|
||||
let is_selected = self.is_selected();
|
||||
|
||||
h_flex()
|
||||
.id(self.ix)
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.when_some(self.avatar, |this, avatar| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.overflow_hidden()
|
||||
.child(Avatar::new(avatar).size(rems(1.5))),
|
||||
)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.when_some(self.name, |this, name| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.justify_between()
|
||||
.line_clamp(1)
|
||||
.text_ellipsis()
|
||||
.truncate()
|
||||
.font_medium()
|
||||
.child(name)
|
||||
.when(is_selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CheckCircle)
|
||||
.small()
|
||||
.text_color(cx.theme().icon_accent),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.when_some(self.created_at, |this, created_at| this.child(created_at)),
|
||||
),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.when_some(self.handler, |this, handler| {
|
||||
this.on_click(move |event, window, cx| {
|
||||
handler(event, window, cx);
|
||||
|
||||
if let Some(public_key) = public_key {
|
||||
if self.kind != Some(RoomKind::Ongoing) && screening {
|
||||
let screening = screening::init(public_key, window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.confirm()
|
||||
.child(screening.clone())
|
||||
.button_props(
|
||||
ModalButtonProps::default()
|
||||
.cancel_text("Ignore")
|
||||
.ok_text("Response"),
|
||||
)
|
||||
.on_cancel(move |_event, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
// Prevent closing the modal on click
|
||||
// modal will be automatically closed after closing panel
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use chat::{ChatRegistry, RoomKind};
|
||||
use chat_ui::{CopyPublicKey, OpenPublicKey};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::context_menu::ContextMenuExt;
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::skeleton::Skeleton;
|
||||
use ui::{h_flex, ContextModal, StyledExt};
|
||||
|
||||
use crate::views::screening;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct RoomListItem {
|
||||
ix: usize,
|
||||
room_id: Option<u64>,
|
||||
public_key: Option<PublicKey>,
|
||||
name: Option<SharedString>,
|
||||
avatar: Option<SharedString>,
|
||||
created_at: Option<SharedString>,
|
||||
kind: Option<RoomKind>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl RoomListItem {
|
||||
pub fn new(ix: usize) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
room_id: None,
|
||||
public_key: None,
|
||||
name: None,
|
||||
avatar: None,
|
||||
created_at: None,
|
||||
kind: None,
|
||||
handler: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn room_id(mut self, room_id: u64) -> Self {
|
||||
self.room_id = Some(room_id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn public_key(mut self, public_key: PublicKey) -> Self {
|
||||
self.public_key = Some(public_key);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
|
||||
self.name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
|
||||
self.avatar = Some(avatar.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
|
||||
self.created_at = Some(created_at.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RoomListItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let screening = AppSettings::get_screening(cx);
|
||||
|
||||
let (
|
||||
Some(public_key),
|
||||
Some(room_id),
|
||||
Some(name),
|
||||
Some(avatar),
|
||||
Some(created_at),
|
||||
Some(kind),
|
||||
Some(handler),
|
||||
) = (
|
||||
self.public_key,
|
||||
self.room_id,
|
||||
self.name,
|
||||
self.avatar,
|
||||
self.created_at,
|
||||
self.kind,
|
||||
self.handler,
|
||||
)
|
||||
else {
|
||||
return h_flex()
|
||||
.id(self.ix)
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.child(Skeleton::new().w_32().h_2p5().rounded(cx.theme().radius))
|
||||
.child(Skeleton::new().w_6().h_2p5().rounded(cx.theme().radius)),
|
||||
);
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.ix)
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.overflow_hidden()
|
||||
.child(Avatar::new(avatar).size(rems(1.5))),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.line_clamp(1)
|
||||
.text_ellipsis()
|
||||
.truncate()
|
||||
.font_medium()
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(created_at),
|
||||
),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.context_menu(move |this, _window, _cx| {
|
||||
this.menu("View Profile", Box::new(OpenPublicKey(public_key)))
|
||||
.menu("Copy Public Key", Box::new(CopyPublicKey(public_key)))
|
||||
})
|
||||
.on_click(move |event, window, cx| {
|
||||
handler(event, window, cx);
|
||||
|
||||
if kind != RoomKind::Ongoing && screening {
|
||||
let screening = screening::init(public_key, window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.confirm()
|
||||
.child(screening.clone())
|
||||
.button_props(
|
||||
ModalButtonProps::default()
|
||||
.cancel_text("Ignore")
|
||||
.ok_text("Response"),
|
||||
)
|
||||
.on_cancel(move |_event, _window, cx| {
|
||||
ChatRegistry::global(cx).update(cx, |this, cx| {
|
||||
this.close_room(room_id, cx);
|
||||
});
|
||||
// false to prevent closing the modal
|
||||
// modal will be closed after closing panel
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,257 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{nip05_verify, shorten_pubkey};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfileViewer> {
|
||||
cx.new(|cx| ProfileViewer::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProfileViewer {
|
||||
profile: Person,
|
||||
|
||||
/// Follow status
|
||||
followed: bool,
|
||||
|
||||
/// Verification status
|
||||
verified: bool,
|
||||
|
||||
/// Copy status
|
||||
copied: bool,
|
||||
|
||||
/// Async operations
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl ProfileViewer {
|
||||
pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&target, cx);
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
let check_follow: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let contact_list = client.database().contacts_public_keys(public_key).await?;
|
||||
|
||||
Ok(contact_list.contains(&target))
|
||||
});
|
||||
|
||||
let verify_nip05 = if let Some(address) = profile.metadata().nip05 {
|
||||
Some(Tokio::spawn(cx, async move {
|
||||
nip05_verify(target, &address).await.unwrap_or(false)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tasks.push(
|
||||
// Load user profile data
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let followed = check_follow.await.unwrap_or(false);
|
||||
|
||||
// Update the followed status
|
||||
this.update(cx, |this, cx| {
|
||||
this.followed = followed;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Update the NIP05 verification status if user has NIP05 address
|
||||
if let Some(task) = verify_nip05 {
|
||||
if let Ok(verified) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.verified = verified;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
profile,
|
||||
followed: false,
|
||||
verified: false,
|
||||
copied: false,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn address(&self, _cx: &Context<Self>) -> Option<String> {
|
||||
self.profile.metadata().nip05
|
||||
}
|
||||
|
||||
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(bech32) = self.profile.public_key().to_bech32();
|
||||
let item = ClipboardItem::new_string(bech32);
|
||||
cx.write_to_clipboard(item);
|
||||
|
||||
self.set_copied(true, window, cx);
|
||||
}
|
||||
|
||||
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.copied = status;
|
||||
cx.notify();
|
||||
|
||||
if status {
|
||||
self._tasks.push(
|
||||
// Reset the copied state after a delay
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_copied(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProfileViewer {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let bech32 = shorten_pubkey(self.profile.public_key(), 16);
|
||||
let shared_bech32 = SharedString::from(bech32);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(self.profile.avatar()).size(rems(4.)))
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(self.profile.name()),
|
||||
)
|
||||
.when_some(self.address(cx), |this, address| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(address)
|
||||
.when(self.verified, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.relative()
|
||||
.text_color(cx.theme().text_accent)
|
||||
.child(
|
||||
Icon::new(IconName::CheckCircleFill)
|
||||
.small()
|
||||
.block(),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when(!self.followed, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w_32()
|
||||
.p_1()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Unknown contact")),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Bio:")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.p_2()
|
||||
.min_h_16()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
self.profile
|
||||
.metadata()
|
||||
.about
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(SharedString::from("No bio.")),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(div().my_1().h_px().w_full().bg(cx.theme().border))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Public Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.h_12()
|
||||
.justify_center()
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.child(shared_bech32)
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.copy_pubkey(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
use std::ops::Range;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use chat::{ChatRegistry, Room};
|
||||
use common::{nip05_profile, TextUtils, BOOTSTRAP_RELAYS};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Entity, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::notification::Notification;
|
||||
use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn compose_button() -> impl IntoElement {
|
||||
div().child(
|
||||
Button::new("compose")
|
||||
.icon(IconName::Plus)
|
||||
.ghost_alt()
|
||||
.cta()
|
||||
.small()
|
||||
.rounded()
|
||||
.on_click(move |_, window, cx| {
|
||||
let compose = cx.new(|cx| Compose::new(window, cx));
|
||||
let weak_view = compose.downgrade();
|
||||
|
||||
window.open_modal(cx, move |modal, _window, cx| {
|
||||
let weak_view = weak_view.clone();
|
||||
let label = if compose.read(cx).selected(cx).len() > 1 {
|
||||
SharedString::from("Create Group DM")
|
||||
} else {
|
||||
SharedString::from("Create DM")
|
||||
};
|
||||
|
||||
modal
|
||||
.alert()
|
||||
.overlay_closable(true)
|
||||
.keyboard(true)
|
||||
.show_close(true)
|
||||
.button_props(ModalButtonProps::default().ok_text(label))
|
||||
.title(SharedString::from("Direct Messages"))
|
||||
.child(compose.clone())
|
||||
.on_ok(move |_, window, cx| {
|
||||
weak_view
|
||||
.update(cx, |this, cx| {
|
||||
this.submit(window, cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
// false to prevent the modal from closing
|
||||
false
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct Contact {
|
||||
public_key: PublicKey,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl AsRef<PublicKey> for Contact {
|
||||
fn as_ref(&self) -> &PublicKey {
|
||||
&self.public_key
|
||||
}
|
||||
}
|
||||
|
||||
impl Contact {
|
||||
pub fn new(public_key: PublicKey) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(mut self) -> Self {
|
||||
self.selected = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Compose {
|
||||
/// Input for the room's subject
|
||||
title_input: Entity<InputState>,
|
||||
|
||||
/// Input for the room's members
|
||||
user_input: Entity<InputState>,
|
||||
|
||||
/// User's contacts
|
||||
contacts: Entity<Vec<Contact>>,
|
||||
|
||||
/// Error message
|
||||
error_message: Entity<Option<SharedString>>,
|
||||
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl Compose {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let contacts = cx.new(|_| vec![]);
|
||||
let error_message = cx.new(|_| None);
|
||||
|
||||
let user_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("npub or nprofile..."));
|
||||
|
||||
let title_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)"));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
let get_contacts: Task<Result<Vec<Contact>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let profiles = client.database().contacts(public_key).await?;
|
||||
let contacts: Vec<Contact> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| Contact::new(profile.public_key()))
|
||||
.collect();
|
||||
|
||||
Ok(contacts)
|
||||
});
|
||||
|
||||
tasks.push(
|
||||
// Load all contacts
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match get_contacts.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.extend_contacts(contacts, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Clear the image cache when sidebar is closed
|
||||
cx.on_release_in(window, move |this, window, cx| {
|
||||
this.image_cache.update(cx, |this, cx| {
|
||||
this.clear(window, cx);
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Handle Enter event for user input
|
||||
cx.subscribe_in(
|
||||
&user_input,
|
||||
window,
|
||||
move |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add_and_select_contact(window, cx)
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Self {
|
||||
title_input,
|
||||
user_input,
|
||||
error_message,
|
||||
contacts,
|
||||
image_cache: RetainAllImageCache::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let kinds = vec![Kind::Metadata, Kind::ContactList];
|
||||
let filter = Filter::new().author(public_key).kinds(kinds).limit(10);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extend_contacts<I>(&mut self, contacts: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: IntoIterator<Item = Contact>,
|
||||
{
|
||||
self.contacts.update(cx, |this, cx| {
|
||||
this.extend(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn push_contact(&mut self, contact: Contact, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let pk = contact.public_key;
|
||||
|
||||
if !self.contacts.read(cx).iter().any(|c| c.public_key == pk) {
|
||||
self._tasks.push(cx.background_spawn(async move {
|
||||
Self::request_metadata(&client, pk).await.ok();
|
||||
}));
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.insert(0, contact);
|
||||
cx.notify();
|
||||
});
|
||||
this.user_input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
self.set_error("Contact already added", cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn select_contact(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
self.contacts.update(cx, |this, cx| {
|
||||
if let Some(contact) = this.iter_mut().find(|c| c.public_key == public_key) {
|
||||
contact.selected = true;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn add_and_select_contact(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let content = self.user_input.read(cx).value().to_string();
|
||||
|
||||
// Show loading indicator in the input
|
||||
self.user_input.update(cx, |this, cx| {
|
||||
this.set_loading(true, cx);
|
||||
});
|
||||
|
||||
if let Ok(public_key) = content.to_public_key() {
|
||||
let contact = Contact::new(public_key).selected();
|
||||
self.push_contact(contact, window, cx);
|
||||
} else if content.contains("@") {
|
||||
let task = Tokio::spawn(cx, async move {
|
||||
if let Ok(profile) = nip05_profile(&content).await {
|
||||
let public_key = profile.public_key;
|
||||
let contact = Contact::new(public_key).selected();
|
||||
|
||||
Ok(contact)
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(Ok(contact)) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.push_contact(contact, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Tokio error: {e}");
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(&self, cx: &App) -> Vec<PublicKey> {
|
||||
self.contacts
|
||||
.read(cx)
|
||||
.iter()
|
||||
.filter_map(|contact| {
|
||||
if contact.selected {
|
||||
Some(contact.public_key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
|
||||
let receivers: Vec<PublicKey> = self.selected(cx);
|
||||
let subject_input = self.title_input.read(cx).value();
|
||||
let subject = (!subject_input.is_empty()).then(|| subject_input.to_string());
|
||||
|
||||
if !self.user_input.read(cx).value().is_empty() {
|
||||
self.add_and_select_contact(window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
chat.update(cx, |this, cx| {
|
||||
let room = cx.new(|_| Room::new(subject, public_key, receivers));
|
||||
this.emit_room(room.downgrade(), cx);
|
||||
});
|
||||
|
||||
window.close_modal(cx);
|
||||
}
|
||||
|
||||
fn set_error(&mut self, error: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
// Unlock the user input
|
||||
self.user_input.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
|
||||
// Update error message
|
||||
self.error_message.update(cx, |this, cx| {
|
||||
*this = Some(error.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Dismiss error after 2 seconds
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.error_message.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn list_items(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let mut items = Vec::with_capacity(self.contacts.read(cx).len());
|
||||
|
||||
for ix in range {
|
||||
let Some(contact) = self.contacts.read(cx).get(ix) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let public_key = contact.public_key;
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_2()
|
||||
.h_11()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.text_sm()
|
||||
.child(Avatar::new(profile.avatar()).size(rems(1.75)))
|
||||
.child(profile.name()),
|
||||
)
|
||||
.when(contact.selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CheckCircleFill)
|
||||
.small()
|
||||
.text_color(cx.theme().text_accent),
|
||||
)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.select_contact(public_key, cx);
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Compose {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let error = self.error_message.read(cx).as_ref();
|
||||
let loading = self.user_input.read(cx).loading;
|
||||
let contacts = self.contacts.read(cx);
|
||||
|
||||
v_flex()
|
||||
.image_cache(self.image_cache.clone())
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).")),
|
||||
)
|
||||
.when_some(error, |this, msg| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(msg.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.h_10()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Subject:")),
|
||||
)
|
||||
.child(TextInput::new(&self.title_input).small().appearance(false)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.pt_1()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("To:")),
|
||||
)
|
||||
.child(
|
||||
TextInput::new(&self.user_input)
|
||||
.small()
|
||||
.disabled(loading)
|
||||
.suffix(
|
||||
Button::new("add")
|
||||
.icon(IconName::PlusCircleFill)
|
||||
.transparent()
|
||||
.small()
|
||||
.disabled(loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add_and_select_contact(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
if contacts.is_empty() {
|
||||
this.child(
|
||||
v_flex()
|
||||
.h_24()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child(SharedString::from("No contacts")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Your recently contacts will appear here.")),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
uniform_list(
|
||||
"contacts",
|
||||
contacts.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.list_items(range, cx)
|
||||
}),
|
||||
)
|
||||
.h(px(300.)),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
pub mod compose;
|
||||
pub mod onboarding;
|
||||
pub mod preferences;
|
||||
pub mod screening;
|
||||
pub mod setup_relay;
|
||||
pub mod startup;
|
||||
pub mod welcome;
|
||||
@@ -1,363 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{TextUtils, CLIENT_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, px, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
SharedString, StatefulInteractiveElement, Styled, Task, Window,
|
||||
};
|
||||
use key_store::{KeyItem, KeyStore};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::notification::Notification;
|
||||
use ui::{divider, h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
use crate::chatspace::{self};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
|
||||
Onboarding::new(window, cx)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NostrConnectApp {
|
||||
Nsec(String),
|
||||
Amber(String),
|
||||
Aegis(String),
|
||||
}
|
||||
|
||||
impl NostrConnectApp {
|
||||
pub fn all() -> Vec<Self> {
|
||||
vec![
|
||||
NostrConnectApp::Nsec("https://nsec.app".to_string()),
|
||||
NostrConnectApp::Amber("https://github.com/greenart7c3/Amber".to_string()),
|
||||
NostrConnectApp::Aegis("https://github.com/ZharlieW/Aegis".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &str {
|
||||
match self {
|
||||
Self::Nsec(url) | Self::Amber(url) | Self::Aegis(url) => url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> String {
|
||||
match self {
|
||||
NostrConnectApp::Nsec(_) => "nsec.app (Desktop)".into(),
|
||||
NostrConnectApp::Amber(_) => "Amber (Android)".into(),
|
||||
NostrConnectApp::Aegis(_) => "Aegis (iOS)".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Onboarding {
|
||||
app_keys: Keys,
|
||||
qr_code: Option<Arc<Image>>,
|
||||
|
||||
/// Panel
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let app_keys = Keys::generate();
|
||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||
|
||||
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
|
||||
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
|
||||
let qr_code = uri.to_string().to_qr();
|
||||
|
||||
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
|
||||
//
|
||||
// Direct connection initiated by the client
|
||||
let signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Wait for nostr connect
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = signer.bunker_uri().await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(uri) => {
|
||||
this.save_connection(&uri, window, cx);
|
||||
this.connect(signer, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
qr_code,
|
||||
app_keys,
|
||||
name: "Onboarding".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn save_connection(
|
||||
&mut self,
|
||||
uri: &NostrConnectUri,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||
let username = self.app_keys.public_key().to_hex();
|
||||
let secret = self.app_keys.secret_key().to_secret_bytes();
|
||||
let mut clean_uri = uri.to_string();
|
||||
|
||||
// Clear the secret parameter in the URI if it exists
|
||||
if let Some(s) = uri.secret() {
|
||||
clean_uri = clean_uri.replace(s, "");
|
||||
}
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let user_url = KeyItem::User.to_string();
|
||||
let bunker_url = KeyItem::Bunker.to_string();
|
||||
let user_password = clean_uri.into_bytes();
|
||||
|
||||
// Write bunker uri to keyring for further connection
|
||||
if let Err(e) = keystore
|
||||
.write_credentials(&user_url, "bunker", &user_password, cx)
|
||||
.await
|
||||
{
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Write the app keys for further connection
|
||||
if let Err(e) = keystore
|
||||
.write_credentials(&bunker_url, &username, &secret, cx)
|
||||
.await
|
||||
{
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn connect(&mut self, signer: NostrConnect, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
client.set_signer(signer).await;
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_apps(&self, cx: &Context<Self>) -> impl IntoIterator<Item = impl IntoElement> {
|
||||
let all_apps = NostrConnectApp::all();
|
||||
let mut items = Vec::with_capacity(all_apps.len());
|
||||
|
||||
for (ix, item) in all_apps.into_iter().enumerate() {
|
||||
items.push(self.render_app(ix, item.as_str(), item.url(), cx));
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn render_app<T>(&self, ix: usize, label: T, url: &str, cx: &Context<Self>) -> impl IntoElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
div()
|
||||
.id(ix)
|
||||
.flex_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.py_0p5()
|
||||
.px_2()
|
||||
.bg(cx.theme().ghost_element_background_alt)
|
||||
.child(label.into())
|
||||
.on_click({
|
||||
let url = url.to_owned();
|
||||
move |_e, _window, cx| {
|
||||
cx.open_url(&url);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Onboarding {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Onboarding {}
|
||||
|
||||
impl Focusable for Onboarding {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Onboarding {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.gap_10()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_4()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_16()
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_xl()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child(SharedString::from("Welcome to Coop")),
|
||||
)
|
||||
.child(div().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from("Chat Freely, Stay Private on Nostr."),
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_80()
|
||||
.gap_3()
|
||||
.child(
|
||||
Button::new("continue_btn")
|
||||
.icon(Icon::new(IconName::ArrowRight))
|
||||
.label(SharedString::from("Start Messaging on Nostr"))
|
||||
.primary()
|
||||
.large()
|
||||
.bold()
|
||||
.reverse()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
chatspace::new_account(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.my_1()
|
||||
.gap_1()
|
||||
.child(divider(cx))
|
||||
.child(div().text_sm().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from(
|
||||
"Already have an account? Continue with",
|
||||
),
|
||||
))
|
||||
.child(divider(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("key")
|
||||
.label("Secret Key or Bunker")
|
||||
.large()
|
||||
.ghost_alt()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
chatspace::login(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.p_2()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_5()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.when_some(self.qr_code.as_ref(), |this, qr| {
|
||||
this.child(
|
||||
img(qr.clone())
|
||||
.size(px(256.))
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.when(cx.theme().shadow, |this| this.shadow_lg())
|
||||
.border_1()
|
||||
.border_color(cx.theme().element_active),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child(SharedString::from(
|
||||
"Continue with Nostr Connect",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(
|
||||
"Use Nostr Connect apps to scan the code",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.justify_center()
|
||||
.children(self.render_apps(cx)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
use gpui::{div, App, AppContext, Context, Entity, IntoElement, Render, Window};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
||||
cx.new(|cx| Preferences::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct Preferences {
|
||||
//
|
||||
}
|
||||
|
||||
impl Preferences {
|
||||
pub fn new(_window: &mut Window, _cx: &mut App) -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Preferences {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Task, TextAlign, UniformList,
|
||||
Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<SetupRelay> {
|
||||
cx.new(|cx| SetupRelay::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SetupRelay {
|
||||
input: Entity<InputState>,
|
||||
error: Option<SharedString>,
|
||||
|
||||
// All relays
|
||||
relays: HashSet<RelayUrl>,
|
||||
|
||||
// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl SetupRelay {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Load user's relays in the local database
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move { Self::load(&client).await })
|
||||
.await;
|
||||
|
||||
if let Ok(relays) = result {
|
||||
this.update(cx, |this, cx| {
|
||||
this.relays.extend(relays);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to user's input events
|
||||
cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Self, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Self {
|
||||
input,
|
||||
relays: HashSet::new(),
|
||||
error: None,
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let urls = nip17::extract_owned_relay_list(event).collect();
|
||||
Ok(urls)
|
||||
} else {
|
||||
Err(anyhow!("Not found."))
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(url) = RelayUrl::parse(&value) {
|
||||
if !self.relays.insert(url) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
} else {
|
||||
self.set_error("Relay URl is invalid", window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||
self.relays.remove(url);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
E: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
cx.notify();
|
||||
|
||||
// Clear the error message after a delay
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.relays.is_empty() {
|
||||
self.set_error(
|
||||
"You need to add at least 1 relay to receive messages from others.",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let relays = self.relays.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
let signer = client.signer().await?;
|
||||
|
||||
let tags: Vec<Tag> = relays
|
||||
.iter()
|
||||
.map(|relay| Tag::relay(relay.clone()))
|
||||
.collect();
|
||||
|
||||
let event = EventBuilder::new(Kind::InboxRelays, "")
|
||||
.tags(tags)
|
||||
.sign(&signer)
|
||||
.await?;
|
||||
|
||||
// Set messaging relays
|
||||
client.send_event_to(urls, &event).await?;
|
||||
|
||||
// Connect to messaging relays
|
||||
for relay in relays.iter() {
|
||||
client.add_relay(relay).await.ok();
|
||||
client.connect_relay(relay).await.ok();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(|window, cx| {
|
||||
window.close_modal(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_error(e.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
|
||||
let relays = self.relays.clone();
|
||||
let total = relays.len();
|
||||
|
||||
uniform_list(
|
||||
"relays",
|
||||
total,
|
||||
cx.processor(move |_v, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
if let Some(url) = relays.iter().nth(ix) {
|
||||
items.push(
|
||||
div()
|
||||
.id(SharedString::from(url.to_string()))
|
||||
.group("")
|
||||
.w_full()
|
||||
.h_9()
|
||||
.py_0p5()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.h_full()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.text_xs()
|
||||
.child(SharedString::from(url.to_string()))
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click({
|
||||
let url = url.to_owned();
|
||||
cx.listener(move |this, _ev, _window, cx| {
|
||||
this.remove(&url, cx);
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
.w_full()
|
||||
.min_h(px(200.))
|
||||
}
|
||||
|
||||
fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.h_20()
|
||||
.mb_2()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(SharedString::from("Please add some relays."))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SetupRelay {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("In order to receive messages from others, you need to set up at least one Messaging Relay.")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::PlusFill)
|
||||
.label("Add")
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.map(|this| {
|
||||
if !self.relays.is_empty() {
|
||||
this.child(self.render_list(window, cx))
|
||||
} else {
|
||||
this.child(self.render_empty(window, cx))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use common::BUNKER_TIMEOUT;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
|
||||
Window,
|
||||
};
|
||||
use key_store::{Credential, KeyItem, KeyStore};
|
||||
use nostr_connect::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::{h_flex, v_flex, ContextModal, Sizable, StyledExt};
|
||||
|
||||
use crate::actions::{reset, CoopAuthUrlHandler};
|
||||
|
||||
pub fn init(cre: Credential, window: &mut Window, cx: &mut App) -> Entity<Startup> {
|
||||
cx.new(|cx| Startup::new(cre, window, cx))
|
||||
}
|
||||
|
||||
/// Startup
|
||||
#[derive(Debug)]
|
||||
pub struct Startup {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Local user credentials
|
||||
credential: Credential,
|
||||
|
||||
/// Whether the loadng is in progress
|
||||
loading: bool,
|
||||
|
||||
/// Image cache
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
/// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl Startup {
|
||||
fn new(credential: Credential, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let tasks = smallvec![];
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Clear the local state when user closes the account panel
|
||||
cx.on_release_in(window, move |this, window, cx| {
|
||||
this.image_cache.update(cx, |this, cx| {
|
||||
this.clear(window, cx);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
credential,
|
||||
loading: false,
|
||||
name: "Onboarding".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
image_cache: RetainAllImageCache::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let secret = self.credential.secret();
|
||||
|
||||
// Try to login with bunker
|
||||
if secret.starts_with("bunker://") {
|
||||
match NostrConnectUri::parse(secret) {
|
||||
Ok(uri) => {
|
||||
self.login_with_bunker(uri, window, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
self.set_loading(false, cx);
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// Fall back to login with keys
|
||||
match SecretKey::parse(secret) {
|
||||
Ok(secret) => {
|
||||
self.login_with_keys(secret, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
self.set_loading(false, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn login_with_bunker(
|
||||
&mut self,
|
||||
uri: NostrConnectUri,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||
|
||||
// Handle connection in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = keystore
|
||||
.read_credentials(&KeyItem::Bunker.to_string(), cx)
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(Some((_, content))) => {
|
||||
let secret = SecretKey::from_slice(&content).unwrap();
|
||||
let keys = Keys::new(secret);
|
||||
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
|
||||
let mut signer = NostrConnect::new(uri, keys, timeout, None).unwrap();
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
// Connect to the remote signer
|
||||
this._tasks.push(
|
||||
// Handle connection in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match signer.bunker_uri().await {
|
||||
Ok(_) => {
|
||||
client.set_signer(signer).await;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
Ok(None) => {
|
||||
window.push_notification(
|
||||
"You must allow Coop access to the keyring to continue.",
|
||||
cx,
|
||||
);
|
||||
this.set_loading(false, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
this.set_loading(false, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn login_with_keys(&mut self, secret: SecretKey, cx: &mut Context<Self>) {
|
||||
let keys = Keys::new(secret);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
})
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Startup {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Startup {}
|
||||
|
||||
impl Focusable for Startup {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Startup {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let bunker = self.credential.secret().starts_with("bunker://");
|
||||
let profile = persons.read(cx).get(&self.credential.public_key(), cx);
|
||||
|
||||
v_flex()
|
||||
.image_cache(self.image_cache.clone())
|
||||
.relative()
|
||||
.size_full()
|
||||
.gap_10()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_4()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_16()
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_xl()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child(SharedString::from("Welcome to Coop")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(
|
||||
"Chat Freely, Stay Private on Nostr.",
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.id("account")
|
||||
.h_10()
|
||||
.w_72()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_sm()
|
||||
.when(self.loading, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Indicator::new().small()),
|
||||
)
|
||||
})
|
||||
.when(!self.loading, |this| {
|
||||
let avatar = profile.avatar();
|
||||
let name = profile.name();
|
||||
|
||||
this.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Avatar::new(avatar).size(rems(1.5)))
|
||||
.child(div().pb_px().font_semibold().child(name)),
|
||||
)
|
||||
.child(div().when(bunker, |this| {
|
||||
let label = SharedString::from("Nostr Connect");
|
||||
|
||||
this.child(
|
||||
div()
|
||||
.py_0p5()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.bg(cx.theme().secondary_active)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.rounded_full()
|
||||
.child(label),
|
||||
)
|
||||
})),
|
||||
)
|
||||
})
|
||||
.text_color(cx.theme().text)
|
||||
.active(|this| {
|
||||
this.text_color(cx.theme().element_foreground)
|
||||
.bg(cx.theme().element_active)
|
||||
})
|
||||
.hover(|this| {
|
||||
this.text_color(cx.theme().element_foreground)
|
||||
.bg(cx.theme().element_hover)
|
||||
})
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.login(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(Button::new("logout").label("Sign out").ghost().on_click(
|
||||
|_, _window, cx| {
|
||||
reset(cx);
|
||||
},
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
use gpui::{
|
||||
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::{v_flex, StyledExt};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
|
||||
Welcome::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Welcome {
|
||||
name: SharedString,
|
||||
version: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Welcome {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let version = SharedString::from(format!("Version: {}", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
Self {
|
||||
version,
|
||||
name: "Welcome".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Welcome {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_4()
|
||||
.text_color(cx.theme().element_background),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Welcome {}
|
||||
|
||||
impl Focusable for Welcome {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Welcome {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("coop on nostr")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("version")
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_xs()
|
||||
.child(self.version.clone())
|
||||
.on_click(|_, _window, cx| {
|
||||
cx.open_url("https://github.com/lumehq/coop/releases");
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
289
crates/coop/src/workspace.rs
Normal file
289
crates/coop/src/workspace.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, rems, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||
};
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrRegistry, RelayState};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
|
||||
use title_bar::TitleBar;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::{PanelStyle, PanelView};
|
||||
use ui::dock_area::{ClosePanel, DockArea, DockItem};
|
||||
use ui::menu::DropdownMenu;
|
||||
use ui::{h_flex, v_flex, Root, Sizable, WindowExtension};
|
||||
|
||||
use crate::panels::greeter;
|
||||
use crate::sidebar;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
||||
cx.new(|cx| Workspace::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
/// App's Title Bar
|
||||
titlebar: Entity<TitleBar>,
|
||||
|
||||
/// App's Dock Area
|
||||
dock: Entity<DockArea>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let titlebar = cx.new(|_| TitleBar::new());
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx).panel_style(PanelStyle::TabBar));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe all events emitted by the chat registry
|
||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
||||
match ev {
|
||||
ChatEvent::OpenRoom(id) => {
|
||||
if let Some(room) = chat.read(cx).room(id, cx) {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.add_panel(
|
||||
Arc::new(chat_ui::init(room, window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
ChatEvent::CloseRoom(..) => {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
// Force focus to the tab panel
|
||||
this.focus_tab_panel(window, cx);
|
||||
|
||||
// Dispatch the close panel action
|
||||
cx.defer_in(window, |_, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the chat registry
|
||||
cx.observe(&chat, move |this, chat, cx| {
|
||||
let ids = this.panel_ids(cx);
|
||||
|
||||
chat.update(cx, |this, cx| {
|
||||
this.refresh_rooms(ids, cx);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Set the default layout for app's dock
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.set_layout(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
titlebar,
|
||||
dock,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add panel to the dock
|
||||
pub fn add_panel<P>(panel: P, placement: DockPlacement, window: &mut Window, cx: &mut App)
|
||||
where
|
||||
P: PanelView,
|
||||
{
|
||||
if let Some(root) = window.root::<Root>().flatten() {
|
||||
if let Ok(workspace) = root.read(cx).view().clone().downcast::<Self>() {
|
||||
workspace.update(cx, |this, cx| {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.add_panel(Arc::new(panel), placement, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all panel ids
|
||||
fn panel_ids(&self, cx: &App) -> Option<Vec<u64>> {
|
||||
let ids: Vec<u64> = self
|
||||
.dock
|
||||
.read(cx)
|
||||
.items
|
||||
.panel_ids(cx)
|
||||
.into_iter()
|
||||
.filter_map(|panel| panel.parse::<u64>().ok())
|
||||
.collect();
|
||||
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
/// Set the dock layout
|
||||
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let weak_dock = self.dock.downgrade();
|
||||
|
||||
// Sidebar
|
||||
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||
|
||||
// Main workspace
|
||||
let center = DockItem::split_with_sizes(
|
||||
Axis::Vertical,
|
||||
vec![DockItem::tabs(
|
||||
vec![Arc::new(greeter::init(window, cx))],
|
||||
None,
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
)],
|
||||
vec![None],
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Update the dock layout
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
let current_user = signer.public_key();
|
||||
|
||||
h_flex()
|
||||
.h(TITLEBAR_HEIGHT)
|
||||
.flex_shrink_0()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.when_some(current_user.as_ref(), |this, public_key| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(public_key, cx);
|
||||
|
||||
this.child(
|
||||
Button::new("current-user")
|
||||
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
|
||||
.small()
|
||||
.caret()
|
||||
.compact()
|
||||
.transparent()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.label(profile.name())
|
||||
.separator()
|
||||
.menu("Profile", Box::new(ClosePanel))
|
||||
.menu("Backup", Box::new(ClosePanel))
|
||||
.menu("Themes", Box::new(ClosePanel))
|
||||
.menu("Settings", Box::new(ClosePanel))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(nostr.read(cx).creating(), |this| {
|
||||
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from("Coop is creating a new identity for you..."),
|
||||
))
|
||||
})
|
||||
.when(!nostr.read(cx).connected(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Connecting...")),
|
||||
)
|
||||
})
|
||||
.map(|this| match nostr.read(cx).relay_list_state() {
|
||||
RelayState::Checking => this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Fetching user's relay list...")),
|
||||
),
|
||||
RelayState::NotConfigured => this.child(
|
||||
h_flex()
|
||||
.h_6()
|
||||
.w_full()
|
||||
.px_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.bg(cx.theme().warning_background)
|
||||
.rounded_sm()
|
||||
.child(SharedString::from("User hasn't configured a relay list")),
|
||||
),
|
||||
_ => this,
|
||||
})
|
||||
.map(|this| match chat.read(cx).relay_state(cx) {
|
||||
RelayState::Checking => {
|
||||
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from("Fetching user's messaging relay list..."),
|
||||
))
|
||||
}
|
||||
RelayState::NotConfigured => this.child(
|
||||
h_flex()
|
||||
.h_6()
|
||||
.w_full()
|
||||
.px_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.bg(cx.theme().warning_background)
|
||||
.rounded_sm()
|
||||
.child(SharedString::from(
|
||||
"User hasn't configured a messaging relay list",
|
||||
)),
|
||||
),
|
||||
_ => this,
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context<Self>) -> impl IntoElement {
|
||||
h_flex().h(TITLEBAR_HEIGHT).flex_shrink_0()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Workspace {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
// Titlebar elements
|
||||
let left = self.titlebar_left(window, cx).into_any_element();
|
||||
let right = self.titlebar_right(window, cx).into_any_element();
|
||||
|
||||
// Update title bar children
|
||||
self.titlebar.update(cx, |this, _cx| {
|
||||
this.set_children(vec![left, right]);
|
||||
});
|
||||
|
||||
div()
|
||||
.id(SharedString::from("workspace"))
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(self.titlebar.clone())
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
)
|
||||
// Notifications
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
.children(modal_layer)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use common::app_name;
|
||||
pub use device::*;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrRegistry, RelayState, GIFTWRAP_SUBSCRIPTION, TIMEOUT};
|
||||
use state::{app_name, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT};
|
||||
|
||||
mod device;
|
||||
|
||||
pub use device::*;
|
||||
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
|
||||
@@ -27,15 +26,12 @@ impl Global for GlobalDeviceRegistry {}
|
||||
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceRegistry {
|
||||
/// Device signer
|
||||
pub device_signer: Entity<Option<Arc<dyn NostrSigner>>>,
|
||||
/// Device state
|
||||
state: DeviceState,
|
||||
|
||||
/// Device requests
|
||||
requests: Entity<HashSet<Event>>,
|
||||
|
||||
/// Device state
|
||||
state: DeviceState,
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
@@ -55,40 +51,79 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Create a new device registry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let identity = nostr.read(cx).identity();
|
||||
|
||||
let device_signer = cx.new(|_| None);
|
||||
let requests = cx.new(|_| HashSet::default());
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<Event>(100);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = vec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the identity entity
|
||||
cx.observe(&identity, |this, state, cx| {
|
||||
if state.read(cx).has_public_key() {
|
||||
if state.read(cx).relay_list_state() == RelayState::Set {
|
||||
// Observe the NIP-65 state
|
||||
cx.observe(&nostr, |this, state, cx| {
|
||||
match state.read(cx).relay_list_state() {
|
||||
RelayState::Idle => {
|
||||
this.reset(cx);
|
||||
}
|
||||
RelayState::Configured => {
|
||||
this.get_announcement(cx);
|
||||
}
|
||||
if state.read(cx).messaging_relays_state() == RelayState::Set {
|
||||
this.get_messages(cx);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Handle nostr notifications
|
||||
cx.background_spawn(async move { Self::handle_notifications(&client, &tx).await }),
|
||||
);
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.handle_notifications(cx);
|
||||
});
|
||||
|
||||
tasks.push(
|
||||
Self {
|
||||
requests,
|
||||
state: DeviceState::default(),
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let (tx, rx) = flume::bounded::<Event>(100);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
if let ClientNotification::Message {
|
||||
message: RelayMessage::Event { event, .. },
|
||||
..
|
||||
} = notification
|
||||
{
|
||||
if !processed_events.insert(event.id) {
|
||||
// Skip if the event has already been processed
|
||||
continue;
|
||||
}
|
||||
|
||||
match event.kind {
|
||||
Kind::Custom(4454) => {
|
||||
if verify_author(&client, event.as_ref()).await {
|
||||
tx.send_async(event.into_owned()).await.ok();
|
||||
}
|
||||
}
|
||||
Kind::Custom(4455) => {
|
||||
if verify_author(&client, event.as_ref()).await {
|
||||
tx.send_async(event.into_owned()).await.ok();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
self.tasks.push(
|
||||
// Update GPUI states
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Ok(event) = rx.recv_async().await {
|
||||
@@ -110,117 +145,11 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
device_signer,
|
||||
requests,
|
||||
state: DeviceState::default(),
|
||||
tasks,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle nostr notifications
|
||||
async fn handle_notifications(client: &Client, tx: &flume::Sender<Event>) -> Result<(), Error> {
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
if let RelayPoolNotification::Message {
|
||||
message: RelayMessage::Event { event, .. },
|
||||
..
|
||||
} = notification
|
||||
{
|
||||
if !processed_events.insert(event.id) {
|
||||
// Skip if the event has already been processed
|
||||
continue;
|
||||
}
|
||||
|
||||
match event.kind {
|
||||
Kind::Custom(4454) => {
|
||||
if Self::verify_author(client, event.as_ref()).await {
|
||||
tx.send_async(event.into_owned()).await.ok();
|
||||
}
|
||||
}
|
||||
Kind::Custom(4455) => {
|
||||
if Self::verify_author(client, event.as_ref()).await {
|
||||
tx.send_async(event.into_owned()).await.ok();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify the author of an event
|
||||
async fn verify_author(client: &Client, event: &Event) -> bool {
|
||||
if let Ok(signer) = client.signer().await {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
return public_key == event.pubkey;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Encrypt and store device keys in the local database.
|
||||
async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Encrypt the value
|
||||
let content = signer.nip44_encrypt(&public_key, secret).await?;
|
||||
|
||||
// Construct the application data event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tag(Tag::identifier(IDENTIFIER))
|
||||
.build(public_key)
|
||||
.sign(&Keys::generate())
|
||||
.await?;
|
||||
|
||||
// Save the event to the database
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get device keys from the local database.
|
||||
async fn get_keys(client: &Client) -> Result<Keys, Error> {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(IDENTIFIER);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first() {
|
||||
let content = signer.nip44_decrypt(&public_key, &event.content).await?;
|
||||
let secret = SecretKey::parse(&content)?;
|
||||
let keys = Keys::new(secret);
|
||||
|
||||
Ok(keys)
|
||||
} else {
|
||||
Err(anyhow!("Key not found"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the device signer entity
|
||||
pub fn signer(&self, cx: &App) -> Option<Arc<dyn NostrSigner>> {
|
||||
self.device_signer.read(cx).clone()
|
||||
}
|
||||
|
||||
/// Set the decoupled encryption key for the current user
|
||||
fn set_device_signer<S>(&mut self, signer: S, cx: &mut Context<Self>)
|
||||
where
|
||||
S: NostrSigner + 'static,
|
||||
{
|
||||
self.set_state(DeviceState::Set, cx);
|
||||
self.device_signer.update(cx, |this, cx| {
|
||||
*this = Some(Arc::new(signer));
|
||||
cx.notify();
|
||||
});
|
||||
/// Get the device state
|
||||
pub fn state(&self) -> &DeviceState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Set the device state
|
||||
@@ -229,6 +158,37 @@ impl DeviceRegistry {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Set the decoupled encryption key for the current user
|
||||
fn set_signer<S>(&mut self, new: S, cx: &mut Context<Self>)
|
||||
where
|
||||
S: NostrSigner + 'static,
|
||||
{
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
signer.set_encryption_signer(new).await;
|
||||
|
||||
// Update state
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(DeviceState::Set, cx);
|
||||
this.get_messages(cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Reset the device state
|
||||
fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.state = DeviceState::Initial;
|
||||
self.requests.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Add a request for device keys
|
||||
fn add_request(&mut self, request: Event, cx: &mut Context<Self>) {
|
||||
self.requests.update(cx, |this, cx| {
|
||||
@@ -237,35 +197,49 @@ impl DeviceRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
/// Continuously get gift wrap events for the current user in their messaging relays
|
||||
/// Get all messages for encryption keys
|
||||
fn get_messages(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.subscribe_to_giftwrap_events(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
||||
task.await?;
|
||||
|
||||
// Update state
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Continuously get gift wrap events for the current user in their messaging relays
|
||||
fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let device_signer = self.device_signer.read(cx).clone();
|
||||
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let messaging_relays = nostr.read(cx).messaging_relays(&public_key, cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let urls = messaging_relays.await;
|
||||
let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
|
||||
let mut filters = vec![];
|
||||
let relay_urls = messaging_relays.await;
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||
|
||||
// Construct a filter to get user messages
|
||||
filters.push(Filter::new().kind(Kind::GiftWrap).pubkey(public_key));
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&RelayUrl, Filter> = relay_urls
|
||||
.iter()
|
||||
.map(|relay| (relay, filter.clone()))
|
||||
.collect();
|
||||
|
||||
// Construct a filter to get dekey messages if available
|
||||
if let Some(signer) = device_signer.as_ref() {
|
||||
if let Ok(pubkey) = signer.get_public_key().await {
|
||||
filters.push(Filter::new().kind(Kind::GiftWrap).pubkey(pubkey));
|
||||
}
|
||||
}
|
||||
let output = client.subscribe(target).with_id(id).await?;
|
||||
|
||||
if let Err(e) = client.subscribe_with_id_to(urls, id, filters, None).await {
|
||||
log::error!("Failed to subscribe to gift wrap events: {e}");
|
||||
}
|
||||
log::info!(
|
||||
"Successfully subscribed to encryption gift-wrap messages on: {:?}",
|
||||
output.success
|
||||
);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Get device announcement for current user
|
||||
@@ -273,7 +247,9 @@ impl DeviceRegistry {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
|
||||
@@ -285,8 +261,14 @@ impl DeviceRegistry {
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&RelayUrl, Filter> =
|
||||
urls.iter().map(|relay| (relay, filter.clone())).collect();
|
||||
|
||||
// Stream events from user's write relays
|
||||
let mut stream = client
|
||||
.stream_events_from(&urls, vec![filter], Duration::from_secs(TIMEOUT))
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await?;
|
||||
|
||||
while let Some((_url, res)) = stream.next().await {
|
||||
@@ -327,7 +309,9 @@ impl DeviceRegistry {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let keys = Keys::generate();
|
||||
@@ -335,23 +319,21 @@ impl DeviceRegistry {
|
||||
let n = keys.public_key();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let urls = write_relays.await;
|
||||
|
||||
// Construct an announcement event
|
||||
let event = EventBuilder::new(Kind::Custom(10044), "")
|
||||
.tags(vec![
|
||||
let event = client
|
||||
.sign_event_builder(EventBuilder::new(Kind::Custom(10044), "").tags(vec![
|
||||
Tag::custom(TagKind::custom("n"), vec![n]),
|
||||
Tag::client(app_name()),
|
||||
])
|
||||
.sign(&signer)
|
||||
]))
|
||||
.await?;
|
||||
|
||||
// Publish announcement
|
||||
client.send_event_to(&urls, &event).await?;
|
||||
client.send_event(&event).to(urls).await?;
|
||||
|
||||
// Save device keys to the database
|
||||
Self::set_keys(&client, &secret).await?;
|
||||
set_keys(&client, &secret).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
@@ -359,7 +341,7 @@ impl DeviceRegistry {
|
||||
cx.spawn(async move |this, cx| {
|
||||
if task.await.is_ok() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_device_signer(keys, cx);
|
||||
this.set_signer(keys, cx);
|
||||
this.listen_device_request(cx);
|
||||
})
|
||||
.ok();
|
||||
@@ -377,7 +359,7 @@ impl DeviceRegistry {
|
||||
let device_pubkey = announcement.public_key();
|
||||
|
||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||
if let Ok(keys) = Self::get_keys(&client).await {
|
||||
if let Ok(keys) = get_keys(&client).await {
|
||||
if keys.public_key() != device_pubkey {
|
||||
return Err(anyhow!("Key mismatch"));
|
||||
};
|
||||
@@ -392,7 +374,7 @@ impl DeviceRegistry {
|
||||
match task.await {
|
||||
Ok(keys) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_device_signer(keys, cx);
|
||||
this.set_signer(keys, cx);
|
||||
this.listen_device_request(cx);
|
||||
})
|
||||
.ok();
|
||||
@@ -416,7 +398,9 @@ impl DeviceRegistry {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
@@ -428,8 +412,12 @@ impl DeviceRegistry {
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&RelayUrl, Filter> =
|
||||
urls.iter().map(|relay| (relay, filter.clone())).collect();
|
||||
|
||||
// Subscribe to the device key requests on user's write relays
|
||||
client.subscribe_to(&urls, vec![filter], None).await?;
|
||||
client.subscribe(target).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
@@ -442,7 +430,9 @@ impl DeviceRegistry {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
@@ -454,8 +444,12 @@ impl DeviceRegistry {
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&RelayUrl, Filter> =
|
||||
urls.iter().map(|relay| (relay, filter.clone())).collect();
|
||||
|
||||
// Subscribe to the device key requests on user's write relays
|
||||
client.subscribe_to(&urls, vec![filter], None).await?;
|
||||
client.subscribe(target).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
@@ -468,14 +462,15 @@ impl DeviceRegistry {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let app_keys = nostr.read(cx).app_keys().clone();
|
||||
let app_pubkey = app_keys.public_key();
|
||||
|
||||
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
@@ -505,16 +500,15 @@ impl DeviceRegistry {
|
||||
let urls = write_relays.await;
|
||||
|
||||
// Construct an event for device key request
|
||||
let event = EventBuilder::new(Kind::Custom(4454), "")
|
||||
.tags(vec![
|
||||
let event = client
|
||||
.sign_event_builder(EventBuilder::new(Kind::Custom(4454), "").tags(vec![
|
||||
Tag::client(app_name()),
|
||||
Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
|
||||
])
|
||||
.sign(&signer)
|
||||
]))
|
||||
.await?;
|
||||
|
||||
// Send the event to write relays
|
||||
client.send_event_to(&urls, &event).await?;
|
||||
client.send_event(&event).to(urls).await?;
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
@@ -525,7 +519,7 @@ impl DeviceRegistry {
|
||||
match task.await {
|
||||
Ok(Some(keys)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_device_signer(keys, cx);
|
||||
this.set_signer(keys, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -569,7 +563,7 @@ impl DeviceRegistry {
|
||||
match task.await {
|
||||
Ok(keys) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_device_signer(keys, cx);
|
||||
this.set_signer(keys, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -587,15 +581,16 @@ impl DeviceRegistry {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = signer.public_key().unwrap();
|
||||
|
||||
let write_relays = nostr.read(cx).write_relays(&public_key, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let urls = write_relays.await;
|
||||
let signer = client.signer().await?;
|
||||
|
||||
// Get device keys
|
||||
let keys = Self::get_keys(&client).await?;
|
||||
let keys = get_keys(&client).await?;
|
||||
let secret = keys.secret_key().to_secret_hex();
|
||||
|
||||
// Extract the target public key from the event tags
|
||||
@@ -613,16 +608,15 @@ impl DeviceRegistry {
|
||||
//
|
||||
// P tag: the current device's public key
|
||||
// p tag: the requester's public key
|
||||
let event = EventBuilder::new(Kind::Custom(4455), payload)
|
||||
.tags(vec![
|
||||
let event = client
|
||||
.sign_event_builder(EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
|
||||
Tag::custom(TagKind::custom("P"), vec![keys.public_key()]),
|
||||
Tag::public_key(target),
|
||||
])
|
||||
.sign(&signer)
|
||||
]))
|
||||
.await?;
|
||||
|
||||
// Send the response event to the user's relay list
|
||||
client.send_event_to(&urls, &event).await?;
|
||||
client.send_event(&event).to(urls).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
@@ -630,3 +624,54 @@ impl DeviceRegistry {
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the author of an event
|
||||
async fn verify_author(client: &Client, event: &Event) -> bool {
|
||||
if let Some(signer) = client.signer() {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
return public_key == event.pubkey;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Encrypt and store device keys in the local database.
|
||||
async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Encrypt the value
|
||||
let content = signer.nip44_encrypt(&public_key, secret).await?;
|
||||
|
||||
// Construct the application data event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tag(Tag::identifier(IDENTIFIER))
|
||||
.build(public_key)
|
||||
.sign(&Keys::generate())
|
||||
.await?;
|
||||
|
||||
// Save the event to the database
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get device keys from the local database.
|
||||
async fn get_keys(client: &Client) -> Result<Keys, Error> {
|
||||
let signer = client.signer().context("Signer not found")?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(IDENTIFIER);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first() {
|
||||
let content = signer.nip44_decrypt(&public_key, &event.content).await?;
|
||||
let secret = SecretKey::parse(&content)?;
|
||||
let keys = Keys::new(secret);
|
||||
|
||||
Ok(keys)
|
||||
} else {
|
||||
Err(anyhow!("Key not found"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
device = { path = "../device" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
@@ -4,15 +4,17 @@ use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{EventUtils, BOOTSTRAP_RELAYS};
|
||||
use common::EventUtils;
|
||||
use device::Announcement;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
pub use person::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{Announcement, NostrRegistry, TIMEOUT};
|
||||
use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
||||
|
||||
mod person;
|
||||
|
||||
pub use person::*;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||
}
|
||||
@@ -25,6 +27,7 @@ impl Global for GlobalPersonRegistry {}
|
||||
enum Dispatch {
|
||||
Person(Box<Person>),
|
||||
Announcement(Box<Event>),
|
||||
Relays(Box<Event>),
|
||||
}
|
||||
|
||||
/// Person Registry
|
||||
@@ -99,6 +102,9 @@ impl PersonRegistry {
|
||||
Dispatch::Announcement(event) => {
|
||||
this.set_announcement(&event, cx);
|
||||
}
|
||||
Dispatch::Relays(event) => {
|
||||
this.set_messaging_relays(&event, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
@@ -111,7 +117,7 @@ impl PersonRegistry {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_executor()
|
||||
.await_on_background(async move { Self::load_persons(&client).await })
|
||||
.await_on_background(async move { load_persons(&client).await })
|
||||
.await;
|
||||
|
||||
match result {
|
||||
@@ -139,17 +145,17 @@ impl PersonRegistry {
|
||||
/// Handle nostr notifications
|
||||
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
let mut processed: HashSet<EventId> = HashSet::new();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
let RelayPoolNotification::Message { message, .. } = notification else {
|
||||
while let Some(notification) = notifications.next().await {
|
||||
let ClientNotification::Message { message, .. } = notification else {
|
||||
// Skip if the notification is not a message
|
||||
continue;
|
||||
};
|
||||
|
||||
if let RelayMessage::Event { event, .. } = message {
|
||||
if !processed_events.insert(event.id) {
|
||||
// Skip if the event has already been processed
|
||||
// Skip if the event has already been processed
|
||||
if !processed.insert(event.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -162,18 +168,24 @@ impl PersonRegistry {
|
||||
// Send
|
||||
tx.send_async(Dispatch::Person(val)).await.ok();
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let public_keys = event.extract_public_keys();
|
||||
|
||||
// Get metadata for all public keys
|
||||
get_metadata(client, public_keys).await.ok();
|
||||
}
|
||||
Kind::InboxRelays => {
|
||||
let val = Box::new(event.into_owned());
|
||||
|
||||
// Send
|
||||
tx.send_async(Dispatch::Relays(val)).await.ok();
|
||||
}
|
||||
Kind::Custom(10044) => {
|
||||
let val = Box::new(event.into_owned());
|
||||
|
||||
// Send
|
||||
tx.send_async(Dispatch::Announcement(val)).await.ok();
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let public_keys = event.extract_public_keys();
|
||||
|
||||
// Get metadata for all public keys
|
||||
Self::get_metadata(client, public_keys).await.ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -190,70 +202,19 @@ impl PersonRegistry {
|
||||
.wait_timeout(Duration::from_secs(2))
|
||||
{
|
||||
Ok(Some(public_key)) => {
|
||||
log::info!("Received public key: {}", public_key);
|
||||
batch.insert(public_key);
|
||||
// Process the batch if it's full
|
||||
if batch.len() >= 20 {
|
||||
Self::get_metadata(client, std::mem::take(&mut batch))
|
||||
.await
|
||||
.ok();
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
Self::get_metadata(client, std::mem::take(&mut batch))
|
||||
.await
|
||||
.ok();
|
||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get metadata for all public keys in a event
|
||||
async fn get_metadata<I>(client: &Client, public_keys: I) -> Result<(), Error>
|
||||
where
|
||||
I: IntoIterator<Item = PublicKey>,
|
||||
{
|
||||
let authors: Vec<PublicKey> = public_keys.into_iter().collect();
|
||||
let limit = authors.len();
|
||||
|
||||
if authors.is_empty() {
|
||||
return Err(anyhow!("You need at least one public key"));
|
||||
}
|
||||
|
||||
// Construct the subscription option
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||
|
||||
// Construct the filter for metadata
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.authors(authors)
|
||||
.limit(limit);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load all user profiles from the database
|
||||
async fn load_persons(client: &Client) -> Result<Vec<Person>, Error> {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let mut persons = vec![];
|
||||
|
||||
for event in events.into_iter() {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
persons.push(person);
|
||||
}
|
||||
|
||||
Ok(persons)
|
||||
}
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
@@ -266,6 +227,18 @@ impl PersonRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set messaging relays for a person
|
||||
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
|
||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect();
|
||||
|
||||
person.update(cx, |person, cx| {
|
||||
person.set_messaging_relays(urls);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert batch of persons
|
||||
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||
for person in persons.into_iter() {
|
||||
@@ -316,3 +289,53 @@ impl PersonRegistry {
|
||||
Person::new(public_key, Metadata::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get metadata for all public keys in a event
|
||||
async fn get_metadata<I>(client: &Client, public_keys: I) -> Result<(), Error>
|
||||
where
|
||||
I: IntoIterator<Item = PublicKey>,
|
||||
{
|
||||
let authors: Vec<PublicKey> = public_keys.into_iter().collect();
|
||||
let limit = authors.len();
|
||||
|
||||
if authors.is_empty() {
|
||||
return Err(anyhow!("You need at least one public key"));
|
||||
}
|
||||
|
||||
// Construct the subscription option
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||
|
||||
// Construct the filter for metadata
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.authors(authors)
|
||||
.limit(limit);
|
||||
|
||||
// Construct target for subscription
|
||||
let target = BOOTSTRAP_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, vec![filter.clone()]))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
client.subscribe(target).close_on(opts).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load all user profiles from the database
|
||||
async fn load_persons(client: &Client) -> Result<Vec<Person>, Error> {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let mut persons = vec![];
|
||||
|
||||
for event in events.into_iter() {
|
||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
persons.push(person);
|
||||
}
|
||||
|
||||
Ok(persons)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use device::Announcement;
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::Announcement;
|
||||
|
||||
const IMAGE_RESIZER: &str = "https://wsrv.nl";
|
||||
|
||||
/// Person
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -16,6 +18,9 @@ pub struct Person {
|
||||
|
||||
/// Dekey (NIP-4e) announcement
|
||||
announcement: Option<Announcement>,
|
||||
|
||||
/// Messaging relays
|
||||
messaging_relays: Vec<RelayUrl>,
|
||||
}
|
||||
|
||||
impl PartialEq for Person {
|
||||
@@ -56,6 +61,7 @@ impl Person {
|
||||
public_key,
|
||||
metadata,
|
||||
announcement: None,
|
||||
messaging_relays: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,13 +86,37 @@ impl Person {
|
||||
log::info!("Updated announcement for: {}", self.public_key());
|
||||
}
|
||||
|
||||
/// Get profile messaging relays
|
||||
pub fn messaging_relays(&self) -> &Vec<RelayUrl> {
|
||||
&self.messaging_relays
|
||||
}
|
||||
|
||||
/// Get relay hint for messaging relay list
|
||||
pub fn messaging_relay_hint(&self) -> Option<RelayUrl> {
|
||||
self.messaging_relays.first().cloned()
|
||||
}
|
||||
|
||||
/// Set profile messaging relays
|
||||
pub fn set_messaging_relays<I>(&mut self, relays: I)
|
||||
where
|
||||
I: IntoIterator<Item = RelayUrl>,
|
||||
{
|
||||
self.messaging_relays = relays.into_iter().collect();
|
||||
log::info!("Updated messaging relays for: {}", self.public_key());
|
||||
}
|
||||
|
||||
/// Get profile avatar
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| picture.into())
|
||||
.map(|picture| {
|
||||
let url = format!(
|
||||
"{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
|
||||
);
|
||||
url.into()
|
||||
})
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
use std::borrow::Cow;
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashSet;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::hash::Hash;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
|
||||
Subscription, Task, Window,
|
||||
Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::{AppSettings, AuthMode};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{tracker, NostrRegistry};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::notification::Notification;
|
||||
use ui::{v_flex, ContextModal, Disableable, IconName, Sizable};
|
||||
use ui::{v_flex, Disableable, IconName, Sizable, WindowExtension};
|
||||
|
||||
const AUTH_MESSAGE: &str =
|
||||
"Approve the authentication request to allow Coop to continue sending or receiving events.";
|
||||
@@ -26,16 +27,10 @@ pub fn init(window: &mut Window, cx: &mut App) {
|
||||
}
|
||||
|
||||
/// Authentication request
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct AuthRequest {
|
||||
pub url: RelayUrl,
|
||||
pub challenge: String,
|
||||
}
|
||||
|
||||
impl Hash for AuthRequest {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.challenge.hash(state);
|
||||
}
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct AuthRequest {
|
||||
url: RelayUrl,
|
||||
challenge: String,
|
||||
}
|
||||
|
||||
impl AuthRequest {
|
||||
@@ -45,6 +40,20 @@ impl AuthRequest {
|
||||
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>);
|
||||
@@ -54,14 +63,11 @@ impl Global for GlobalRelayAuth {}
|
||||
// Relay authentication
|
||||
#[derive(Debug)]
|
||||
pub struct RelayAuth {
|
||||
/// Entity for managing auth requests
|
||||
requests: HashSet<AuthRequest>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
/// Pending events waiting for resend after authentication
|
||||
pending_events: HashSet<(EventId, RelayUrl)>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
}
|
||||
|
||||
impl RelayAuth {
|
||||
@@ -77,206 +83,242 @@ impl RelayAuth {
|
||||
|
||||
/// Create a new relay auth instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.handle_notifications(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
pending_events: HashSet::default(),
|
||||
tasks: smallvec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle nostr notifications
|
||||
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get the current entity
|
||||
let entity = cx.entity();
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<AuthRequest>(100);
|
||||
let (tx, rx) = flume::bounded::<Signal>(256);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
log::info!("Started handling nostr notifications");
|
||||
let mut notifications = client.notifications();
|
||||
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the current state
|
||||
cx.observe_in(&entity, window, |this, _, window, cx| {
|
||||
let settings = AppSettings::global(cx);
|
||||
let mode = AppSettings::get_auth_mode(cx);
|
||||
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);
|
||||
|
||||
for req in this.requests.clone().into_iter() {
|
||||
let is_trusted_relay = settings.read(cx).is_trusted_relay(&req.url, cx);
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
}
|
||||
RelayMessage::Ok {
|
||||
event_id, message, ..
|
||||
} => {
|
||||
let msg = MachineReadablePrefix::parse(&message);
|
||||
|
||||
if is_trusted_relay && mode == AuthMode::Auto {
|
||||
// Automatically authenticate if the relay is authenticated before
|
||||
this.response(req, window, cx);
|
||||
} else {
|
||||
// Otherwise open the auth request popup
|
||||
this.ask_for_approval(req, window, cx);
|
||||
// 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(
|
||||
// Handle nostr notifications
|
||||
cx.background_spawn(async move { Self::handle_notifications(&client, &tx).await }),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Update GPUI states
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Ok(request) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.add_request(request, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
requests: HashSet::new(),
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nostr notifications
|
||||
async fn handle_notifications(client: &Client, tx: &flume::Sender<AuthRequest>) {
|
||||
let mut notifications = client.notifications();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
if let RelayPoolNotification::Message {
|
||||
message: RelayMessage::Auth { challenge },
|
||||
relay_url,
|
||||
} = notification
|
||||
{
|
||||
let request = AuthRequest::new(challenge, relay_url);
|
||||
|
||||
if let Err(e) = tx.send_async(request).await {
|
||||
log::error!("Failed to send auth request: {}", e);
|
||||
self.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Add a new authentication request.
|
||||
fn add_request(&mut self, request: AuthRequest, cx: &mut Context<Self>) {
|
||||
self.requests.insert(request);
|
||||
/// 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 the number of pending requests.
|
||||
pub fn pending_requests(&self, _cx: &App) -> usize {
|
||||
self.requests.len()
|
||||
/// Get all pending events for a specific relay,
|
||||
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
|
||||
let pending_events: Vec<EventId> = self
|
||||
.pending_events
|
||||
.iter()
|
||||
.filter(|(_, pending_relay)| pending_relay == relay)
|
||||
.map(|(id, _relay)| id)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
pending_events
|
||||
}
|
||||
|
||||
/// Reask for approval for all pending requests.
|
||||
pub fn re_ask(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
for request in self.requests.clone().into_iter() {
|
||||
self.ask_for_approval(request, window, cx);
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Respond to an authentication request.
|
||||
fn response(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let settings = AppSettings::global(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();
|
||||
|
||||
let challenge = req.challenge.to_owned();
|
||||
let url = req.url.to_owned();
|
||||
|
||||
let challenge_clone = challenge.clone();
|
||||
let url_clone = url.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
// 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 event: Event = EventBuilder::auth(challenge_clone, url_clone.clone())
|
||||
.sign(&signer)
|
||||
.await?;
|
||||
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.pool().relay(url_clone).await?;
|
||||
let relay_url = relay.url();
|
||||
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)))?;
|
||||
relay
|
||||
.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))
|
||||
.await?;
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
log::info!("Sending AUTH event");
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
match notification {
|
||||
RelayNotification::Message {
|
||||
message: RelayMessage::Ok { event_id, .. },
|
||||
} => {
|
||||
if id == event_id {
|
||||
// Re-subscribe to previous subscription
|
||||
relay.resubscribe().await?;
|
||||
|
||||
// Get all pending events that need to be resent
|
||||
let mut tracker = tracker().write().await;
|
||||
let ids: Vec<EventId> = tracker.pending_resend(relay_url);
|
||||
|
||||
for id in ids.into_iter() {
|
||||
if let Some(event) = client.database().event_by_id(&id).await? {
|
||||
let event_id = relay.send_event(&event).await?;
|
||||
tracker.sent(event_id);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
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,
|
||||
RelayNotification::Shutdown => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Authentication failed"))
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
self._tasks.push(
|
||||
// Handle response in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
/// 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 = 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(challenge, cx);
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
// Clear the current notification
|
||||
window.clear_notification_by_id(SharedString::from(&challenge), cx);
|
||||
|
||||
// Push a new notification
|
||||
window.push_notification(format!("{url} has been authenticated"), cx);
|
||||
|
||||
// Save the authenticated relay to automatically authenticate future requests
|
||||
settings.update(cx, |this, cx| {
|
||||
this.add_trusted_relay(url, cx);
|
||||
});
|
||||
|
||||
// Remove the challenge from the list of pending authentications
|
||||
this.requests.remove(&req);
|
||||
cx.notify();
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
// Clear pending events for the authenticated relay
|
||||
this.clear_pending_events(url, cx);
|
||||
// Save the authenticated relay to automatically authenticate future requests
|
||||
settings.update(cx, |this, cx| {
|
||||
this.add_trusted_relay(url, cx);
|
||||
});
|
||||
window.push_notification(format!("{} has been authenticated", url), cx);
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Push a popup to approve the authentication request.
|
||||
fn ask_for_approval(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let url = SharedString::from(req.url.clone().to_string());
|
||||
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 url = SharedString::from(req.url().to_string());
|
||||
let entity = cx.entity().downgrade();
|
||||
let loading = Rc::new(Cell::new(false));
|
||||
|
||||
let note = Notification::new()
|
||||
Notification::new()
|
||||
.custom_id(SharedString::from(&req.challenge))
|
||||
.autohide(false)
|
||||
.icon(IconName::Info)
|
||||
@@ -299,7 +341,7 @@ impl RelayAuth {
|
||||
.into_any_element()
|
||||
})
|
||||
.action(move |_window, _cx| {
|
||||
let entity = entity.clone();
|
||||
let view = entity.clone();
|
||||
let req = req.clone();
|
||||
|
||||
Button::new("approve")
|
||||
@@ -310,24 +352,18 @@ impl RelayAuth {
|
||||
.disabled(loading.get())
|
||||
.on_click({
|
||||
let loading = Rc::clone(&loading);
|
||||
|
||||
move |_ev, window, cx| {
|
||||
// Set loading state to true
|
||||
loading.set(true);
|
||||
|
||||
// Process to approve the request
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.response(req.clone(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
view.update(cx, |this, cx| {
|
||||
this.response(&req, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Push the notification to the current window
|
||||
window.push_notification(note, cx);
|
||||
|
||||
// Bring the window to the front
|
||||
cx.activate(true);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
state = { path = "../state" }
|
||||
common = { path = "../common" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
gpui.workspace = true
|
||||
smol.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
const SETTINGS_IDENTIFIER: &str = "coop:settings";
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
@@ -47,17 +45,31 @@ setting_accessors! {
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum AuthMode {
|
||||
#[default]
|
||||
Manual,
|
||||
Auto,
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// Signer kind
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SignerKind {
|
||||
#[default]
|
||||
Auto,
|
||||
#[default]
|
||||
User,
|
||||
Device,
|
||||
Encryption,
|
||||
}
|
||||
|
||||
impl SignerKind {
|
||||
pub fn auto(&self) -> bool {
|
||||
matches!(self, SignerKind::Auto)
|
||||
}
|
||||
|
||||
pub fn user(&self) -> bool {
|
||||
matches!(self, SignerKind::User)
|
||||
}
|
||||
|
||||
pub fn encryption(&self) -> bool {
|
||||
matches!(self, SignerKind::Encryption)
|
||||
}
|
||||
}
|
||||
|
||||
/// Room configuration
|
||||
@@ -67,6 +79,16 @@ pub struct RoomConfig {
|
||||
signer_kind: SignerKind,
|
||||
}
|
||||
|
||||
impl RoomConfig {
|
||||
pub fn backup(&self) -> bool {
|
||||
self.backup
|
||||
}
|
||||
|
||||
pub fn signer_kind(&self) -> &SignerKind {
|
||||
&self.signer_kind
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
@@ -118,10 +140,7 @@ pub struct AppSettings {
|
||||
values: Settings,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
/// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
@@ -136,9 +155,6 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let load_settings = Self::get_from_database(false, cx);
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
@@ -148,108 +164,77 @@ impl AppSettings {
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Load the initial settings
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(settings) = load_settings.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.values = settings;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
cx.defer(|cx| {
|
||||
let settings = AppSettings::global(cx);
|
||||
|
||||
settings.update(cx, |this, cx| {
|
||||
this.load(cx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
values: Settings::default(),
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get settings from the database
|
||||
///
|
||||
/// If `current_user` is true, the settings will be retrieved for current user.
|
||||
/// Otherwise, Coop will load the latest settings from the database.
|
||||
fn get_from_database(current_user: bool, cx: &App) -> Task<Result<Settings, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct a filter to get the latest settings
|
||||
let mut filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(SETTINGS_IDENTIFIER)
|
||||
.limit(1);
|
||||
|
||||
if current_user {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Push author to the filter
|
||||
filter = filter.author(public_key);
|
||||
}
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
Ok(serde_json::from_str(&event.content).unwrap_or(Settings::default()))
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
}
|
||||
})
|
||||
/// Update settings
|
||||
fn set_settings(&mut self, settings: Settings, cx: &mut Context<Self>) {
|
||||
self.values = settings;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Load settings
|
||||
pub fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let task = Self::get_from_database(true, cx);
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
||||
let path = config_dir().join(".settings");
|
||||
|
||||
self._tasks.push(
|
||||
// Run task in the background
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(settings) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.values = settings;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
if let Ok(content) = smol::fs::read_to_string(&path).await {
|
||||
Ok(serde_json::from_str(&content)?)
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let settings = task.await.unwrap_or(Settings::default());
|
||||
|
||||
// Update settings
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_settings(settings, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Save settings
|
||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let settings = self.values.clone();
|
||||
|
||||
if let Ok(content) = serde_json::to_string(&self.values) {
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let path = config_dir().join(".settings");
|
||||
let content = serde_json::to_string(&settings)?;
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tag(Tag::identifier(SETTINGS_IDENTIFIER))
|
||||
.build(public_key)
|
||||
.sign(&Keys::generate())
|
||||
.await?;
|
||||
// Write settings to file
|
||||
smol::fs::write(&path, content).await?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Check if the given relay is trusted
|
||||
pub fn is_trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
|
||||
self.values.trusted_relays.contains(url)
|
||||
/// Check if the given relay is already authenticated
|
||||
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
|
||||
self.values.trusted_relays.iter().any(|relay| {
|
||||
relay.as_str_without_trailing_slash() == url.as_str_without_trailing_slash()
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a relay to the trusted list
|
||||
pub fn add_trusted_relay(&mut self, url: RelayUrl, cx: &mut Context<Self>) {
|
||||
self.values.trusted_relays.insert(url);
|
||||
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||
self.values.trusted_relays.insert(url.clone());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,19 @@ common = { path = "../common" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
nostr-lmdb.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
smol.workspace = true
|
||||
reqwest.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
webbrowser.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
rustls = "0.23"
|
||||
petname = "2.0.2"
|
||||
whoami = "1.6.1"
|
||||
|
||||
59
crates/state/src/constants.rs
Normal file
59
crates/state/src/constants.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Client name (Application name)
|
||||
pub const CLIENT_NAME: &str = "Coop";
|
||||
|
||||
/// COOP's public key
|
||||
pub const COOP_PUBKEY: &str = "npub126kl5fruqan90py77gf6pvfvygefl2mu2ukew6xdx5pc5uqscwgsnkgarv";
|
||||
|
||||
/// App ID
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
|
||||
/// Keyring name
|
||||
pub const KEYRING: &str = "Coop Safe Storage";
|
||||
|
||||
/// Default timeout for subscription
|
||||
pub const TIMEOUT: u64 = 3;
|
||||
|
||||
/// Default delay for searching
|
||||
pub const FIND_DELAY: u64 = 600;
|
||||
|
||||
/// Default limit for searching
|
||||
pub const FIND_LIMIT: usize = 20;
|
||||
|
||||
/// Default timeout for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
||||
|
||||
/// Default Nostr Connect relay
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
||||
|
||||
/// Default subscription id for device gift wrap events
|
||||
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
|
||||
|
||||
/// Default subscription id for user gift wrap events
|
||||
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
|
||||
|
||||
/// Default vertex relays
|
||||
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||
|
||||
/// Default search relays
|
||||
pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"];
|
||||
|
||||
/// Default bootstrap relays
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"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})")
|
||||
})
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
||||
pub enum DeviceState {
|
||||
#[default]
|
||||
Initial,
|
||||
Requesting,
|
||||
Set,
|
||||
}
|
||||
|
||||
/// Announcement
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Announcement {
|
||||
/// The public key of the device that created this announcement.
|
||||
public_key: PublicKey,
|
||||
|
||||
/// The name of the device that created this announcement.
|
||||
client_name: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Event> for Announcement {
|
||||
fn from(val: &Event) -> Self {
|
||||
let public_key = val
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "P")
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|c| PublicKey::parse(c).ok())
|
||||
.unwrap_or(val.pubkey);
|
||||
|
||||
let client_name = val
|
||||
.tags
|
||||
.find(TagKind::Client)
|
||||
.and_then(|tag| tag.content())
|
||||
.map(|c| c.to_string());
|
||||
|
||||
Self::new(public_key, client_name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
pub fn new(public_key: PublicKey, client_name: Option<String>) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
client_name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the public key of the device that created this announcement.
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
/// Returns the client name of the device that created this announcement.
|
||||
pub fn client_name(&self) -> SharedString {
|
||||
self.client_name
|
||||
.as_ref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(SharedString::from("Unknown"))
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::lock::RwLock;
|
||||
|
||||
static TRACKER: OnceLock<Arc<RwLock<EventTracker>>> = OnceLock::new();
|
||||
|
||||
pub fn tracker() -> &'static Arc<RwLock<EventTracker>> {
|
||||
TRACKER.get_or_init(|| Arc::new(RwLock::new(EventTracker::default())))
|
||||
}
|
||||
|
||||
/// Event tracker
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EventTracker {
|
||||
/// Tracking events sent by Coop in the current session
|
||||
sent_ids: HashSet<EventId>,
|
||||
|
||||
/// Events that need to be resent later
|
||||
pending_resend: HashSet<(EventId, RelayUrl)>,
|
||||
}
|
||||
|
||||
impl EventTracker {
|
||||
/// Check if an event was sent by Coop in the current session.
|
||||
pub fn is_sent_by_coop(&self, id: &EventId) -> bool {
|
||||
self.sent_ids.contains(id)
|
||||
}
|
||||
|
||||
/// Mark an event as sent by Coop.
|
||||
pub fn sent(&mut self, id: EventId) {
|
||||
self.sent_ids.insert(id);
|
||||
}
|
||||
|
||||
/// Get all events that need to be resent later for a specific relay.
|
||||
pub fn pending_resend(&mut self, relay: &RelayUrl) -> Vec<EventId> {
|
||||
self.pending_resend
|
||||
.extract_if(|(_id, url)| url == relay)
|
||||
.map(|(id, _url)| id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Add an event (id and relay url) to the pending resend set.
|
||||
pub fn add_to_pending(&mut self, id: EventId, url: RelayUrl) {
|
||||
self.pending_resend.insert((id, url));
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum RelayState {
|
||||
#[default]
|
||||
Initial,
|
||||
NotSet,
|
||||
Set,
|
||||
}
|
||||
|
||||
impl RelayState {
|
||||
pub fn is_initial(&self) -> bool {
|
||||
matches!(self, RelayState::Initial)
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Identity {
|
||||
/// The public key of the account
|
||||
pub public_key: Option<PublicKey>,
|
||||
|
||||
/// Status of the current user NIP-65 relays
|
||||
relay_list: RelayState,
|
||||
|
||||
/// Status of the current user NIP-17 relays
|
||||
messaging_relays: RelayState,
|
||||
}
|
||||
|
||||
impl AsRef<Identity> for Identity {
|
||||
fn as_ref(&self) -> &Identity {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
public_key: None,
|
||||
relay_list: RelayState::default(),
|
||||
messaging_relays: RelayState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the state of the NIP-65 relays.
|
||||
pub fn set_relay_list_state(&mut self, state: RelayState) {
|
||||
self.relay_list = state;
|
||||
}
|
||||
|
||||
/// Returns the state of the NIP-65 relays.
|
||||
pub fn relay_list_state(&self) -> RelayState {
|
||||
self.relay_list
|
||||
}
|
||||
|
||||
/// Sets the state of the NIP-17 relays.
|
||||
pub fn set_messaging_relays_state(&mut self, state: RelayState) {
|
||||
self.messaging_relays = state;
|
||||
}
|
||||
|
||||
/// Returns the state of the NIP-17 relays.
|
||||
pub fn messaging_relays_state(&self) -> RelayState {
|
||||
self.messaging_relays
|
||||
}
|
||||
|
||||
/// Force getting the public key of the identity.
|
||||
///
|
||||
/// Panics if the public key is not set.
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key.unwrap()
|
||||
}
|
||||
|
||||
/// Returns true if the identity has a public key.
|
||||
pub fn has_public_key(&self) -> bool {
|
||||
self.public_key.is_some()
|
||||
}
|
||||
|
||||
/// Sets the public key of the identity.
|
||||
pub fn set_public_key(&mut self, public_key: PublicKey) {
|
||||
self.public_key = Some(public_key);
|
||||
}
|
||||
|
||||
/// Unsets the public key of the identity.
|
||||
pub fn unset_public_key(&mut self) {
|
||||
self.public_key = None;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
60
crates/state/src/nip05.rs
Normal file
60
crates/state/src/nip05.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::io::AsyncReadExt;
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait NostrAddress {
|
||||
/// Get the NIP-05 profile
|
||||
async fn profile(&self, client: &Arc<dyn HttpClient>) -> Result<Nip05Profile, Error>;
|
||||
|
||||
/// Verify the NIP-05 address
|
||||
async fn verify(
|
||||
&self,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
public_key: &PublicKey,
|
||||
) -> Result<bool, Error>;
|
||||
}
|
||||
|
||||
impl NostrAddress for Nip05Address {
|
||||
async fn profile(&self, client: &Arc<dyn HttpClient>) -> Result<Nip05Profile, Error> {
|
||||
let mut body = Vec::new();
|
||||
let mut res = client
|
||||
.get(self.url().as_str(), AsyncBody::default(), false)
|
||||
.await?;
|
||||
|
||||
// Read the response body into a vector
|
||||
res.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
// Parse the JSON response
|
||||
let json: Value = serde_json::from_slice(&body)?;
|
||||
|
||||
let profile = Nip05Profile::from_json(self, &json)?;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
async fn verify(
|
||||
&self,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
public_key: &PublicKey,
|
||||
) -> Result<bool, Error> {
|
||||
let mut body = Vec::new();
|
||||
let mut res = client
|
||||
.get(self.url().as_str(), AsyncBody::default(), false)
|
||||
.await?;
|
||||
|
||||
// Read the response body into a vector
|
||||
res.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
// Parse the JSON response
|
||||
let json: Value = serde_json::from_slice(&body)?;
|
||||
|
||||
// Verify the NIP-05 address
|
||||
let verified = nip05::verify_from_json(public_key, self, &json);
|
||||
|
||||
Ok(verified)
|
||||
}
|
||||
}
|
||||
146
crates/state/src/signer.rs
Normal file
146
crates/state/src/signer.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use std::borrow::Cow;
|
||||
use std::result::Result;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::lock::RwLock;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CoopSigner {
|
||||
/// User's signer
|
||||
signer: RwLock<Arc<dyn NostrSigner>>,
|
||||
|
||||
/// User's signer public key
|
||||
signer_pkey: RwLock<Option<PublicKey>>,
|
||||
|
||||
/// Specific signer for encryption purposes
|
||||
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
|
||||
|
||||
/// By default, Coop generates a new signer for new users.
|
||||
///
|
||||
/// This flag indicates whether the signer is user-owned or Coop-generated.
|
||||
owned: AtomicBool,
|
||||
}
|
||||
|
||||
impl CoopSigner {
|
||||
pub fn new<T>(signer: T) -> Self
|
||||
where
|
||||
T: IntoNostrSigner,
|
||||
{
|
||||
Self {
|
||||
signer: RwLock::new(signer.into_nostr_signer()),
|
||||
signer_pkey: RwLock::new(None),
|
||||
encryption_signer: RwLock::new(None),
|
||||
owned: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current signer.
|
||||
pub async fn get(&self) -> Arc<dyn NostrSigner> {
|
||||
self.signer.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get the encryption signer.
|
||||
pub async fn get_encryption_signer(&self) -> Option<Arc<dyn NostrSigner>> {
|
||||
self.encryption_signer.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get public key
|
||||
pub fn public_key(&self) -> Option<PublicKey> {
|
||||
self.signer_pkey.read_blocking().to_owned()
|
||||
}
|
||||
|
||||
/// Get the flag indicating whether the signer is user-owned.
|
||||
pub fn owned(&self) -> bool {
|
||||
self.owned.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Switch the current signer to a new signer.
|
||||
pub async fn switch<T>(&self, new: T, owned: bool)
|
||||
where
|
||||
T: IntoNostrSigner,
|
||||
{
|
||||
let new_signer = new.into_nostr_signer();
|
||||
let public_key = new_signer.get_public_key().await.ok();
|
||||
let mut signer = self.signer.write().await;
|
||||
let mut signer_pkey = self.signer_pkey.write().await;
|
||||
let mut encryption_signer = self.encryption_signer.write().await;
|
||||
|
||||
// Switch to the new signer
|
||||
*signer = new_signer;
|
||||
|
||||
// Update the public key
|
||||
*signer_pkey = public_key;
|
||||
|
||||
// Reset the encryption signer
|
||||
*encryption_signer = None;
|
||||
|
||||
// Update the owned flag
|
||||
self.owned.store(owned, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
#[allow(mismatched_lifetime_syntaxes)]
|
||||
fn backend(&self) -> SignerBackend {
|
||||
SignerBackend::Custom(Cow::Borrowed("custom"))
|
||||
}
|
||||
|
||||
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,
|
||||
) -> BoxedFuture<'a, Result<Event, SignerError>> {
|
||||
Box::pin(async move { self.get().await.sign_event(unsigned).await })
|
||||
}
|
||||
|
||||
fn nip04_encrypt<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
||||
Box::pin(async move { self.get().await.nip04_encrypt(public_key, content).await })
|
||||
}
|
||||
|
||||
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,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
||||
Box::pin(async move { self.get().await.nip44_decrypt(public_key, payload).await })
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ use std::rc::Rc;
|
||||
use gpui::{px, App, Global, Pixels, SharedString, Window};
|
||||
|
||||
mod colors;
|
||||
mod platform_kind;
|
||||
mod registry;
|
||||
mod scale;
|
||||
mod scrollbar_mode;
|
||||
mod theme;
|
||||
|
||||
pub use colors::*;
|
||||
pub use platform_kind::PlatformKind;
|
||||
pub use registry::*;
|
||||
pub use scale::*;
|
||||
pub use scrollbar_mode::*;
|
||||
@@ -21,6 +23,15 @@ pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0);
|
||||
/// Defines window shadow size for platforms that use client side decorations.
|
||||
pub const CLIENT_SIDE_DECORATION_SHADOW: Pixels = px(10.0);
|
||||
|
||||
/// Defines window border size for platforms that use client side decorations.
|
||||
pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
|
||||
|
||||
/// Defines window titlebar height
|
||||
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
|
||||
|
||||
/// Defines default sidebar width
|
||||
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
registry::init(cx);
|
||||
|
||||
@@ -164,7 +175,16 @@ impl Theme {
|
||||
|
||||
impl From<ThemeFamily> for Theme {
|
||||
fn from(family: ThemeFamily) -> Self {
|
||||
let platform = PlatformKind::platform();
|
||||
let mode = ThemeMode::default();
|
||||
|
||||
// Define the font family based on the platform.
|
||||
// TODO: Use native fonts on Linux too.
|
||||
let font_family = match platform {
|
||||
PlatformKind::Linux => "Inter",
|
||||
_ => ".SystemUIFont",
|
||||
};
|
||||
|
||||
// Define the theme colors based on the appearance
|
||||
let colors = match mode {
|
||||
ThemeMode::Light => family.light(),
|
||||
@@ -173,7 +193,7 @@ impl From<ThemeFamily> for Theme {
|
||||
|
||||
Theme {
|
||||
font_size: px(15.),
|
||||
font_family: ".SystemUIFont".into(),
|
||||
font_family: font_family.into(),
|
||||
radius: px(5.),
|
||||
radius_lg: px(10.),
|
||||
shadow: true,
|
||||
|
||||
@@ -9,15 +9,13 @@ use gpui::{
|
||||
WindowControlArea,
|
||||
};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
|
||||
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING};
|
||||
use ui::h_flex;
|
||||
|
||||
use crate::platform_kind::PlatformKind;
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::platforms::linux::LinuxWindowControls;
|
||||
use crate::platforms::windows::WindowsWindowControls;
|
||||
|
||||
mod platform_kind;
|
||||
mod platforms;
|
||||
|
||||
pub struct TitleBar {
|
||||
|
||||
333
crates/ui/src/anchored.rs
Normal file
333
crates/ui/src/anchored.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
//! 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::{
|
||||
point, px, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half,
|
||||
InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
|
||||
Window,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::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 }
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use theme::ActiveTheme;
|
||||
|
||||
use crate::indicator::Indicator;
|
||||
use crate::tooltip::Tooltip;
|
||||
use crate::{h_flex, Disableable, Icon, Selectable, Sizable, Size, StyledExt};
|
||||
use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ButtonCustomVariant {
|
||||
@@ -20,50 +20,6 @@ pub struct ButtonCustomVariant {
|
||||
active: Hsla,
|
||||
}
|
||||
|
||||
pub trait ButtonVariants: Sized {
|
||||
fn with_variant(self, variant: ButtonVariant) -> Self;
|
||||
|
||||
/// With the primary style for the Button.
|
||||
fn primary(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Primary)
|
||||
}
|
||||
|
||||
/// With the secondary style for the Button.
|
||||
fn secondary(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Secondary)
|
||||
}
|
||||
|
||||
/// With the danger style for the Button.
|
||||
fn danger(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Danger)
|
||||
}
|
||||
|
||||
/// With the warning style for the Button.
|
||||
fn warning(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Warning)
|
||||
}
|
||||
|
||||
/// With the ghost style for the Button.
|
||||
fn ghost(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Ghost { alt: false })
|
||||
}
|
||||
|
||||
/// With the ghost style for the Button.
|
||||
fn ghost_alt(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Ghost { alt: true })
|
||||
}
|
||||
|
||||
/// With the transparent style for the Button.
|
||||
fn transparent(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Transparent)
|
||||
}
|
||||
|
||||
/// With the custom style for the Button.
|
||||
fn custom(self, style: ButtonCustomVariant) -> Self {
|
||||
self.with_variant(ButtonVariant::Custom(style))
|
||||
}
|
||||
}
|
||||
|
||||
impl ButtonCustomVariant {
|
||||
pub fn new(_window: &Window, cx: &App) -> Self {
|
||||
Self {
|
||||
@@ -110,6 +66,50 @@ pub enum ButtonVariant {
|
||||
Custom(ButtonCustomVariant),
|
||||
}
|
||||
|
||||
pub trait ButtonVariants: Sized {
|
||||
fn with_variant(self, variant: ButtonVariant) -> Self;
|
||||
|
||||
/// With the primary style for the Button.
|
||||
fn primary(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Primary)
|
||||
}
|
||||
|
||||
/// With the secondary style for the Button.
|
||||
fn secondary(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Secondary)
|
||||
}
|
||||
|
||||
/// With the danger style for the Button.
|
||||
fn danger(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Danger)
|
||||
}
|
||||
|
||||
/// With the warning style for the Button.
|
||||
fn warning(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Warning)
|
||||
}
|
||||
|
||||
/// With the ghost style for the Button.
|
||||
fn ghost(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Ghost { alt: false })
|
||||
}
|
||||
|
||||
/// With the ghost style for the Button.
|
||||
fn ghost_alt(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Ghost { alt: true })
|
||||
}
|
||||
|
||||
/// With the transparent style for the Button.
|
||||
fn transparent(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Transparent)
|
||||
}
|
||||
|
||||
/// With the custom style for the Button.
|
||||
fn custom(self, style: ButtonCustomVariant) -> Self {
|
||||
self.with_variant(ButtonVariant::Custom(style))
|
||||
}
|
||||
}
|
||||
|
||||
/// A Button element.
|
||||
#[derive(IntoElement)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -124,16 +124,15 @@ pub struct Button {
|
||||
children: Vec<AnyElement>,
|
||||
|
||||
variant: ButtonVariant,
|
||||
rounded: bool,
|
||||
size: Size,
|
||||
|
||||
disabled: bool,
|
||||
reverse: bool,
|
||||
bold: bool,
|
||||
cta: bool,
|
||||
|
||||
loading: bool,
|
||||
loading_icon: Option<Icon>,
|
||||
|
||||
rounded: bool,
|
||||
compact: bool,
|
||||
underline: bool,
|
||||
caret: bool,
|
||||
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||
@@ -160,20 +159,19 @@ impl Button {
|
||||
style: StyleRefinement::default(),
|
||||
icon: None,
|
||||
label: None,
|
||||
variant: ButtonVariant::default(),
|
||||
disabled: false,
|
||||
selected: false,
|
||||
variant: ButtonVariant::default(),
|
||||
underline: false,
|
||||
compact: false,
|
||||
caret: false,
|
||||
rounded: false,
|
||||
size: Size::Medium,
|
||||
tooltip: None,
|
||||
on_click: None,
|
||||
on_hover: None,
|
||||
loading: false,
|
||||
reverse: false,
|
||||
bold: false,
|
||||
cta: false,
|
||||
children: Vec::new(),
|
||||
loading_icon: None,
|
||||
tab_index: 0,
|
||||
tab_stop: true,
|
||||
}
|
||||
@@ -209,27 +207,21 @@ impl Button {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set reverse the position between icon and label.
|
||||
pub fn reverse(mut self) -> Self {
|
||||
self.reverse = true;
|
||||
/// Set true to make the button compact (no padding).
|
||||
pub fn compact(mut self) -> Self {
|
||||
self.compact = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set bold the button (label will be use the semi-bold font).
|
||||
pub fn bold(mut self) -> Self {
|
||||
self.bold = true;
|
||||
/// Set true to show the caret indicator.
|
||||
pub fn caret(mut self) -> Self {
|
||||
self.caret = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the cta style of the button.
|
||||
pub fn cta(mut self) -> Self {
|
||||
self.cta = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the loading icon of the button.
|
||||
pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.loading_icon = Some(icon.into());
|
||||
/// Set true to show the underline indicator.
|
||||
pub fn underline(mut self) -> Self {
|
||||
self.underline = true;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -338,7 +330,7 @@ impl RenderOnce for Button {
|
||||
};
|
||||
|
||||
let focus_handle = window
|
||||
.use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
|
||||
.use_keyed_state(self.id.clone(), cx, |_window, cx| cx.focus_handle())
|
||||
.read(cx)
|
||||
.clone();
|
||||
|
||||
@@ -350,6 +342,7 @@ impl RenderOnce for Button {
|
||||
.tab_stop(self.tab_stop),
|
||||
)
|
||||
})
|
||||
.relative()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
@@ -361,39 +354,15 @@ impl RenderOnce for Button {
|
||||
false => this.rounded(cx.theme().radius),
|
||||
true => this.rounded_full(),
|
||||
})
|
||||
.map(|this| {
|
||||
.when(!self.compact, |this| {
|
||||
if self.label.is_none() && self.children.is_empty() {
|
||||
// Icon Button
|
||||
match self.size {
|
||||
Size::Size(px) => this.size(px),
|
||||
Size::XSmall => {
|
||||
if self.cta {
|
||||
this.w_10().h_5()
|
||||
} else {
|
||||
this.size_5()
|
||||
}
|
||||
}
|
||||
Size::Small => {
|
||||
if self.cta {
|
||||
this.w_12().h_6()
|
||||
} else {
|
||||
this.size_6()
|
||||
}
|
||||
}
|
||||
Size::Medium => {
|
||||
if self.cta {
|
||||
this.w_12().h_7()
|
||||
} else {
|
||||
this.size_7()
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if self.cta {
|
||||
this.w_16().h_9()
|
||||
} else {
|
||||
this.size_9()
|
||||
}
|
||||
}
|
||||
Size::XSmall => this.size_5(),
|
||||
Size::Small => this.size_6(),
|
||||
Size::Medium => this.size_7(),
|
||||
_ => this.size_9(),
|
||||
}
|
||||
} else {
|
||||
// Normal Button
|
||||
@@ -402,8 +371,6 @@ impl RenderOnce for Button {
|
||||
Size::XSmall => {
|
||||
if self.icon.is_some() {
|
||||
this.h_6().pl_2().pr_2p5()
|
||||
} else if self.cta {
|
||||
this.h_6().px_4()
|
||||
} else {
|
||||
this.h_6().px_2()
|
||||
}
|
||||
@@ -411,8 +378,6 @@ impl RenderOnce for Button {
|
||||
Size::Small => {
|
||||
if self.icon.is_some() {
|
||||
this.h_7().pl_2().pr_2p5()
|
||||
} else if self.cta {
|
||||
this.h_7().px_4()
|
||||
} else {
|
||||
this.h_7().px_2()
|
||||
}
|
||||
@@ -434,13 +399,27 @@ impl RenderOnce for Button {
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
|
||||
.refine_style(&self.style)
|
||||
.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
|
||||
// Stop handle any click event when disabled.
|
||||
// To avoid handle dropdown menu open when button is disabled.
|
||||
if self.disabled {
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
// Avoid focus on mouse down.
|
||||
window.prevent_default();
|
||||
})
|
||||
.when_some(self.on_click.filter(|_| clickable), |this, on_click| {
|
||||
.when_some(self.on_click, |this, on_click| {
|
||||
this.on_click(move |event, window, cx| {
|
||||
(on_click)(event, window, cx);
|
||||
// Stop handle any click event when disabled.
|
||||
// To avoid handle dropdown menu open when button is disabled.
|
||||
if !clickable {
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
|
||||
on_click(event, window, cx);
|
||||
})
|
||||
})
|
||||
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
|
||||
@@ -451,7 +430,6 @@ impl RenderOnce for Button {
|
||||
.child({
|
||||
h_flex()
|
||||
.id("label")
|
||||
.when(self.reverse, |this| this.flex_row_reverse())
|
||||
.justify_center()
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.text_xs().gap_1(),
|
||||
@@ -463,22 +441,18 @@ impl RenderOnce for Button {
|
||||
this.child(icon.with_size(icon_size))
|
||||
})
|
||||
})
|
||||
.when(self.loading, |this| {
|
||||
this.child(
|
||||
Indicator::new()
|
||||
.when_some(self.loading_icon, |this, icon| this.icon(icon)),
|
||||
)
|
||||
})
|
||||
.when(self.loading, |this| this.child(Indicator::new()))
|
||||
.when_some(self.label, |this, label| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.line_height(relative(1.))
|
||||
.child(label)
|
||||
.when(self.bold, |this| this.font_semibold()),
|
||||
)
|
||||
this.child(div().flex_none().line_height(relative(1.)).child(label))
|
||||
})
|
||||
.children(self.children)
|
||||
.when(self.caret, |this| {
|
||||
this.justify_between().gap_0p5().child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.small()
|
||||
.text_color(cx.theme().text_muted),
|
||||
)
|
||||
})
|
||||
})
|
||||
.text_color(normal_style.fg)
|
||||
.when(!self.disabled && !self.selected, |this| {
|
||||
@@ -496,6 +470,17 @@ impl RenderOnce for Button {
|
||||
let selected_style = style.selected(cx);
|
||||
this.bg(selected_style.bg).text_color(selected_style.fg)
|
||||
})
|
||||
.when(self.selected && self.underline, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_0()
|
||||
.left_0()
|
||||
.h_px()
|
||||
.w_full()
|
||||
.bg(cx.theme().element_background),
|
||||
)
|
||||
})
|
||||
.when(self.disabled, |this| {
|
||||
let disabled_style = style.disabled(cx);
|
||||
this.cursor_not_allowed()
|
||||
|
||||
@@ -61,8 +61,8 @@ impl RenderOnce for Divider {
|
||||
.absolute()
|
||||
.rounded_full()
|
||||
.map(|this| match self.axis {
|
||||
Axis::Vertical => this.w(px(2.)).h_full(),
|
||||
Axis::Horizontal => this.h(px(2.)).w_full(),
|
||||
Axis::Vertical => this.w(px(1.)).h_full(),
|
||||
Axis::Horizontal => this.h(px(1.)).w_full(),
|
||||
})
|
||||
.bg(self.color.unwrap_or(cx.theme().border_variant)),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
use crate::button::Button;
|
||||
use crate::popup_menu::PopupMenu;
|
||||
use crate::menu::PopupMenu;
|
||||
|
||||
pub enum PanelEvent {
|
||||
ZoomIn,
|
||||
|
||||
@@ -14,7 +14,7 @@ use super::stack_panel::StackPanel;
|
||||
use super::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::dock_area::panel::Panel;
|
||||
use crate::popup_menu::{PopupMenu, PopupMenuExt};
|
||||
use crate::menu::{DropdownMenu, PopupMenu};
|
||||
use crate::tab::tab_bar::TabBar;
|
||||
use crate::tab::Tab;
|
||||
use crate::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt};
|
||||
@@ -423,7 +423,7 @@ impl TabPanel {
|
||||
.when(self.is_zoomed, |this| {
|
||||
this.child(
|
||||
Button::new("zoom")
|
||||
.icon(IconName::ArrowIn)
|
||||
.icon(IconName::Zoom)
|
||||
.small()
|
||||
.ghost()
|
||||
.tooltip("Zoom Out")
|
||||
@@ -442,7 +442,7 @@ impl TabPanel {
|
||||
.small()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.popup_menu({
|
||||
.dropdown_menu({
|
||||
let zoomable = state.zoomable;
|
||||
let closable = state.closable;
|
||||
|
||||
|
||||
27
crates/ui/src/element_ext.rs
Normal file
27
crates/ui/src/element_ext.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use gpui::{canvas, App, Bounds, ParentElement, Pixels, Styled as _, Window};
|
||||
|
||||
/// A trait to extend [`gpui::Element`] with additional functionality.
|
||||
pub trait ElementExt: ParentElement + Sized {
|
||||
/// Add a prepaint callback to the element.
|
||||
///
|
||||
/// This is a helper method to get the bounds of the element after paint.
|
||||
///
|
||||
/// The first argument is the bounds of the element in pixels.
|
||||
///
|
||||
/// See also [`gpui::canvas`].
|
||||
fn on_prepaint<F>(self, f: F) -> Self
|
||||
where
|
||||
F: FnOnce(Bounds<Pixels>, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
self.child(
|
||||
canvas(
|
||||
move |bounds, window, cx| f(bounds, window, cx),
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ParentElement> ElementExt for T {}
|
||||
294
crates/ui/src/geometry.rs
Normal file
294
crates/ui/src/geometry.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
use std::fmt::{self, Debug, Display, Formatter};
|
||||
|
||||
use gpui::{AbsoluteLength, Axis, Corner, Length, Pixels};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A enum for defining the placement of the element.
|
||||
///
|
||||
/// See also: [`Side`] if you need to define the left, right side.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Placement {
|
||||
#[serde(rename = "top")]
|
||||
Top,
|
||||
#[serde(rename = "bottom")]
|
||||
Bottom,
|
||||
#[serde(rename = "left")]
|
||||
Left,
|
||||
#[serde(rename = "right")]
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Display for Placement {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Placement::Top => write!(f, "Top"),
|
||||
Placement::Bottom => write!(f, "Bottom"),
|
||||
Placement::Left => write!(f, "Left"),
|
||||
Placement::Right => write!(f, "Right"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
#[inline]
|
||||
pub fn is_horizontal(&self) -> bool {
|
||||
matches!(self, Placement::Left | Placement::Right)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
matches!(self, Placement::Top | Placement::Bottom)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn axis(&self) -> Axis {
|
||||
match self {
|
||||
Placement::Top | Placement::Bottom => Axis::Vertical,
|
||||
Placement::Left | Placement::Right => Axis::Horizontal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(crate) 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.
|
||||
///
|
||||
/// See also: [`Placement`] if you need to define the 4 edges.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Side {
|
||||
#[serde(rename = "left")]
|
||||
Left,
|
||||
#[serde(rename = "right")]
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// Returns true if the side is left.
|
||||
#[inline]
|
||||
pub fn is_left(&self) -> bool {
|
||||
matches!(self, Self::Left)
|
||||
}
|
||||
|
||||
/// Returns true if the side is right.
|
||||
#[inline]
|
||||
pub fn is_right(&self) -> bool {
|
||||
matches!(self, Self::Right)
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait to extend the [`Axis`] enum with utility methods.
|
||||
pub trait AxisExt {
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn is_horizontal(self) -> bool;
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn is_vertical(self) -> bool;
|
||||
}
|
||||
|
||||
impl AxisExt for Axis {
|
||||
#[inline]
|
||||
fn is_horizontal(self) -> bool {
|
||||
self == Axis::Horizontal
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_vertical(self) -> bool {
|
||||
self == Axis::Vertical
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for converting [`Pixels`] to `f32` and `f64`.
|
||||
pub trait PixelsExt {
|
||||
fn as_f32(&self) -> f32;
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn as_f64(self) -> f64;
|
||||
}
|
||||
impl PixelsExt for Pixels {
|
||||
fn as_f32(&self) -> f32 {
|
||||
f32::from(self)
|
||||
}
|
||||
|
||||
fn as_f64(self) -> f64 {
|
||||
f64::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait to extend the [`Length`] enum with utility methods.
|
||||
pub trait LengthExt {
|
||||
/// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`.
|
||||
///
|
||||
/// If the [`Length`] is [`Length::Auto`], it returns `None`.
|
||||
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels>;
|
||||
}
|
||||
|
||||
impl LengthExt for Length {
|
||||
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels> {
|
||||
match self {
|
||||
Length::Auto => None,
|
||||
Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A struct for defining the edges of an element.
|
||||
///
|
||||
/// A extend version of [`gpui::Edges`] to serialize/deserialize.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[repr(C)]
|
||||
pub struct Edges<T: Clone + Debug + Default + PartialEq> {
|
||||
/// The size of the top edge.
|
||||
pub top: T,
|
||||
/// The size of the right edge.
|
||||
pub right: T,
|
||||
/// The size of the bottom edge.
|
||||
pub bottom: T,
|
||||
/// The size of the left edge.
|
||||
pub left: T,
|
||||
}
|
||||
|
||||
impl<T> Edges<T>
|
||||
where
|
||||
T: Clone + Debug + Default + PartialEq,
|
||||
{
|
||||
/// Creates a new `Edges` instance with all edges set to the same value.
|
||||
pub fn all(value: T) -> Self {
|
||||
Self {
|
||||
top: value.clone(),
|
||||
right: value.clone(),
|
||||
bottom: value.clone(),
|
||||
left: value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,127 +9,113 @@ use crate::{Sizable, Size};
|
||||
|
||||
#[derive(IntoElement, Clone)]
|
||||
pub enum IconName {
|
||||
ArrowIn,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
CaretUp,
|
||||
Boom,
|
||||
ChevronDown,
|
||||
CaretDown,
|
||||
CaretDownFill,
|
||||
CaretRight,
|
||||
CaretUp,
|
||||
Check,
|
||||
CheckCircle,
|
||||
CheckCircleFill,
|
||||
Close,
|
||||
CloseCircle,
|
||||
CloseCircleFill,
|
||||
Copy,
|
||||
Edit,
|
||||
Door,
|
||||
Ellipsis,
|
||||
Encryption,
|
||||
Emoji,
|
||||
Eye,
|
||||
EyeOff,
|
||||
EmojiFill,
|
||||
Info,
|
||||
Invite,
|
||||
Inbox,
|
||||
InboxFill,
|
||||
Link,
|
||||
Loader,
|
||||
Logout,
|
||||
Moon,
|
||||
PanelBottom,
|
||||
PanelBottomOpen,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRight,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Plus,
|
||||
PlusFill,
|
||||
PlusCircleFill,
|
||||
Group,
|
||||
ResizeCorner,
|
||||
PlusCircle,
|
||||
Profile,
|
||||
Relay,
|
||||
Reply,
|
||||
Report,
|
||||
Refresh,
|
||||
Signal,
|
||||
Search,
|
||||
Settings,
|
||||
Server,
|
||||
SortAscending,
|
||||
SortDescending,
|
||||
Sun,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
Ship,
|
||||
Shield,
|
||||
Upload,
|
||||
OpenUrl,
|
||||
Usb,
|
||||
PanelLeft,
|
||||
PanelLeftOpen,
|
||||
PanelRight,
|
||||
PanelRightOpen,
|
||||
PanelBottom,
|
||||
PanelBottomOpen,
|
||||
PaperPlaneFill,
|
||||
Warning,
|
||||
WindowClose,
|
||||
WindowMaximize,
|
||||
WindowMinimize,
|
||||
WindowRestore,
|
||||
Fistbump,
|
||||
FistbumpFill,
|
||||
Zoom,
|
||||
}
|
||||
|
||||
impl IconName {
|
||||
pub fn path(self) -> SharedString {
|
||||
match self {
|
||||
Self::ArrowIn => "icons/arrows-in.svg",
|
||||
Self::ArrowDown => "icons/arrow-down.svg",
|
||||
Self::ArrowLeft => "icons/arrow-left.svg",
|
||||
Self::ArrowRight => "icons/arrow-right.svg",
|
||||
Self::ArrowUp => "icons/arrow-up.svg",
|
||||
Self::Boom => "icons/boom.svg",
|
||||
Self::ChevronDown => "icons/chevron-down.svg",
|
||||
Self::CaretDown => "icons/caret-down.svg",
|
||||
Self::CaretRight => "icons/caret-right.svg",
|
||||
Self::CaretUp => "icons/caret-up.svg",
|
||||
Self::CaretDown => "icons/caret-down.svg",
|
||||
Self::CaretDownFill => "icons/caret-down-fill.svg",
|
||||
Self::Check => "icons/check.svg",
|
||||
Self::CheckCircle => "icons/check-circle.svg",
|
||||
Self::CheckCircleFill => "icons/check-circle-fill.svg",
|
||||
Self::Close => "icons/close.svg",
|
||||
Self::CloseCircle => "icons/close-circle.svg",
|
||||
Self::CloseCircleFill => "icons/close-circle-fill.svg",
|
||||
Self::Copy => "icons/copy.svg",
|
||||
Self::Edit => "icons/edit.svg",
|
||||
Self::Door => "icons/door.svg",
|
||||
Self::Ellipsis => "icons/ellipsis.svg",
|
||||
Self::Emoji => "icons/emoji.svg",
|
||||
Self::Eye => "icons/eye.svg",
|
||||
Self::Encryption => "icons/encryption.svg",
|
||||
Self::EmojiFill => "icons/emoji-fill.svg",
|
||||
Self::EyeOff => "icons/eye-off.svg",
|
||||
Self::Info => "icons/info.svg",
|
||||
Self::Invite => "icons/invite.svg",
|
||||
Self::Inbox => "icons/inbox.svg",
|
||||
Self::InboxFill => "icons/inbox-fill.svg",
|
||||
Self::Link => "icons/link.svg",
|
||||
Self::Loader => "icons/loader.svg",
|
||||
Self::Logout => "icons/logout.svg",
|
||||
Self::Moon => "icons/moon.svg",
|
||||
Self::PanelBottom => "icons/panel-bottom.svg",
|
||||
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
|
||||
Self::PanelLeft => "icons/panel-left.svg",
|
||||
Self::PanelLeftClose => "icons/panel-left-close.svg",
|
||||
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
||||
Self::PanelRight => "icons/panel-right.svg",
|
||||
Self::PanelRightClose => "icons/panel-right-close.svg",
|
||||
Self::PanelRightOpen => "icons/panel-right-open.svg",
|
||||
Self::Plus => "icons/plus.svg",
|
||||
Self::PlusFill => "icons/plus-fill.svg",
|
||||
Self::PlusCircleFill => "icons/plus-circle-fill.svg",
|
||||
Self::Group => "icons/group.svg",
|
||||
Self::ResizeCorner => "icons/resize-corner.svg",
|
||||
Self::PlusCircle => "icons/plus-circle.svg",
|
||||
Self::Profile => "icons/profile.svg",
|
||||
Self::Relay => "icons/relay.svg",
|
||||
Self::Reply => "icons/reply.svg",
|
||||
Self::Report => "icons/report.svg",
|
||||
Self::Refresh => "icons/refresh.svg",
|
||||
Self::Signal => "icons/signal.svg",
|
||||
Self::Search => "icons/search.svg",
|
||||
Self::Settings => "icons/settings.svg",
|
||||
Self::Server => "icons/server.svg",
|
||||
Self::SortAscending => "icons/sort-ascending.svg",
|
||||
Self::SortDescending => "icons/sort-descending.svg",
|
||||
Self::Sun => "icons/sun.svg",
|
||||
Self::ThumbsDown => "icons/thumbs-down.svg",
|
||||
Self::ThumbsUp => "icons/thumbs-up.svg",
|
||||
Self::Ship => "icons/ship.svg",
|
||||
Self::Shield => "icons/shield.svg",
|
||||
Self::Upload => "icons/upload.svg",
|
||||
Self::OpenUrl => "icons/open-url.svg",
|
||||
Self::Usb => "icons/usb.svg",
|
||||
Self::PanelLeft => "icons/panel-left.svg",
|
||||
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
||||
Self::PanelRight => "icons/panel-right.svg",
|
||||
Self::PanelRightOpen => "icons/panel-right-open.svg",
|
||||
Self::PanelBottom => "icons/panel-bottom.svg",
|
||||
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
|
||||
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
|
||||
Self::Warning => "icons/warning.svg",
|
||||
Self::WindowClose => "icons/window-close.svg",
|
||||
Self::WindowMaximize => "icons/window-maximize.svg",
|
||||
Self::WindowMinimize => "icons/window-minimize.svg",
|
||||
Self::WindowRestore => "icons/window-restore.svg",
|
||||
Self::Fistbump => "icons/fistbump.svg",
|
||||
Self::FistbumpFill => "icons/fistbump-fill.svg",
|
||||
Self::Zoom => "icons/zoom.svg",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
69
crates/ui/src/index_path.rs
Normal file
69
crates/ui/src/index_path.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
use gpui::ElementId;
|
||||
|
||||
/// Represents an index path in a list, which consists of a section index,
|
||||
///
|
||||
/// The default values for section, row, and column are all set to 0.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct IndexPath {
|
||||
/// The section index.
|
||||
pub section: usize,
|
||||
/// The item index in the section.
|
||||
pub row: usize,
|
||||
/// The column index.
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
impl From<IndexPath> for ElementId {
|
||||
fn from(path: IndexPath) -> Self {
|
||||
ElementId::Name(format!("index-path({},{},{})", path.section, path.row, path.column).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for IndexPath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"IndexPath(section: {}, row: {}, column: {})",
|
||||
self.section, self.row, self.column
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexPath {
|
||||
/// Create a new index path with the specified section and row.
|
||||
///
|
||||
/// The `section` is set to 0 by default.
|
||||
/// The `column` is set to 0 by default.
|
||||
pub fn new(row: usize) -> Self {
|
||||
IndexPath {
|
||||
section: 0,
|
||||
row,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the section for the index path.
|
||||
pub fn section(mut self, section: usize) -> Self {
|
||||
self.section = section;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the row for the index path.
|
||||
pub fn row(mut self, row: usize) -> Self {
|
||||
self.row = row;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the column for the index path.
|
||||
pub fn column(mut self, column: usize) -> Self {
|
||||
self.column = column;
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if the self is equal to the given index path (Same section and row).
|
||||
pub fn eq_row(&self, index: IndexPath) -> bool {
|
||||
self.section == index.section && self.row == index.row
|
||||
}
|
||||
}
|
||||
@@ -1009,8 +1009,7 @@ impl InputState {
|
||||
let left_part = self.text.slice(0..offset).to_string();
|
||||
|
||||
UnicodeSegmentation::split_word_bound_indices(left_part.as_str())
|
||||
.filter(|(_, s)| !s.trim_start().is_empty())
|
||||
.next_back()
|
||||
.rfind(|(_, s)| !s.trim_start().is_empty())
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ impl Styled for TextInput {
|
||||
impl RenderOnce for TextInput {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
const LINE_HEIGHT: Rems = Rems(1.25);
|
||||
|
||||
let font = window.text_style().font();
|
||||
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
|
||||
|
||||
@@ -155,6 +156,7 @@ impl RenderOnce for TextInput {
|
||||
});
|
||||
|
||||
let state = self.state.read(cx);
|
||||
let focused = state.focus_handle.is_focused(window) && !state.disabled;
|
||||
|
||||
let gap_x = match self.size {
|
||||
Size::Small => px(4.),
|
||||
@@ -266,7 +268,16 @@ impl RenderOnce for TextInput {
|
||||
.when_some(self.height, |this, height| this.h(height))
|
||||
})
|
||||
.when(self.appearance, |this| {
|
||||
this.bg(bg).rounded(cx.theme().radius)
|
||||
this.bg(bg)
|
||||
.rounded(cx.theme().radius)
|
||||
.when(self.bordered, |this| {
|
||||
this.border_color(cx.theme().border)
|
||||
.border_1()
|
||||
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||
.when(focused && self.focus_bordered, |this| {
|
||||
this.border_color(cx.theme().border_focused)
|
||||
})
|
||||
})
|
||||
})
|
||||
.items_center()
|
||||
.gap(gap_x)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
pub use anchored::*;
|
||||
pub use element_ext::ElementExt;
|
||||
pub use event::InteractiveElementExt;
|
||||
pub use focusable::FocusableCycle;
|
||||
pub use geometry::*;
|
||||
pub use icon::*;
|
||||
pub use index_path::IndexPath;
|
||||
pub use kbd::*;
|
||||
pub use menu::{context_menu, popup_menu};
|
||||
pub use root::{ContextModal, Root};
|
||||
pub use root::{window_paddings, Root};
|
||||
pub use styled::*;
|
||||
pub use window_border::{window_border, WindowBorder};
|
||||
pub use window_ext::*;
|
||||
|
||||
pub use crate::Disableable;
|
||||
|
||||
@@ -16,7 +19,6 @@ pub mod button;
|
||||
pub mod checkbox;
|
||||
pub mod divider;
|
||||
pub mod dock_area;
|
||||
pub mod dropdown;
|
||||
pub mod history;
|
||||
pub mod indicator;
|
||||
pub mod input;
|
||||
@@ -32,20 +34,23 @@ pub mod switch;
|
||||
pub mod tab;
|
||||
pub mod tooltip;
|
||||
|
||||
mod anchored;
|
||||
mod element_ext;
|
||||
mod event;
|
||||
mod focusable;
|
||||
mod geometry;
|
||||
mod icon;
|
||||
mod index_path;
|
||||
mod kbd;
|
||||
mod root;
|
||||
mod styled;
|
||||
mod window_border;
|
||||
mod window_ext;
|
||||
|
||||
/// Initialize the UI module.
|
||||
///
|
||||
/// This must be called before using any of the UI components.
|
||||
/// You can initialize the UI module at your application's entry point.
|
||||
pub fn init(cx: &mut gpui::App) {
|
||||
dropdown::init(cx);
|
||||
input::init(cx);
|
||||
list::init(cx);
|
||||
modal::init(cx);
|
||||
|
||||
221
crates/ui/src/list/cache.rs
Normal file
221
crates/ui/src/list/cache.rs
Normal file
@@ -0,0 +1,221 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, Pixels, Size};
|
||||
|
||||
use crate::IndexPath;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RowEntry {
|
||||
Entry(IndexPath),
|
||||
SectionHeader(usize),
|
||||
SectionFooter(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(crate) struct MeasuredEntrySize {
|
||||
pub(crate) item_size: Size<Pixels>,
|
||||
pub(crate) section_header_size: Size<Pixels>,
|
||||
pub(crate) section_footer_size: Size<Pixels>,
|
||||
}
|
||||
|
||||
impl RowEntry {
|
||||
#[inline]
|
||||
#[allow(unused)]
|
||||
pub(crate) fn is_section_header(&self) -> bool {
|
||||
matches!(self, RowEntry::SectionHeader(_))
|
||||
}
|
||||
|
||||
pub(crate) fn eq_index_path(&self, path: &IndexPath) -> bool {
|
||||
match self {
|
||||
RowEntry::Entry(index_path) => index_path == path,
|
||||
RowEntry::SectionHeader(_) | RowEntry::SectionFooter(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub(crate) fn index(&self) -> IndexPath {
|
||||
match self {
|
||||
RowEntry::Entry(index_path) => *index_path,
|
||||
RowEntry::SectionHeader(ix) => IndexPath::default().section(*ix),
|
||||
RowEntry::SectionFooter(ix) => IndexPath::default().section(*ix),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[allow(unused)]
|
||||
pub(crate) fn is_section_footer(&self) -> bool {
|
||||
matches!(self, RowEntry::SectionFooter(_))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_entry(&self) -> bool {
|
||||
matches!(self, RowEntry::Entry(_))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[allow(unused)]
|
||||
pub(crate) fn section_ix(&self) -> Option<usize> {
|
||||
match self {
|
||||
RowEntry::SectionHeader(ix) | RowEntry::SectionFooter(ix) => Some(*ix),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub(crate) struct RowsCache {
|
||||
/// Only have section's that have rows.
|
||||
pub(crate) entities: Rc<Vec<RowEntry>>,
|
||||
pub(crate) items_count: usize,
|
||||
/// The sections, the item is number of rows in each section.
|
||||
pub(crate) sections: Rc<Vec<usize>>,
|
||||
pub(crate) entries_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
measured_size: MeasuredEntrySize,
|
||||
}
|
||||
|
||||
impl RowsCache {
|
||||
pub(crate) fn get(&self, flatten_ix: usize) -> Option<RowEntry> {
|
||||
self.entities.get(flatten_ix).cloned()
|
||||
}
|
||||
|
||||
/// Returns the number of flattened rows (Includes header, item, footer).
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.entities.len()
|
||||
}
|
||||
|
||||
/// Return the number of items in the cache.
|
||||
pub(crate) fn items_count(&self) -> usize {
|
||||
self.items_count
|
||||
}
|
||||
|
||||
/// Returns the index of the Entry with given path in the flattened rows.
|
||||
pub(crate) fn position_of(&self, path: &IndexPath) -> Option<usize> {
|
||||
self.entities
|
||||
.iter()
|
||||
.position(|p| p.is_entry() && p.eq_index_path(path))
|
||||
}
|
||||
|
||||
/// Return prev row, if the row is the first in the first section, goes to the last row.
|
||||
///
|
||||
/// Empty rows section are skipped.
|
||||
pub(crate) fn prev(&self, path: Option<IndexPath>) -> IndexPath {
|
||||
let path = path.unwrap_or_default();
|
||||
let Some(pos) = self.position_of(&path) else {
|
||||
return self
|
||||
.entities
|
||||
.iter()
|
||||
.rfind(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
.unwrap_or_default();
|
||||
};
|
||||
|
||||
if let Some(path) = self
|
||||
.entities
|
||||
.iter()
|
||||
.take(pos)
|
||||
.rev()
|
||||
.find(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
{
|
||||
path
|
||||
} else {
|
||||
self.entities
|
||||
.iter()
|
||||
.rfind(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the next row, if the row is the last in the last section, goes to the first row.
|
||||
///
|
||||
/// Empty rows section are skipped.
|
||||
pub(crate) fn next(&self, path: Option<IndexPath>) -> IndexPath {
|
||||
let Some(mut path) = path else {
|
||||
return IndexPath::default();
|
||||
};
|
||||
|
||||
let Some(pos) = self.position_of(&path) else {
|
||||
return self
|
||||
.entities
|
||||
.iter()
|
||||
.find(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
.unwrap_or_default();
|
||||
};
|
||||
|
||||
if let Some(next_path) = self
|
||||
.entities
|
||||
.iter()
|
||||
.skip(pos + 1)
|
||||
.find(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
{
|
||||
path = next_path;
|
||||
} else {
|
||||
path = self
|
||||
.entities
|
||||
.iter()
|
||||
.find(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_if_needed<F>(
|
||||
&mut self,
|
||||
sections_count: usize,
|
||||
measured_size: MeasuredEntrySize,
|
||||
cx: &App,
|
||||
rows_count_f: F,
|
||||
) where
|
||||
F: Fn(usize, &App) -> usize,
|
||||
{
|
||||
let mut new_sections = vec![];
|
||||
for section_ix in 0..sections_count {
|
||||
new_sections.push(rows_count_f(section_ix, cx));
|
||||
}
|
||||
|
||||
let need_update = new_sections != *self.sections || self.measured_size != measured_size;
|
||||
|
||||
if !need_update {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut entries_sizes = vec![];
|
||||
let mut total_items_count = 0;
|
||||
self.measured_size = measured_size;
|
||||
self.sections = Rc::new(new_sections);
|
||||
self.entities = Rc::new(
|
||||
self.sections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(section, items_count)| {
|
||||
total_items_count += items_count;
|
||||
let mut children = vec![];
|
||||
if *items_count == 0 {
|
||||
return children;
|
||||
}
|
||||
|
||||
children.push(RowEntry::SectionHeader(section));
|
||||
entries_sizes.push(measured_size.section_header_size);
|
||||
for row in 0..*items_count {
|
||||
children.push(RowEntry::Entry(IndexPath {
|
||||
section,
|
||||
row,
|
||||
..Default::default()
|
||||
}));
|
||||
entries_sizes.push(measured_size.item_size);
|
||||
}
|
||||
children.push(RowEntry::SectionFooter(section));
|
||||
entries_sizes.push(measured_size.section_footer_size);
|
||||
children
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
self.entries_sizes = Rc::new(entries_sizes);
|
||||
self.items_count = total_items_count;
|
||||
}
|
||||
}
|
||||
171
crates/ui/src/list/delegate.rs
Normal file
171
crates/ui/src/list/delegate.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Task, Window};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::list::loading::Loading;
|
||||
use crate::list::ListState;
|
||||
use crate::{h_flex, Icon, IconName, IndexPath, Selectable};
|
||||
|
||||
/// A delegate for the List.
|
||||
#[allow(unused)]
|
||||
pub trait ListDelegate: Sized + 'static {
|
||||
type Item: Selectable + IntoElement;
|
||||
|
||||
/// When Query Input change, this method will be called.
|
||||
/// You can perform search here.
|
||||
fn perform_search(
|
||||
&mut self,
|
||||
query: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Task<()> {
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
/// Return the number of sections in the list, default is 1.
|
||||
///
|
||||
/// Min value is 1.
|
||||
fn sections_count(&self, cx: &App) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
/// Return the number of items in the section at the given index.
|
||||
///
|
||||
/// NOTE: Only the sections with items_count > 0 will be rendered. If the section has 0 items,
|
||||
/// the section header and footer will also be skipped.
|
||||
fn items_count(&self, section: usize, cx: &App) -> usize;
|
||||
|
||||
/// Render the item at the given index.
|
||||
///
|
||||
/// Return None will skip the item.
|
||||
///
|
||||
/// NOTE: Every item should have same height.
|
||||
fn render_item(
|
||||
&mut self,
|
||||
ix: IndexPath,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Option<Self::Item>;
|
||||
|
||||
/// Render the section header at the given index, default is None.
|
||||
///
|
||||
/// NOTE: Every header should have same height.
|
||||
fn render_section_header(
|
||||
&mut self,
|
||||
section: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Option<impl IntoElement> {
|
||||
None::<AnyElement>
|
||||
}
|
||||
|
||||
/// Render the section footer at the given index, default is None.
|
||||
///
|
||||
/// NOTE: Every footer should have same height.
|
||||
fn render_section_footer(
|
||||
&mut self,
|
||||
section: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Option<impl IntoElement> {
|
||||
None::<AnyElement>
|
||||
}
|
||||
|
||||
/// Return a Element to show when list is empty.
|
||||
fn render_empty(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> impl IntoElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_color(cx.theme().text_muted.opacity(0.6))
|
||||
.child(Icon::new(IconName::Inbox).size_12())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Returns Some(AnyElement) to render the initial state of the list.
|
||||
///
|
||||
/// This can be used to show a view for the list before the user has
|
||||
/// interacted with it.
|
||||
///
|
||||
/// For example: The last search results, or the last selected item.
|
||||
///
|
||||
/// Default is None, that means no initial state.
|
||||
fn render_initial(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Option<AnyElement> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the loading state to show the loading view.
|
||||
fn loading(&self, cx: &App) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns a Element to show when loading, default is built-in Skeleton
|
||||
/// loading view.
|
||||
fn render_loading(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> impl IntoElement {
|
||||
Loading
|
||||
}
|
||||
|
||||
/// Set the selected index, just store the ix, don't confirm.
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: Option<IndexPath>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
);
|
||||
|
||||
/// Set the index of the item that has been right clicked.
|
||||
fn set_right_clicked_index(
|
||||
&mut self,
|
||||
ix: Option<IndexPath>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) {
|
||||
}
|
||||
|
||||
/// Set the confirm and give the selected index,
|
||||
/// this is means user have clicked the item or pressed Enter.
|
||||
///
|
||||
/// This will always to `set_selected_index` before confirm.
|
||||
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
|
||||
}
|
||||
|
||||
/// Cancel the selection, e.g.: Pressed ESC.
|
||||
fn cancel(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {}
|
||||
|
||||
/// Return true to enable load more data when scrolling to the bottom.
|
||||
///
|
||||
/// Default: false
|
||||
fn has_more(&self, cx: &App) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns a threshold value (n entities), of course,
|
||||
/// when scrolling to the bottom, the remaining number of rows
|
||||
/// triggers `load_more`.
|
||||
///
|
||||
/// This should smaller than the total number of first load rows.
|
||||
///
|
||||
/// Default: 20 entities (section header, footer and row)
|
||||
fn load_more_threshold(&self) -> usize {
|
||||
20
|
||||
}
|
||||
|
||||
/// Load more data when the table is scrolled to the bottom.
|
||||
///
|
||||
/// This will performed in a background task.
|
||||
///
|
||||
/// This is always called when the table is near the bottom,
|
||||
/// so you must check if there is more data to load or lock
|
||||
/// the loading state.
|
||||
fn load_more(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,57 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, MouseButton,
|
||||
MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, Styled,
|
||||
Window,
|
||||
div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement,
|
||||
MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _,
|
||||
StyleRefinement, Styled, Window,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable as _};
|
||||
use crate::{h_flex, Disableable, Icon, Selectable, Sizable as _, StyledExt};
|
||||
|
||||
type OnClick = Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>;
|
||||
type OnMouseEnter = Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>;
|
||||
type Suffix = Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
enum ListItemMode {
|
||||
#[default]
|
||||
Entry,
|
||||
Separator,
|
||||
}
|
||||
|
||||
impl ListItemMode {
|
||||
#[inline]
|
||||
fn is_separator(&self) -> bool {
|
||||
matches!(self, ListItemMode::Separator)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ListItem {
|
||||
base: Stateful<Div>,
|
||||
mode: ListItemMode,
|
||||
style: StyleRefinement,
|
||||
disabled: bool,
|
||||
selected: bool,
|
||||
secondary_selected: bool,
|
||||
confirmed: bool,
|
||||
check_icon: Option<Icon>,
|
||||
on_click: OnClick,
|
||||
on_mouse_enter: OnMouseEnter,
|
||||
suffix: Suffix,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_mouse_enter: Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
suffix: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl ListItem {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
let id: ElementId = id.into();
|
||||
|
||||
Self {
|
||||
base: h_flex().id(id).gap_x_1().py_1().px_2().text_base(),
|
||||
mode: ListItemMode::Entry,
|
||||
base: h_flex().id(id),
|
||||
style: StyleRefinement::default(),
|
||||
disabled: false,
|
||||
selected: false,
|
||||
secondary_selected: false,
|
||||
confirmed: false,
|
||||
on_click: None,
|
||||
on_mouse_enter: None,
|
||||
@@ -43,9 +61,15 @@ impl ListItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set this list item to as a separator, it not able to be selected.
|
||||
pub fn separator(mut self) -> Self {
|
||||
self.mode = ListItemMode::Separator;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set to show check icon, default is None.
|
||||
pub fn check_icon(mut self, icon: IconName) -> Self {
|
||||
self.check_icon = Some(Icon::new(icon));
|
||||
pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.check_icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -111,11 +135,16 @@ impl Selectable for ListItem {
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
|
||||
fn secondary_selected(mut self, selected: bool) -> Self {
|
||||
self.secondary_selected = selected;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ListItem {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,35 +156,39 @@ impl ParentElement for ListItem {
|
||||
|
||||
impl RenderOnce for ListItem {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let is_active = self.selected || self.confirmed;
|
||||
let is_active = self.confirmed || self.selected;
|
||||
|
||||
let corner_radii = self.style.corner_radii.clone();
|
||||
|
||||
let _selected_style = StyleRefinement {
|
||||
corner_radii,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let is_selectable = !(self.disabled || self.mode.is_separator());
|
||||
|
||||
self.base
|
||||
.relative()
|
||||
.gap_x_1()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.text_base()
|
||||
.text_color(cx.theme().text)
|
||||
.relative()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.when_some(self.on_click, |this, on_click| {
|
||||
if !self.disabled {
|
||||
this.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, move |_, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.on_click(on_click)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
.refine_style(&self.style)
|
||||
.when(is_selectable, |this| {
|
||||
this.when_some(self.on_click, |this, on_click| this.on_click(on_click))
|
||||
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
|
||||
this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
|
||||
})
|
||||
.when(!is_active, |this| {
|
||||
this.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
})
|
||||
})
|
||||
.when(is_active, |this| this.bg(cx.theme().element_active))
|
||||
.when(!is_active && !self.disabled, |this| {
|
||||
this.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
})
|
||||
// Mouse enter
|
||||
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
|
||||
if !self.disabled {
|
||||
this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
|
||||
} else {
|
||||
this
|
||||
}
|
||||
.when(!is_selectable, |this| {
|
||||
this.text_color(cx.theme().text_muted)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -177,5 +210,17 @@ impl RenderOnce for ListItem {
|
||||
}),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| this.child(suffix(window, cx)))
|
||||
.map(|this| {
|
||||
if is_selectable && (self.selected || self.secondary_selected) {
|
||||
let bg = if self.selected {
|
||||
cx.theme().ghost_element_active
|
||||
} else {
|
||||
cx.theme().ghost_element_background
|
||||
};
|
||||
this.bg(bg)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ impl RenderOnce for LoadingItem {
|
||||
.gap_1p5()
|
||||
.overflow_hidden()
|
||||
.child(Skeleton::new().h_5().w_48().max_w_full())
|
||||
.child(Skeleton::new().secondary(true).h_3().w_64().max_w_full()),
|
||||
.child(Skeleton::new().secondary().h_3().w_64().max_w_full()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
pub(crate) mod cache;
|
||||
mod delegate;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod list;
|
||||
mod list_item;
|
||||
mod loading;
|
||||
mod separator_item;
|
||||
|
||||
pub use delegate::*;
|
||||
pub use list::*;
|
||||
pub use list_item::*;
|
||||
pub use separator_item::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Settings for List.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListSettings {
|
||||
/// Whether to use active highlight style on ListItem, default
|
||||
pub active_highlight: bool,
|
||||
}
|
||||
|
||||
impl Default for ListSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active_highlight: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
50
crates/ui/src/list/separator_item.rs
Normal file
50
crates/ui/src/list/separator_item.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use gpui::{AnyElement, ParentElement, RenderOnce, StyleRefinement};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::list::ListItem;
|
||||
use crate::{Selectable, StyledExt};
|
||||
|
||||
pub struct ListSeparatorItem {
|
||||
style: StyleRefinement,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl ListSeparatorItem {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
style: StyleRefinement::default(),
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ListSeparatorItem {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for ListSeparatorItem {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for ListSeparatorItem {
|
||||
fn selected(self, _: bool) -> Self {
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ListSeparatorItem {
|
||||
fn render(self, _: &mut gpui::Window, _: &mut gpui::App) -> impl gpui::IntoElement {
|
||||
ListItem::new("separator")
|
||||
.refine_style(&self.style)
|
||||
.children(self.children)
|
||||
.disabled(true)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity,
|
||||
Focusable, InteractiveElement as _, IntoElement, KeyBinding, OwnedMenu, ParentElement, Render,
|
||||
SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
||||
Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu,
|
||||
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
||||
};
|
||||
|
||||
use crate::actions::{Cancel, SelectLeft, SelectRight};
|
||||
use crate::button::{Button, ButtonVariants};
|
||||
use crate::popup_menu::PopupMenu;
|
||||
use crate::menu::PopupMenu;
|
||||
use crate::{h_flex, Selectable, Sizable};
|
||||
|
||||
const CONTEXT: &str = "AppMenuBar";
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
||||
@@ -22,67 +23,74 @@ pub fn init(cx: &mut App) {
|
||||
/// The application menu bar, for Windows and Linux.
|
||||
pub struct AppMenuBar {
|
||||
menus: Vec<Entity<AppMenu>>,
|
||||
selected_ix: Option<usize>,
|
||||
selected_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl AppMenuBar {
|
||||
/// Create a new app menu bar.
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
let menu_bar = cx.entity();
|
||||
let menus = cx
|
||||
.get_menus()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), window, cx))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
selected_ix: None,
|
||||
menus,
|
||||
}
|
||||
let mut this = Self {
|
||||
selected_index: None,
|
||||
menus: Vec::new(),
|
||||
};
|
||||
this.reload(cx);
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(selected_ix) = self.selected_ix else {
|
||||
/// Reload the menus from the app.
|
||||
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
let menu_bar = cx.entity();
|
||||
self.menus = cx
|
||||
.get_menus()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), cx))
|
||||
.collect();
|
||||
self.selected_index = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(selected_index) = self.selected_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
let new_ix = if selected_ix == 0 {
|
||||
let new_ix = if selected_index == 0 {
|
||||
self.menus.len().saturating_sub(1)
|
||||
} else {
|
||||
selected_ix.saturating_sub(1)
|
||||
selected_index.saturating_sub(1)
|
||||
};
|
||||
self.set_selected_ix(Some(new_ix), window, cx);
|
||||
self.set_selected_index(Some(new_ix), window, cx);
|
||||
}
|
||||
|
||||
fn move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(selected_ix) = self.selected_ix else {
|
||||
fn on_move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(selected_index) = self.selected_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
let new_ix = if selected_ix + 1 >= self.menus.len() {
|
||||
let new_ix = if selected_index + 1 >= self.menus.len() {
|
||||
0
|
||||
} else {
|
||||
selected_ix + 1
|
||||
selected_index + 1
|
||||
};
|
||||
self.set_selected_ix(Some(new_ix), window, cx);
|
||||
self.set_selected_index(Some(new_ix), window, cx);
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_selected_ix(None, window, cx);
|
||||
fn on_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_selected_index(None, window, cx);
|
||||
}
|
||||
|
||||
fn set_selected_ix(&mut self, ix: Option<usize>, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selected_ix = ix;
|
||||
fn set_selected_index(&mut self, ix: Option<usize>, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selected_index = ix;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn has_activated_menu(&self) -> bool {
|
||||
self.selected_ix.is_some()
|
||||
self.selected_index.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +99,9 @@ impl Render for AppMenuBar {
|
||||
h_flex()
|
||||
.id("app-menu-bar")
|
||||
.key_context(CONTEXT)
|
||||
.on_action(cx.listener(Self::move_left))
|
||||
.on_action(cx.listener(Self::move_right))
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.on_action(cx.listener(Self::on_move_left))
|
||||
.on_action(cx.listener(Self::on_move_right))
|
||||
.on_action(cx.listener(Self::on_cancel))
|
||||
.size_full()
|
||||
.gap_x_1()
|
||||
.overflow_x_scroll()
|
||||
@@ -117,7 +125,6 @@ impl AppMenu {
|
||||
ix: usize,
|
||||
menu: &OwnedMenu,
|
||||
menu_bar: Entity<AppMenuBar>,
|
||||
_: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let name = menu.name.clone();
|
||||
@@ -173,7 +180,7 @@ impl AppMenu {
|
||||
self._subscription.take();
|
||||
self.popup_menu.take();
|
||||
self.menu_bar.update(cx, |state, cx| {
|
||||
state.cancel(&Cancel, window, cx);
|
||||
state.on_cancel(&Cancel, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -183,11 +190,11 @@ impl AppMenu {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let is_selected = self.menu_bar.read(cx).selected_ix == Some(self.ix);
|
||||
let is_selected = self.menu_bar.read(cx).selected_index == Some(self.ix);
|
||||
|
||||
self.menu_bar.update(cx, |state, cx| {
|
||||
let new_ix = if is_selected { None } else { Some(self.ix) };
|
||||
state.set_selected_ix(new_ix, window, cx);
|
||||
state.set_selected_index(new_ix, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -202,7 +209,7 @@ impl AppMenu {
|
||||
}
|
||||
|
||||
self.menu_bar.update(cx, |state, cx| {
|
||||
state.set_selected_ix(Some(self.ix), window, cx);
|
||||
state.set_selected_index(Some(self.ix), window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -210,7 +217,7 @@ impl AppMenu {
|
||||
impl Render for AppMenu {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let menu_bar = self.menu_bar.read(cx);
|
||||
let is_selected = menu_bar.selected_ix == Some(self.ix);
|
||||
let is_selected = menu_bar.selected_index == Some(self.ix);
|
||||
|
||||
div()
|
||||
.id(self.ix)
|
||||
@@ -219,10 +226,15 @@ impl Render for AppMenu {
|
||||
Button::new("menu")
|
||||
.small()
|
||||
.py_0p5()
|
||||
.xsmall()
|
||||
.compact()
|
||||
.ghost()
|
||||
.label(self.name.clone())
|
||||
.selected(is_selected)
|
||||
.on_mouse_down(MouseButton::Left, |_, window, cx| {
|
||||
// Stop propagation to avoid dragging the window.
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.on_click(cx.listener(Self::handle_trigger_click)),
|
||||
)
|
||||
.on_hover(cx.listener(Self::handle_hover))
|
||||
|
||||
@@ -3,49 +3,66 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
anchored, deferred, div, px, relative, AnyElement, App, Context, Corner, DismissEvent, Element,
|
||||
ElementId, Entity, Focusable, GlobalElementId, InspectorElementId, InteractiveElement,
|
||||
IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Position, Stateful,
|
||||
Style, Subscription, Window,
|
||||
anchored, deferred, div, px, AnyElement, App, Context, Corner, DismissEvent, Element,
|
||||
ElementId, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
|
||||
InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
|
||||
StyleRefinement, Styled, Subscription, Window,
|
||||
};
|
||||
|
||||
use crate::popup_menu::PopupMenu;
|
||||
use crate::menu::PopupMenu;
|
||||
|
||||
pub trait ContextMenuExt: ParentElement + Sized {
|
||||
/// A extension trait for adding a context menu to an element.
|
||||
pub trait ContextMenuExt: ParentElement + Styled {
|
||||
/// Add a context menu to the element.
|
||||
///
|
||||
/// This will changed the element to be `relative` positioned, and add a child `ContextMenu` element.
|
||||
/// Because the `ContextMenu` element is positioned `absolute`, it will not affect the layout of the parent element.
|
||||
fn context_menu(
|
||||
self,
|
||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
self.child(ContextMenu::new("context-menu").menu(f))
|
||||
) -> ContextMenu<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
// Generate a unique ID based on the element's memory address to ensure
|
||||
// each context menu has its own state and doesn't share with others
|
||||
let id = format!("context-menu-{:p}", &self as *const _);
|
||||
ContextMenu::new(id, self).menu(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> ContextMenuExt for Stateful<E> where E: ParentElement {}
|
||||
impl<E: ParentElement + Styled> ContextMenuExt for E {}
|
||||
|
||||
/// A context menu that can be shown on right-click.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct ContextMenu {
|
||||
pub struct ContextMenu<E: ParentElement + Styled + Sized> {
|
||||
id: ElementId,
|
||||
menu:
|
||||
Option<Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>>,
|
||||
element: Option<E>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
|
||||
// This is not in use, just for style refinement forwarding.
|
||||
_ignore_style: StyleRefinement,
|
||||
anchor: Corner,
|
||||
}
|
||||
|
||||
impl ContextMenu {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
impl<E: ParentElement + Styled> ContextMenu<E> {
|
||||
/// Create a new context menu with the given ID.
|
||||
pub fn new(id: impl Into<ElementId>, element: E) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
element: Some(element),
|
||||
menu: None,
|
||||
anchor: Corner::TopLeft,
|
||||
_ignore_style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the context menu using the given builder function.
|
||||
#[must_use]
|
||||
pub fn menu<F>(mut self, builder: F) -> Self
|
||||
fn menu<F>(mut self, builder: F) -> Self
|
||||
where
|
||||
F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
{
|
||||
self.menu = Some(Box::new(builder));
|
||||
self.menu = Some(Rc::new(builder));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -67,7 +84,25 @@ impl ContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for ContextMenu {
|
||||
impl<E: ParentElement + Styled> ParentElement for ContextMenu<E> {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
if let Some(element) = &mut self.element {
|
||||
element.extend(elements);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: ParentElement + Styled> Styled for ContextMenu<E> {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
if let Some(element) = &mut self.element {
|
||||
element.style()
|
||||
} else {
|
||||
&mut self._ignore_style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: ParentElement + Styled + IntoElement + 'static> IntoElement for ContextMenu<E> {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
@@ -83,14 +118,14 @@ struct ContextMenuSharedState {
|
||||
}
|
||||
|
||||
pub struct ContextMenuState {
|
||||
menu_element: Option<AnyElement>,
|
||||
element: Option<AnyElement>,
|
||||
shared_state: Rc<RefCell<ContextMenuSharedState>>,
|
||||
}
|
||||
|
||||
impl Default for ContextMenuState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
menu_element: None,
|
||||
element: None,
|
||||
shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
|
||||
menu_view: None,
|
||||
open: false,
|
||||
@@ -101,8 +136,8 @@ impl Default for ContextMenuState {
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ContextMenu {
|
||||
type PrepaintState = ();
|
||||
impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
|
||||
type PrepaintState = Hitbox;
|
||||
type RequestLayoutState = ContextMenuState;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
@@ -113,7 +148,6 @@ impl Element for ContextMenu {
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
id: Option<&gpui::GlobalElementId>,
|
||||
@@ -121,71 +155,73 @@ impl Element for ContextMenu {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
// Set the layout style relative to the table view to get same size.
|
||||
style.position = Position::Absolute;
|
||||
style.flex_grow = 1.0;
|
||||
style.flex_shrink = 1.0;
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = relative(1.).into();
|
||||
|
||||
let anchor = self.anchor;
|
||||
|
||||
self.with_element_state(
|
||||
id.unwrap(),
|
||||
window,
|
||||
cx,
|
||||
|_, state: &mut ContextMenuState, window, cx| {
|
||||
|this, state: &mut ContextMenuState, window, cx| {
|
||||
let (position, open) = {
|
||||
let shared_state = state.shared_state.borrow();
|
||||
(shared_state.position, shared_state.open)
|
||||
};
|
||||
let menu_view = state.shared_state.borrow().menu_view.clone();
|
||||
let (menu_element, menu_layout_id) = if open {
|
||||
let mut menu_element = None;
|
||||
if open {
|
||||
let has_menu_item = menu_view
|
||||
.as_ref()
|
||||
.map(|menu| !menu.read(cx).is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_menu_item {
|
||||
let mut menu_element = deferred(
|
||||
anchored()
|
||||
.position(position)
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.anchor(anchor)
|
||||
.when_some(menu_view, |this, menu| {
|
||||
// Focus the menu, so that can be handle the action.
|
||||
if !menu.focus_handle(cx).contains_focused(window, cx) {
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
}
|
||||
menu_element = Some(
|
||||
deferred(
|
||||
anchored().child(
|
||||
div()
|
||||
.w(window.bounds().size.width)
|
||||
.h(window.bounds().size.height)
|
||||
.on_scroll_wheel(|_, _, cx| {
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.child(
|
||||
anchored()
|
||||
.position(position)
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.anchor(anchor)
|
||||
.when_some(menu_view, |this, menu| {
|
||||
// Focus the menu, so that can be handle the action.
|
||||
if !menu
|
||||
.focus_handle(cx)
|
||||
.contains_focused(window, cx)
|
||||
{
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
}
|
||||
|
||||
this.child(div().occlude().child(menu.clone()))
|
||||
}),
|
||||
)
|
||||
.with_priority(1)
|
||||
.into_any();
|
||||
|
||||
let menu_layout_id = menu_element.request_layout(window, cx);
|
||||
(Some(menu_element), Some(menu_layout_id))
|
||||
} else {
|
||||
(None, None)
|
||||
this.child(menu.clone())
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.with_priority(1)
|
||||
.into_any(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let mut layout_ids = vec![];
|
||||
if let Some(menu_layout_id) = menu_layout_id {
|
||||
layout_ids.push(menu_layout_id);
|
||||
}
|
||||
|
||||
let layout_id = window.request_layout(style, layout_ids, cx);
|
||||
let mut element = this
|
||||
.element
|
||||
.take()
|
||||
.expect("Element should exists.")
|
||||
.children(menu_element)
|
||||
.into_any_element();
|
||||
|
||||
let layout_id = element.request_layout(window, cx);
|
||||
|
||||
(
|
||||
layout_id,
|
||||
ContextMenuState {
|
||||
menu_element,
|
||||
|
||||
element: Some(element),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -197,33 +233,33 @@ impl Element for ContextMenu {
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
_: gpui::Bounds<gpui::Pixels>,
|
||||
bounds: gpui::Bounds<gpui::Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
if let Some(menu_element) = &mut request_layout.menu_element {
|
||||
menu_element.prepaint(window, cx);
|
||||
if let Some(element) = &mut request_layout.element {
|
||||
element.prepaint(window, cx);
|
||||
}
|
||||
window.insert_hitbox(bounds, HitboxBehavior::Normal)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
id: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
bounds: gpui::Bounds<gpui::Pixels>,
|
||||
_: gpui::Bounds<gpui::Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
hitbox: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if let Some(menu_element) = &mut request_layout.menu_element {
|
||||
menu_element.paint(window, cx);
|
||||
if let Some(element) = &mut request_layout.element {
|
||||
element.paint(window, cx);
|
||||
}
|
||||
|
||||
let Some(builder) = self.menu.take() else {
|
||||
return;
|
||||
};
|
||||
// Take the builder before setting up element state to avoid borrow issues
|
||||
let builder = self.menu.clone();
|
||||
|
||||
self.with_element_state(
|
||||
id.unwrap(),
|
||||
@@ -232,34 +268,53 @@ impl Element for ContextMenu {
|
||||
|_view, state: &mut ContextMenuState, window, _| {
|
||||
let shared_state = state.shared_state.clone();
|
||||
|
||||
let hitbox = hitbox.clone();
|
||||
// When right mouse click, to build content menu, and show it at the mouse position.
|
||||
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
|
||||
if phase.bubble()
|
||||
&& event.button == MouseButton::Right
|
||||
&& bounds.contains(&event.position)
|
||||
&& hitbox.is_hovered(window)
|
||||
{
|
||||
{
|
||||
let mut shared_state = shared_state.borrow_mut();
|
||||
// Clear any existing menu view to allow immediate replacement
|
||||
// Set the new position and open the menu
|
||||
shared_state.menu_view = None;
|
||||
shared_state._subscription = None;
|
||||
shared_state.position = event.position;
|
||||
shared_state.open = true;
|
||||
}
|
||||
|
||||
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
|
||||
(builder)(menu, window, cx)
|
||||
})
|
||||
.into_element();
|
||||
|
||||
let _subscription = window.subscribe(&menu, cx, {
|
||||
// Use defer to build the menu in the next frame, avoiding race conditions
|
||||
window.defer(cx, {
|
||||
let shared_state = shared_state.clone();
|
||||
move |_, _: &DismissEvent, window, _| {
|
||||
shared_state.borrow_mut().open = false;
|
||||
window.refresh();
|
||||
let builder = builder.clone();
|
||||
move |window, cx| {
|
||||
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
|
||||
let Some(build) = &builder else {
|
||||
return menu;
|
||||
};
|
||||
build(menu, window, cx)
|
||||
});
|
||||
|
||||
// Set up the subscription for dismiss handling
|
||||
let _subscription = window.subscribe(&menu, cx, {
|
||||
let shared_state = shared_state.clone();
|
||||
move |_, _: &DismissEvent, window, _cx| {
|
||||
shared_state.borrow_mut().open = false;
|
||||
window.refresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Update the shared state with the built menu and subscription
|
||||
{
|
||||
let mut state = shared_state.borrow_mut();
|
||||
state.menu_view = Some(menu.clone());
|
||||
state._subscription = Some(_subscription);
|
||||
window.refresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
shared_state.borrow_mut().menu_view = Some(menu.clone());
|
||||
shared_state.borrow_mut()._subscription = Some(_subscription);
|
||||
window.refresh();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
142
crates/ui/src/menu/dropdown_menu.rs
Normal file
142
crates/ui/src/menu/dropdown_menu.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Context, Corner, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
};
|
||||
|
||||
use crate::button::Button;
|
||||
use crate::menu::PopupMenu;
|
||||
use crate::popover::Popover;
|
||||
use crate::Selectable;
|
||||
|
||||
/// A dropdown menu trait for buttons and other interactive elements
|
||||
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
|
||||
/// Create a dropdown menu with the given items, anchored to the TopLeft corner
|
||||
fn dropdown_menu(
|
||||
self,
|
||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> DropdownMenuPopover<Self> {
|
||||
self.dropdown_menu_with_anchor(Corner::TopLeft, f)
|
||||
}
|
||||
|
||||
/// Create a dropdown menu with the given items, anchored to the given corner
|
||||
fn dropdown_menu_with_anchor(
|
||||
mut self,
|
||||
anchor: impl Into<Corner>,
|
||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> DropdownMenuPopover<Self> {
|
||||
let style = self.style().clone();
|
||||
let id = self.interactivity().element_id.clone();
|
||||
|
||||
DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f).trigger_style(style)
|
||||
}
|
||||
}
|
||||
|
||||
impl DropdownMenu for Button {}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Corner,
|
||||
trigger: T,
|
||||
#[allow(clippy::type_complexity)]
|
||||
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
|
||||
}
|
||||
|
||||
impl<T> DropdownMenuPopover<T>
|
||||
where
|
||||
T: Selectable + IntoElement + 'static,
|
||||
{
|
||||
fn new(
|
||||
id: ElementId,
|
||||
anchor: impl Into<Corner>,
|
||||
trigger: T,
|
||||
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: SharedString::from(format!("dropdown-menu:{:?}", id)).into(),
|
||||
style: StyleRefinement::default(),
|
||||
anchor: anchor.into(),
|
||||
trigger,
|
||||
builder: Rc::new(builder),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the anchor corner for the dropdown menu popover.
|
||||
pub fn anchor(mut self, anchor: impl Into<Corner>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the style refinement for the dropdown menu trigger.
|
||||
fn trigger_style(mut self, style: StyleRefinement) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DropdownMenuState {
|
||||
menu: Option<Entity<PopupMenu>>,
|
||||
}
|
||||
|
||||
impl<T> RenderOnce for DropdownMenuPopover<T>
|
||||
where
|
||||
T: Selectable + IntoElement + 'static,
|
||||
{
|
||||
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||
let builder = self.builder.clone();
|
||||
let menu_state =
|
||||
window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
|
||||
Popover::new(SharedString::from(format!("popover:{}", self.id)))
|
||||
.appearance(false)
|
||||
.overlay_closable(false)
|
||||
.trigger(self.trigger)
|
||||
.trigger_style(self.style)
|
||||
.anchor(self.anchor)
|
||||
.content(move |_, window, cx| {
|
||||
// Here is special logic to only create the PopupMenu once and reuse it.
|
||||
// Because this `content` will called in every time render, so we need to store the menu
|
||||
// in state to avoid recreating at every render.
|
||||
//
|
||||
// And we also need to rebuild the menu when it is dismissed, to rebuild menu items
|
||||
// dynamically for support `dropdown_menu` method, so we listen for DismissEvent below.
|
||||
let menu = match menu_state.read(cx).menu.clone() {
|
||||
Some(menu) => menu,
|
||||
None => {
|
||||
let builder = builder.clone();
|
||||
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
|
||||
builder(menu, window, cx)
|
||||
});
|
||||
menu_state.update(cx, |state, _| {
|
||||
state.menu = Some(menu.clone());
|
||||
});
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
|
||||
// Listen for dismiss events from the PopupMenu to close the popover.
|
||||
let popover_state = cx.entity();
|
||||
window
|
||||
.subscribe(&menu, cx, {
|
||||
let menu_state = menu_state.clone();
|
||||
move |_, _: &DismissEvent, window, cx| {
|
||||
popover_state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
menu_state.update(cx, |state, _| {
|
||||
state.menu = None;
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
menu.clone()
|
||||
}
|
||||
};
|
||||
|
||||
menu.clone()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,22 @@ use theme::ActiveTheme;
|
||||
use crate::{h_flex, Disableable, StyledExt};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) struct MenuItemElement {
|
||||
id: ElementId,
|
||||
group_name: SharedString,
|
||||
style: StyleRefinement,
|
||||
disabled: bool,
|
||||
selected: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl MenuItemElement {
|
||||
pub fn new(id: impl Into<ElementId>, group_name: impl Into<SharedString>) -> Self {
|
||||
/// Create a new MenuItem with the given ID and group name.
|
||||
pub(crate) fn new(id: impl Into<ElementId>, group_name: impl Into<SharedString>) -> Self {
|
||||
let id: ElementId = id.into();
|
||||
Self {
|
||||
id: id.clone(),
|
||||
@@ -38,17 +40,19 @@ impl MenuItemElement {
|
||||
}
|
||||
|
||||
/// Set ListItem as the selected item style.
|
||||
pub fn selected(mut self, selected: bool) -> Self {
|
||||
pub(crate) fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
/// Set the disabled state of the MenuItem.
|
||||
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
/// Set a handler for when the MenuItem is clicked.
|
||||
pub(crate) fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
@@ -88,7 +92,7 @@ impl RenderOnce for MenuItemElement {
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.group(&self.group_name)
|
||||
.gap_x_2()
|
||||
.gap_x_1()
|
||||
.py_1()
|
||||
.px_2()
|
||||
.text_base()
|
||||
@@ -102,12 +106,12 @@ impl RenderOnce for MenuItemElement {
|
||||
})
|
||||
.when(!self.disabled, |this| {
|
||||
this.group_hover(self.group_name, |this| {
|
||||
this.bg(cx.theme().elevated_surface_background)
|
||||
.text_color(cx.theme().text)
|
||||
this.bg(cx.theme().secondary_background)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
})
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().elevated_surface_background)
|
||||
.text_color(cx.theme().text)
|
||||
this.bg(cx.theme().secondary_background)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
})
|
||||
.when_some(self.on_click, |this, on_click| {
|
||||
this.on_mouse_down(MouseButton::Left, move |_, _, cx| {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use gpui::App;
|
||||
|
||||
mod app_menu_bar;
|
||||
mod context_menu;
|
||||
mod dropdown_menu;
|
||||
mod menu_item;
|
||||
|
||||
pub mod context_menu;
|
||||
pub mod popup_menu;
|
||||
mod popup_menu;
|
||||
|
||||
pub use app_menu_bar::AppMenuBar;
|
||||
pub use context_menu::{ContextMenu, ContextMenuExt, ContextMenuState};
|
||||
pub use dropdown_menu::DropdownMenu;
|
||||
pub use popup_menu::{PopupMenu, PopupMenuItem};
|
||||
|
||||
pub(crate) fn init(cx: &mut App) {
|
||||
app_menu_bar::init(cx);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
anchored, div, hsla, point, px, Animation, AnimationExt as _, AnyElement, App, Axis, Bounds,
|
||||
anchored, div, hsla, point, px, Animation, AnimationExt as _, AnyElement, App, Bounds,
|
||||
BoxShadow, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding,
|
||||
MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled,
|
||||
Window,
|
||||
@@ -13,7 +13,8 @@ use theme::ActiveTheme;
|
||||
use crate::actions::{Cancel, Confirm};
|
||||
use crate::animation::cubic_bezier;
|
||||
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
|
||||
use crate::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt};
|
||||
use crate::scroll::ScrollableElement;
|
||||
use crate::{h_flex, v_flex, IconName, Root, Sizable, StyledExt, WindowExtension};
|
||||
|
||||
const CONTEXT: &str = "Modal";
|
||||
|
||||
@@ -97,9 +98,9 @@ pub struct Modal {
|
||||
button_props: ModalButtonProps,
|
||||
|
||||
/// This will be change when open the modal, the focus handle is create when open the modal.
|
||||
pub(crate) focus_handle: FocusHandle,
|
||||
pub(crate) layer_ix: usize,
|
||||
pub(crate) overlay_visible: bool,
|
||||
pub focus_handle: FocusHandle,
|
||||
pub layer_ix: usize,
|
||||
pub overlay_visible: bool,
|
||||
}
|
||||
|
||||
impl Modal {
|
||||
@@ -255,7 +256,7 @@ impl Modal {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn has_overlay(&self) -> bool {
|
||||
pub fn has_overlay(&self) -> bool {
|
||||
self.overlay
|
||||
}
|
||||
}
|
||||
@@ -341,7 +342,7 @@ impl RenderOnce for Modal {
|
||||
}
|
||||
});
|
||||
|
||||
let window_paddings = crate::window_border::window_paddings(window, cx);
|
||||
let window_paddings = crate::root::window_paddings(window, cx);
|
||||
let radius = (cx.theme().radius_lg * 2.).min(px(20.));
|
||||
|
||||
let view_size = window.viewport_size()
|
||||
@@ -489,13 +490,13 @@ impl RenderOnce for Modal {
|
||||
.w_full()
|
||||
.h_auto()
|
||||
.flex_1()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
v_flex()
|
||||
.pr(padding_right)
|
||||
.pl(padding_left)
|
||||
.scrollable(Axis::Vertical)
|
||||
.size_full()
|
||||
.overflow_y_scrollbar()
|
||||
.child(self.content),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -425,7 +425,7 @@ impl NotificationList {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn close<T>(&mut self, key: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
pub fn close<T>(&mut self, key: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<ElementId>,
|
||||
{
|
||||
|
||||
@@ -1,129 +1,78 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
actions, anchored, deferred, div, px, AnyElement, App, Bounds, Context, Corner, DismissEvent,
|
||||
DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
GlobalElementId, Hitbox, HitboxBehavior, InteractiveElement as _, IntoElement, KeyBinding,
|
||||
LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render,
|
||||
ScrollHandle, StatefulInteractiveElement, Style, StyleRefinement, Styled, Window,
|
||||
deferred, div, px, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId,
|
||||
EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding,
|
||||
MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement,
|
||||
Styled, Subscription, Window,
|
||||
};
|
||||
|
||||
use crate::{Selectable, StyledExt as _};
|
||||
use crate::actions::Cancel;
|
||||
use crate::{anchored, v_flex, Anchor, ElementExt, Selectable, StyledExt as _};
|
||||
|
||||
const CONTEXT: &str = "Popover";
|
||||
|
||||
actions!(popover, [Escape]);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
|
||||
pub(crate) fn init(cx: &mut App) {
|
||||
cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))])
|
||||
}
|
||||
|
||||
type PopoverChild<T> = Rc<dyn Fn(&mut Window, &mut Context<T>) -> AnyElement>;
|
||||
|
||||
pub struct PopoverContent {
|
||||
focus_handle: FocusHandle,
|
||||
scroll_handle: ScrollHandle,
|
||||
max_width: Option<Pixels>,
|
||||
max_height: Option<Pixels>,
|
||||
scrollable: bool,
|
||||
child: PopoverChild<Self>,
|
||||
}
|
||||
|
||||
impl PopoverContent {
|
||||
pub fn new<B>(_window: &mut Window, cx: &mut App, content: B) -> Self
|
||||
where
|
||||
B: Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static,
|
||||
{
|
||||
let focus_handle = cx.focus_handle();
|
||||
let scroll_handle = ScrollHandle::default();
|
||||
|
||||
Self {
|
||||
focus_handle,
|
||||
scroll_handle,
|
||||
child: Rc::new(content),
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
scrollable: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_w(mut self, max_width: Pixels) -> Self {
|
||||
self.max_width = Some(max_width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn max_h(mut self, max_height: Pixels) -> Self {
|
||||
self.max_height = Some(max_height);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn scrollable(mut self) -> Self {
|
||||
self.scrollable = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for PopoverContent {}
|
||||
|
||||
impl Focusable for PopoverContent {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PopoverContent {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.id("popup-content")
|
||||
.track_focus(&self.focus_handle)
|
||||
.key_context(CONTEXT)
|
||||
.on_action(cx.listener(|_, _: &Escape, _, cx| cx.emit(DismissEvent)))
|
||||
.p_2()
|
||||
.when(self.scrollable, |this| {
|
||||
this.overflow_y_scroll().track_scroll(&self.scroll_handle)
|
||||
})
|
||||
.when_some(self.max_width, |this, v| this.max_w(v))
|
||||
.when_some(self.max_height, |this, v| this.max_h(v))
|
||||
.child(self.child.clone()(window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
type Trigger = Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>;
|
||||
type Content<M> = Option<Rc<dyn Fn(&mut Window, &mut App) -> Entity<M> + 'static>>;
|
||||
|
||||
pub struct Popover<M: ManagedView> {
|
||||
/// A popover element that can be triggered by a button or any other element.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Popover {
|
||||
id: ElementId,
|
||||
anchor: Corner,
|
||||
trigger: Trigger,
|
||||
content: Content<M>,
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
default_open: bool,
|
||||
open: Option<bool>,
|
||||
tracked_focus_handle: Option<FocusHandle>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
content: Option<
|
||||
Rc<
|
||||
dyn Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement
|
||||
+ 'static,
|
||||
>,
|
||||
>,
|
||||
children: Vec<AnyElement>,
|
||||
/// Style for trigger element.
|
||||
/// This is used for hotfix the trigger element style to support w_full.
|
||||
trigger_style: Option<StyleRefinement>,
|
||||
mouse_button: MouseButton,
|
||||
no_style: bool,
|
||||
appearance: bool,
|
||||
overlay_closable: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl<M> Popover<M>
|
||||
where
|
||||
M: ManagedView,
|
||||
{
|
||||
impl Popover {
|
||||
/// Create a new Popover with `view` mode.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
anchor: Corner::TopLeft,
|
||||
style: StyleRefinement::default(),
|
||||
anchor: Anchor::TopLeft,
|
||||
trigger: None,
|
||||
trigger_style: None,
|
||||
content: None,
|
||||
tracked_focus_handle: None,
|
||||
children: vec![],
|
||||
mouse_button: MouseButton::Left,
|
||||
no_style: false,
|
||||
appearance: true,
|
||||
overlay_closable: true,
|
||||
default_open: false,
|
||||
open: None,
|
||||
on_open_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn anchor(mut self, anchor: Corner) -> Self {
|
||||
self.anchor = anchor;
|
||||
/// Set the anchor corner of the popover, default is `Corner::TopLeft`.
|
||||
///
|
||||
/// This method is kept for backward compatibility with `Corner` type.
|
||||
/// Internally, it converts `Corner` to `Anchor`.
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -133,29 +82,75 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the trigger element of the popover.
|
||||
pub fn trigger<T>(mut self, trigger: T) -> Self
|
||||
where
|
||||
T: Selectable + IntoElement + 'static,
|
||||
{
|
||||
self.trigger = Some(Box::new(|is_open, _, _| {
|
||||
trigger.selected(is_open).into_any_element()
|
||||
let selected = trigger.is_selected();
|
||||
trigger.selected(selected || is_open).into_any_element()
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the default open state of the popover, default is `false`.
|
||||
///
|
||||
/// This is only used to initialize the open state of the popover.
|
||||
///
|
||||
/// And please note that if you use the `open` method, this value will be ignored.
|
||||
pub fn default_open(mut self, open: bool) -> Self {
|
||||
self.default_open = open;
|
||||
self
|
||||
}
|
||||
|
||||
/// Force set the open state of the popover.
|
||||
///
|
||||
/// If this is set, the popover will be controlled by this value.
|
||||
///
|
||||
/// NOTE: You must be used in conjunction with `on_open_change` to handle state changes.
|
||||
pub fn open(mut self, open: bool) -> Self {
|
||||
self.open = Some(open);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a callback to be called when the open state changes.
|
||||
///
|
||||
/// The first `&bool` parameter is the **new open state**.
|
||||
///
|
||||
/// This is useful when using the `open` method to control the popover state.
|
||||
pub fn on_open_change<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: Fn(&bool, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
self.on_open_change = Some(Rc::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the style for the trigger element.
|
||||
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
|
||||
self.trigger_style = Some(style);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the content of the popover.
|
||||
/// Set whether clicking outside the popover will dismiss it, default is `true`.
|
||||
pub fn overlay_closable(mut self, closable: bool) -> Self {
|
||||
self.overlay_closable = closable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the content builder for content of the Popover.
|
||||
///
|
||||
/// The `content` is a closure that returns an `AnyElement`.
|
||||
pub fn content<C>(mut self, content: C) -> Self
|
||||
/// This callback will called every time on render the popover.
|
||||
/// So, you should avoid creating new elements or entities in the content closure.
|
||||
pub fn content<F, E>(mut self, content: F) -> Self
|
||||
where
|
||||
C: Fn(&mut Window, &mut App) -> Entity<M> + 'static,
|
||||
E: IntoElement,
|
||||
F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
|
||||
{
|
||||
self.content = Some(Rc::new(content));
|
||||
self.content = Some(Rc::new(move |state, window, cx| {
|
||||
content(state, window, cx).into_any_element()
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -165,302 +160,265 @@ where
|
||||
///
|
||||
/// - The popover will not have a bg, border, shadow, or padding.
|
||||
/// - The click out of the popover will not dismiss it.
|
||||
pub fn no_style(mut self) -> Self {
|
||||
self.no_style = true;
|
||||
pub fn appearance(mut self, appearance: bool) -> Self {
|
||||
self.appearance = appearance;
|
||||
self
|
||||
}
|
||||
|
||||
fn render_trigger(&mut self, is_open: bool, window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let Some(trigger) = self.trigger.take() else {
|
||||
return div().into_any_element();
|
||||
/// Bind the focus handle to receive focus when the popover is opened.
|
||||
/// If you not set this, a new focus handle will be created for the popover to
|
||||
///
|
||||
/// If popover is opened, the focus will be moved to the focus handle.
|
||||
pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
|
||||
self.tracked_focus_handle = Some(handle.clone());
|
||||
self
|
||||
}
|
||||
|
||||
fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> {
|
||||
let offset = if anchor.is_center() {
|
||||
gpui::point(trigger_bounds.size.width.half(), px(0.))
|
||||
} else {
|
||||
Point::default()
|
||||
};
|
||||
|
||||
(trigger)(is_open, window, cx)
|
||||
}
|
||||
|
||||
fn resolved_corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> {
|
||||
bounds.corner(match self.anchor {
|
||||
Corner::TopLeft => Corner::BottomLeft,
|
||||
Corner::TopRight => Corner::BottomRight,
|
||||
Corner::BottomLeft => Corner::TopLeft,
|
||||
Corner::BottomRight => Corner::TopRight,
|
||||
})
|
||||
}
|
||||
|
||||
fn with_element_state<R>(
|
||||
&mut self,
|
||||
id: &GlobalElementId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
f: impl FnOnce(&mut Self, &mut PopoverElementState<M>, &mut Window, &mut App) -> R,
|
||||
) -> R {
|
||||
window.with_optional_element_state::<PopoverElementState<M>, _>(
|
||||
Some(id),
|
||||
|element_state, window| {
|
||||
let mut element_state = element_state.unwrap().unwrap_or_default();
|
||||
let result = f(self, &mut element_state, window, cx);
|
||||
(result, Some(element_state))
|
||||
},
|
||||
)
|
||||
trigger_bounds.corner(anchor.swap_vertical().into())
|
||||
+ offset
|
||||
+ Point {
|
||||
x: px(0.),
|
||||
y: -trigger_bounds.size.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> IntoElement for Popover<M>
|
||||
where
|
||||
M: ManagedView,
|
||||
{
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
impl ParentElement for Popover {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PopoverElementState<M> {
|
||||
trigger_layout_id: Option<LayoutId>,
|
||||
popover_layout_id: Option<LayoutId>,
|
||||
popover_element: Option<AnyElement>,
|
||||
trigger_element: Option<AnyElement>,
|
||||
content_view: Rc<RefCell<Option<Entity<M>>>>,
|
||||
/// Trigger bounds for positioning the popover.
|
||||
trigger_bounds: Option<Bounds<Pixels>>,
|
||||
impl Styled for Popover {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> Default for PopoverElementState<M> {
|
||||
fn default() -> Self {
|
||||
pub struct PopoverState {
|
||||
focus_handle: FocusHandle,
|
||||
pub(crate) tracked_focus_handle: Option<FocusHandle>,
|
||||
trigger_bounds: Bounds<Pixels>,
|
||||
open: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||
|
||||
_dismiss_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl PopoverState {
|
||||
pub fn new(default_open: bool, cx: &mut App) -> Self {
|
||||
Self {
|
||||
trigger_layout_id: None,
|
||||
popover_layout_id: None,
|
||||
popover_element: None,
|
||||
trigger_element: None,
|
||||
content_view: Rc::new(RefCell::new(None)),
|
||||
trigger_bounds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PrepaintState {
|
||||
hitbox: Hitbox,
|
||||
/// Trigger bounds for limit a rect to handle mouse click.
|
||||
trigger_bounds: Option<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
impl<M: ManagedView> Element for Popover<M> {
|
||||
type PrepaintState = PrepaintState;
|
||||
type RequestLayoutState = PopoverElementState<M>;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
id: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
|
||||
// FIXME: Remove this and find a better way to handle this.
|
||||
// Apply trigger style, for support w_full for trigger.
|
||||
//
|
||||
// If remove this, the trigger will not support w_full.
|
||||
if let Some(trigger_style) = self.trigger_style.clone() {
|
||||
if let Some(width) = trigger_style.size.width {
|
||||
style.size.width = width;
|
||||
}
|
||||
if let Some(display) = trigger_style.display {
|
||||
style.display = display;
|
||||
}
|
||||
}
|
||||
|
||||
self.with_element_state(
|
||||
id.unwrap(),
|
||||
window,
|
||||
cx,
|
||||
|view, element_state, window, cx| {
|
||||
let mut popover_layout_id = None;
|
||||
let mut popover_element = None;
|
||||
let mut is_open = false;
|
||||
|
||||
if let Some(content_view) = element_state.content_view.borrow_mut().as_mut() {
|
||||
is_open = true;
|
||||
|
||||
let mut anchored = anchored()
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.anchor(view.anchor);
|
||||
if let Some(trigger_bounds) = element_state.trigger_bounds {
|
||||
anchored = anchored.position(view.resolved_corner(trigger_bounds));
|
||||
}
|
||||
|
||||
let mut element = {
|
||||
let content_view_mut = element_state.content_view.clone();
|
||||
let anchor = view.anchor;
|
||||
let no_style = view.no_style;
|
||||
deferred(
|
||||
anchored.child(
|
||||
div()
|
||||
.size_full()
|
||||
.occlude()
|
||||
.when(!no_style, |this| this.popover_style(cx))
|
||||
.map(|this| match anchor {
|
||||
Corner::TopLeft | Corner::TopRight => this.top_1p5(),
|
||||
Corner::BottomLeft | Corner::BottomRight => {
|
||||
this.bottom_1p5()
|
||||
}
|
||||
})
|
||||
.child(content_view.clone())
|
||||
.when(!no_style, |this| {
|
||||
this.on_mouse_down_out(move |_, window, _| {
|
||||
// Update the element_state.content_view to `None`,
|
||||
// so that the `paint`` method will not paint it.
|
||||
*content_view_mut.borrow_mut() = None;
|
||||
window.refresh();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.with_priority(1)
|
||||
.into_any()
|
||||
};
|
||||
|
||||
popover_layout_id = Some(element.request_layout(window, cx));
|
||||
popover_element = Some(element);
|
||||
}
|
||||
|
||||
let mut trigger_element = view.render_trigger(is_open, window, cx);
|
||||
let trigger_layout_id = trigger_element.request_layout(window, cx);
|
||||
|
||||
let layout_id = window.request_layout(
|
||||
style,
|
||||
Some(trigger_layout_id).into_iter().chain(popover_layout_id),
|
||||
cx,
|
||||
);
|
||||
|
||||
(
|
||||
layout_id,
|
||||
PopoverElementState {
|
||||
trigger_layout_id: Some(trigger_layout_id),
|
||||
popover_layout_id,
|
||||
popover_element,
|
||||
trigger_element: Some(trigger_element),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_bounds: gpui::Bounds<gpui::Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
if let Some(element) = &mut request_layout.trigger_element {
|
||||
element.prepaint(window, cx);
|
||||
}
|
||||
if let Some(element) = &mut request_layout.popover_element {
|
||||
element.prepaint(window, cx);
|
||||
}
|
||||
|
||||
let trigger_bounds = request_layout
|
||||
.trigger_layout_id
|
||||
.map(|id| window.layout_bounds(id));
|
||||
|
||||
// Prepare the popover, for get the bounds of it for open window size.
|
||||
let _ = request_layout
|
||||
.popover_layout_id
|
||||
.map(|id| window.layout_bounds(id));
|
||||
|
||||
let hitbox =
|
||||
window.insert_hitbox(trigger_bounds.unwrap_or_default(), HitboxBehavior::Normal);
|
||||
|
||||
PrepaintState {
|
||||
trigger_bounds,
|
||||
hitbox,
|
||||
focus_handle: cx.focus_handle(),
|
||||
tracked_focus_handle: None,
|
||||
trigger_bounds: Bounds::default(),
|
||||
open: default_open,
|
||||
on_open_change: None,
|
||||
_dismiss_subscription: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
id: Option<&GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
self.with_element_state(
|
||||
id.unwrap(),
|
||||
window,
|
||||
cx,
|
||||
|this, element_state, window, cx| {
|
||||
element_state.trigger_bounds = prepaint.trigger_bounds;
|
||||
/// Check if the popover is open.
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
|
||||
if let Some(mut element) = request_layout.trigger_element.take() {
|
||||
element.paint(window, cx);
|
||||
}
|
||||
/// Dismiss the popover if it is open.
|
||||
pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.open {
|
||||
self.toggle_open(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mut element) = request_layout.popover_element.take() {
|
||||
element.paint(window, cx);
|
||||
return;
|
||||
}
|
||||
/// Open the popover if it is closed.
|
||||
pub fn show(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.open {
|
||||
self.toggle_open(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
// When mouse click down in the trigger bounds, open the popover.
|
||||
let Some(content_build) = this.content.take() else {
|
||||
return;
|
||||
};
|
||||
let old_content_view = element_state.content_view.clone();
|
||||
let hitbox_id = prepaint.hitbox.id;
|
||||
let mouse_button = this.mouse_button;
|
||||
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& event.button == mouse_button
|
||||
&& hitbox_id.is_hovered(window)
|
||||
{
|
||||
cx.stop_propagation();
|
||||
window.prevent_default();
|
||||
fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open = !self.open;
|
||||
if self.open {
|
||||
let state = cx.entity();
|
||||
let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone()
|
||||
{
|
||||
tracked_focus_handle
|
||||
} else {
|
||||
self.focus_handle.clone()
|
||||
};
|
||||
focus_handle.focus(window, cx);
|
||||
|
||||
let new_content_view = (content_build)(window, cx);
|
||||
let old_content_view1 = old_content_view.clone();
|
||||
|
||||
let previous_focus_handle = window.focused(cx);
|
||||
|
||||
window
|
||||
.subscribe(
|
||||
&new_content_view,
|
||||
cx,
|
||||
move |modal, _: &DismissEvent, window, cx| {
|
||||
if modal.focus_handle(cx).contains_focused(window, cx) {
|
||||
if let Some(previous_focus_handle) =
|
||||
previous_focus_handle.as_ref()
|
||||
{
|
||||
window.focus(previous_focus_handle, cx);
|
||||
}
|
||||
}
|
||||
*old_content_view1.borrow_mut() = None;
|
||||
|
||||
window.refresh();
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
window.focus(&new_content_view.focus_handle(cx), cx);
|
||||
*old_content_view.borrow_mut() = Some(new_content_view);
|
||||
self._dismiss_subscription =
|
||||
Some(
|
||||
window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
window.refresh();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
self._dismiss_subscription = None;
|
||||
}
|
||||
|
||||
if let Some(callback) = self.on_open_change.as_ref() {
|
||||
callback(&self.open, window, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.dismiss(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for PopoverState {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PopoverState {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for PopoverState {}
|
||||
|
||||
impl Popover {
|
||||
pub(crate) fn render_popover<E>(
|
||||
anchor: Anchor,
|
||||
trigger_bounds: Bounds<Pixels>,
|
||||
content: E,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> Deferred
|
||||
where
|
||||
E: IntoElement + 'static,
|
||||
{
|
||||
deferred(
|
||||
anchored()
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.anchor(anchor)
|
||||
.position(Self::resolved_corner(anchor, trigger_bounds))
|
||||
.child(div().relative().child(content)),
|
||||
)
|
||||
.with_priority(1)
|
||||
}
|
||||
|
||||
pub(crate) fn render_popover_content(
|
||||
anchor: Anchor,
|
||||
appearance: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Stateful<Div> {
|
||||
v_flex()
|
||||
.id("content")
|
||||
.occlude()
|
||||
.tab_group()
|
||||
.when(appearance, |this| this.popover_style(cx).p_3())
|
||||
.map(|this| match anchor {
|
||||
Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(),
|
||||
Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Popover {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let force_open = self.open;
|
||||
let default_open = self.default_open;
|
||||
let tracked_focus_handle = self.tracked_focus_handle.clone();
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| {
|
||||
PopoverState::new(default_open, cx)
|
||||
});
|
||||
|
||||
state.update(cx, |state, _| {
|
||||
if let Some(tracked_focus_handle) = tracked_focus_handle {
|
||||
state.tracked_focus_handle = Some(tracked_focus_handle);
|
||||
}
|
||||
state.on_open_change = self.on_open_change.clone();
|
||||
if let Some(force_open) = force_open {
|
||||
state.open = force_open;
|
||||
}
|
||||
});
|
||||
|
||||
let open = state.read(cx).open;
|
||||
let focus_handle = state.read(cx).focus_handle.clone();
|
||||
let trigger_bounds = state.read(cx).trigger_bounds;
|
||||
|
||||
let Some(trigger) = self.trigger else {
|
||||
return div().id("empty");
|
||||
};
|
||||
|
||||
let parent_view_id = window.current_view();
|
||||
|
||||
let el = div()
|
||||
.id(self.id)
|
||||
.child((trigger)(open, window, cx))
|
||||
.on_mouse_down(self.mouse_button, {
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
state.update(cx, |state, cx| {
|
||||
// We force set open to false to toggle it correctly.
|
||||
// Because if the mouse down out will toggle open first.
|
||||
state.open = open;
|
||||
state.toggle_open(window, cx);
|
||||
});
|
||||
cx.notify(parent_view_id);
|
||||
}
|
||||
})
|
||||
.on_prepaint({
|
||||
let state = state.clone();
|
||||
move |bounds, _, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.trigger_bounds = bounds;
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
if !open {
|
||||
return el;
|
||||
}
|
||||
|
||||
let popover_content =
|
||||
Self::render_popover_content(self.anchor, self.appearance, window, cx)
|
||||
.track_focus(&focus_handle)
|
||||
.key_context(CONTEXT)
|
||||
.on_action(window.listener_for(&state, PopoverState::on_action_cancel))
|
||||
.when_some(self.content, |this, content| {
|
||||
this.child(state.update(cx, |state, cx| (content)(state, window, cx)))
|
||||
})
|
||||
.children(self.children)
|
||||
.when(self.overlay_closable, |this| {
|
||||
this.on_mouse_down_out({
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
cx.notify(parent_view_id);
|
||||
}
|
||||
})
|
||||
})
|
||||
.refine_style(&self.style);
|
||||
|
||||
el.child(Self::render_popover(
|
||||
self.anchor,
|
||||
trigger_bounds,
|
||||
popover_content,
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,168 +2,63 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, AnyView, App, AppContext, Context, Decorations, Entity, FocusHandle, InteractiveElement,
|
||||
IntoElement, ParentElement as _, Render, SharedString, Styled, Window,
|
||||
canvas, div, point, px, size, AnyView, App, AppContext, Bounds, Context, CursorStyle,
|
||||
Decorations, Edges, Entity, FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled,
|
||||
Tiling, WeakFocusHandle, Window,
|
||||
};
|
||||
use theme::{
|
||||
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
|
||||
CLIENT_SIDE_DECORATION_SHADOW,
|
||||
};
|
||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
|
||||
|
||||
use crate::input::InputState;
|
||||
use crate::modal::Modal;
|
||||
use crate::notification::{Notification, NotificationList};
|
||||
use crate::window_border;
|
||||
|
||||
/// Extension trait for [`WindowContext`] and [`ViewContext`] to add drawer functionality.
|
||||
pub trait ContextModal: Sized {
|
||||
/// Opens a Modal.
|
||||
fn open_modal<F>(&mut self, cx: &mut App, build: F)
|
||||
where
|
||||
F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static;
|
||||
|
||||
/// Return true, if there is an active Modal.
|
||||
fn has_active_modal(&mut self, cx: &mut App) -> bool;
|
||||
|
||||
/// Closes the last active Modal.
|
||||
fn close_modal(&mut self, cx: &mut App);
|
||||
|
||||
/// Closes all active Modals.
|
||||
fn close_all_modals(&mut self, cx: &mut App);
|
||||
|
||||
/// Returns number of notifications.
|
||||
fn notifications(&mut self, cx: &mut App) -> Rc<Vec<Entity<Notification>>>;
|
||||
|
||||
/// Pushes a notification to the notification list.
|
||||
fn push_notification(&mut self, note: impl Into<Notification>, cx: &mut App);
|
||||
|
||||
/// Clears a notification by its ID.
|
||||
fn clear_notification_by_id(&mut self, id: SharedString, cx: &mut App);
|
||||
|
||||
/// Clear all notifications
|
||||
fn clear_notifications(&mut self, cx: &mut App);
|
||||
|
||||
/// Return current focused Input entity.
|
||||
fn focused_input(&mut self, cx: &mut App) -> Option<Entity<InputState>>;
|
||||
|
||||
/// Returns true if there is a focused Input entity.
|
||||
fn has_focused_input(&mut self, cx: &mut App) -> bool;
|
||||
}
|
||||
|
||||
impl ContextModal for Window {
|
||||
fn open_modal<F>(&mut self, cx: &mut App, build: F)
|
||||
where
|
||||
F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static,
|
||||
{
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
// Only save focus handle if there are no active modals.
|
||||
// This is used to restore focus when all modals are closed.
|
||||
if root.active_modals.is_empty() {
|
||||
root.previous_focus_handle = window.focused(cx);
|
||||
}
|
||||
|
||||
let focus_handle = cx.focus_handle();
|
||||
focus_handle.focus(window, cx);
|
||||
|
||||
root.active_modals.push(ActiveModal {
|
||||
focus_handle,
|
||||
builder: Rc::new(build),
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn has_active_modal(&mut self, cx: &mut App) -> bool {
|
||||
!Root::read(self, cx).active_modals.is_empty()
|
||||
}
|
||||
|
||||
fn close_modal(&mut self, cx: &mut App) {
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.active_modals.pop();
|
||||
|
||||
if let Some(top_modal) = root.active_modals.last() {
|
||||
// Focus the next modal.
|
||||
top_modal.focus_handle.focus(window, cx);
|
||||
} else {
|
||||
// Restore focus if there are no more modals.
|
||||
root.focus_back(window, cx);
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn close_all_modals(&mut self, cx: &mut App) {
|
||||
Root::update(self, cx, |root, window, cx| {
|
||||
root.active_modals.clear();
|
||||
root.focus_back(window, cx);
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn push_notification(&mut self, note: impl Into<Notification>, cx: &mut App) {
|
||||
let note = note.into();
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.notification
|
||||
.update(cx, |view, cx| view.push(note, window, cx));
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_notifications(&mut self, cx: &mut App) {
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.notification
|
||||
.update(cx, |view, cx| view.clear(window, cx));
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_notification_by_id(&mut self, id: SharedString, cx: &mut App) {
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.notification.update(cx, |view, cx| {
|
||||
view.close(id.clone(), window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn notifications(&mut self, cx: &mut App) -> Rc<Vec<Entity<Notification>>> {
|
||||
let entity = Root::read(self, cx).notification.clone();
|
||||
Rc::new(entity.read(cx).notifications())
|
||||
}
|
||||
|
||||
fn has_focused_input(&mut self, cx: &mut App) -> bool {
|
||||
Root::read(self, cx).focused_input.is_some()
|
||||
}
|
||||
|
||||
fn focused_input(&mut self, cx: &mut App) -> Option<Entity<InputState>> {
|
||||
Root::read(self, cx).focused_input.clone()
|
||||
}
|
||||
}
|
||||
|
||||
type Builder = Rc<dyn Fn(Modal, &mut Window, &mut App) -> Modal + 'static>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ActiveModal {
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct ActiveModal {
|
||||
focus_handle: FocusHandle,
|
||||
builder: Builder,
|
||||
/// The previous focused handle before opening the modal.
|
||||
previous_focused_handle: Option<WeakFocusHandle>,
|
||||
builder: Rc<dyn Fn(Modal, &mut Window, &mut App) -> Modal + 'static>,
|
||||
}
|
||||
|
||||
impl ActiveModal {
|
||||
fn new(
|
||||
focus_handle: FocusHandle,
|
||||
previous_focused_handle: Option<WeakFocusHandle>,
|
||||
builder: impl Fn(Modal, &mut Window, &mut App) -> Modal + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
focus_handle,
|
||||
previous_focused_handle,
|
||||
builder: Rc::new(builder),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Root is a view for the App window for as the top level view (Must be the first view in the window).
|
||||
///
|
||||
/// It is used to manage the Modal, and Notification.
|
||||
pub struct Root {
|
||||
/// All active models
|
||||
pub(crate) active_modals: Vec<ActiveModal>,
|
||||
pub notification: Entity<NotificationList>,
|
||||
pub focused_input: Option<Entity<InputState>>,
|
||||
/// Used to store the focus handle of the previous view.
|
||||
///
|
||||
/// When the Modal closes, we will focus back to the previous view.
|
||||
previous_focus_handle: Option<FocusHandle>,
|
||||
|
||||
/// Notification layer
|
||||
pub(crate) notification: Entity<NotificationList>,
|
||||
|
||||
/// Current focused input
|
||||
pub(crate) focused_input: Option<Entity<InputState>>,
|
||||
|
||||
/// App view
|
||||
view: AnyView,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
pub fn new(view: AnyView, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
previous_focus_handle: None,
|
||||
focused_input: None,
|
||||
active_modals: Vec::new(),
|
||||
notification: cx.new(|cx| NotificationList::new(window, cx)),
|
||||
@@ -188,13 +83,11 @@ impl Root {
|
||||
.read(cx)
|
||||
}
|
||||
|
||||
fn focus_back(&mut self, window: &mut Window, cx: &mut App) {
|
||||
if let Some(handle) = self.previous_focus_handle.clone() {
|
||||
window.focus(&handle, cx);
|
||||
}
|
||||
pub fn view(&self) -> &AnyView {
|
||||
&self.view
|
||||
}
|
||||
|
||||
/// Render Notification layer.
|
||||
/// Render the notification layer.
|
||||
pub fn render_notification_layer(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
@@ -210,10 +103,9 @@ impl Root {
|
||||
)
|
||||
}
|
||||
|
||||
/// Render the Modal layer.
|
||||
/// Render the modal layer.
|
||||
pub fn render_modal_layer(window: &mut Window, cx: &mut App) -> Option<impl IntoElement> {
|
||||
let root = window.root::<Root>()??;
|
||||
|
||||
let active_modals = root.read(cx).active_modals.clone();
|
||||
|
||||
if active_modals.is_empty() {
|
||||
@@ -255,50 +147,316 @@ impl Root {
|
||||
Some(div().children(modals))
|
||||
}
|
||||
|
||||
/// Return the root view of the Root.
|
||||
pub fn view(&self) -> &AnyView {
|
||||
&self.view
|
||||
/// Open a modal.
|
||||
pub fn open_modal<F>(&mut self, builder: F, window: &mut Window, cx: &mut Context<'_, Self>)
|
||||
where
|
||||
F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static,
|
||||
{
|
||||
let previous_focused_handle = window.focused(cx).map(|h| h.downgrade());
|
||||
let focus_handle = cx.focus_handle();
|
||||
focus_handle.focus(window, cx);
|
||||
|
||||
self.active_modals.push(ActiveModal::new(
|
||||
focus_handle,
|
||||
previous_focused_handle,
|
||||
builder,
|
||||
));
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Replace the root view of the Root.
|
||||
pub fn replace_view(&mut self, view: AnyView) {
|
||||
self.view = view;
|
||||
/// Close the topmost modal.
|
||||
pub fn close_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.focused_input = None;
|
||||
|
||||
if let Some(handle) = self
|
||||
.active_modals
|
||||
.pop()
|
||||
.and_then(|d| d.previous_focused_handle)
|
||||
.and_then(|h| h.upgrade())
|
||||
{
|
||||
window.focus(&handle, cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Close all modals.
|
||||
pub fn close_all_modals(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.focused_input = None;
|
||||
self.active_modals.clear();
|
||||
|
||||
let previous_focused_handle = self
|
||||
.active_modals
|
||||
.first()
|
||||
.and_then(|d| d.previous_focused_handle.clone());
|
||||
|
||||
if let Some(handle) = previous_focused_handle.and_then(|h| h.upgrade()) {
|
||||
window.focus(&handle, cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Check if there are any active modals.
|
||||
pub fn has_active_modals(&self) -> bool {
|
||||
!self.active_modals.is_empty()
|
||||
}
|
||||
|
||||
/// Push a notification to the notification layer.
|
||||
pub fn push_notification<T>(&mut self, note: T, window: &mut Window, cx: &mut Context<'_, Root>)
|
||||
where
|
||||
T: Into<Notification>,
|
||||
{
|
||||
self.notification
|
||||
.update(cx, |view, cx| view.push(note, window, cx));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Clear a notification by its ID.
|
||||
pub fn clear_notification<T>(&mut self, id: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.notification
|
||||
.update(cx, |view, cx| view.close(id.into(), window, cx));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Clear all notifications from the notification layer.
|
||||
pub fn clear_notifications(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) {
|
||||
self.notification
|
||||
.update(cx, |view, cx| view.clear(window, cx));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Root {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let base_font_size = cx.theme().font_size;
|
||||
let rem_size = cx.theme().font_size;
|
||||
let font_family = cx.theme().font_family.clone();
|
||||
let decorations = window.window_decorations();
|
||||
|
||||
window.set_rem_size(base_font_size);
|
||||
// Set the base font size
|
||||
window.set_rem_size(rem_size);
|
||||
|
||||
window_border().child(
|
||||
div()
|
||||
.id("root")
|
||||
.map(|this| match decorations {
|
||||
Decorations::Server => this,
|
||||
Decorations::Client { tiling, .. } => this
|
||||
.when(!(tiling.top || tiling.right), |el| {
|
||||
el.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!(tiling.top || tiling.left), |el| {
|
||||
el.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!(tiling.bottom || tiling.right), |el| {
|
||||
el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!(tiling.bottom || tiling.left), |el| {
|
||||
el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
}),
|
||||
})
|
||||
.relative()
|
||||
.size_full()
|
||||
.font_family(font_family)
|
||||
.bg(cx.theme().background)
|
||||
.text_color(cx.theme().text)
|
||||
.child(self.view.clone()),
|
||||
)
|
||||
// Set the client inset (linux only)
|
||||
match decorations {
|
||||
Decorations::Client { .. } => window.set_client_inset(CLIENT_SIDE_DECORATION_SHADOW),
|
||||
Decorations::Server => window.set_client_inset(px(0.0)),
|
||||
}
|
||||
|
||||
div()
|
||||
.id("window")
|
||||
.size_full()
|
||||
.bg(gpui::transparent_black())
|
||||
.map(|div| match decorations {
|
||||
Decorations::Server => div,
|
||||
Decorations::Client { tiling } => div
|
||||
.bg(gpui::transparent_black())
|
||||
.child(
|
||||
canvas(
|
||||
|_bounds, window, _cx| {
|
||||
window.insert_hitbox(
|
||||
Bounds::new(
|
||||
point(px(0.0), px(0.0)),
|
||||
window.window_bounds().get_bounds().size,
|
||||
),
|
||||
HitboxBehavior::Normal,
|
||||
)
|
||||
},
|
||||
move |_bounds, hitbox, window, _cx| {
|
||||
let mouse = window.mouse_position();
|
||||
let size = window.window_bounds().get_bounds().size;
|
||||
|
||||
let Some(edge) =
|
||||
resize_edge(mouse, CLIENT_SIDE_DECORATION_SHADOW, size, tiling)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
window.set_cursor_style(
|
||||
match edge {
|
||||
ResizeEdge::Top | ResizeEdge::Bottom => {
|
||||
CursorStyle::ResizeUpDown
|
||||
}
|
||||
ResizeEdge::Left | ResizeEdge::Right => {
|
||||
CursorStyle::ResizeLeftRight
|
||||
}
|
||||
ResizeEdge::TopLeft | ResizeEdge::BottomRight => {
|
||||
CursorStyle::ResizeUpLeftDownRight
|
||||
}
|
||||
ResizeEdge::TopRight | ResizeEdge::BottomLeft => {
|
||||
CursorStyle::ResizeUpRightDownLeft
|
||||
}
|
||||
},
|
||||
&hitbox,
|
||||
);
|
||||
},
|
||||
)
|
||||
.size_full()
|
||||
.absolute(),
|
||||
)
|
||||
.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)
|
||||
})
|
||||
.when(!(tiling.bottom || tiling.right), |div| {
|
||||
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!(tiling.bottom || tiling.left), |div| {
|
||||
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!tiling.top, |div| div.pt(CLIENT_SIDE_DECORATION_SHADOW))
|
||||
.when(!tiling.bottom, |div| div.pb(CLIENT_SIDE_DECORATION_SHADOW))
|
||||
.when(!tiling.left, |div| div.pl(CLIENT_SIDE_DECORATION_SHADOW))
|
||||
.when(!tiling.right, |div| div.pr(CLIENT_SIDE_DECORATION_SHADOW))
|
||||
.on_mouse_down(MouseButton::Left, move |e, window, _cx| {
|
||||
let size = window.window_bounds().get_bounds().size;
|
||||
let pos = e.position;
|
||||
|
||||
if let Some(edge) =
|
||||
resize_edge(pos, CLIENT_SIDE_DECORATION_SHADOW, size, tiling)
|
||||
{
|
||||
window.start_window_resize(edge)
|
||||
};
|
||||
}),
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.map(|div| match decorations {
|
||||
Decorations::Server => div,
|
||||
Decorations::Client { tiling } => div
|
||||
.border_color(cx.theme().border)
|
||||
.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)
|
||||
})
|
||||
.when(!(tiling.bottom || tiling.right), |div| {
|
||||
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!(tiling.bottom || tiling.left), |div| {
|
||||
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!tiling.top, |div| {
|
||||
div.border_t(CLIENT_SIDE_DECORATION_BORDER)
|
||||
})
|
||||
.when(!tiling.bottom, |div| {
|
||||
div.border_b(CLIENT_SIDE_DECORATION_BORDER)
|
||||
})
|
||||
.when(!tiling.left, |div| {
|
||||
div.border_l(CLIENT_SIDE_DECORATION_BORDER)
|
||||
})
|
||||
.when(!tiling.right, |div| {
|
||||
div.border_r(CLIENT_SIDE_DECORATION_BORDER)
|
||||
})
|
||||
.when(!tiling.is_tiled(), |div| {
|
||||
div.shadow(vec![gpui::BoxShadow {
|
||||
color: Hsla {
|
||||
h: 0.,
|
||||
s: 0.,
|
||||
l: 0.,
|
||||
a: 0.4,
|
||||
},
|
||||
blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2.,
|
||||
spread_radius: px(0.),
|
||||
offset: point(px(0.0), px(0.0)),
|
||||
}])
|
||||
}),
|
||||
})
|
||||
.on_mouse_move(|_e, _, cx| {
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.size_full()
|
||||
.font_family(font_family)
|
||||
.bg(cx.theme().background)
|
||||
.text_color(cx.theme().text)
|
||||
.child(self.view.clone()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the window paddings.
|
||||
pub fn window_paddings(window: &Window, _cx: &App) -> Edges<Pixels> {
|
||||
match window.window_decorations() {
|
||||
Decorations::Server => Edges::all(px(0.0)),
|
||||
Decorations::Client { tiling } => {
|
||||
let mut paddings = Edges::all(CLIENT_SIDE_DECORATION_SHADOW);
|
||||
if tiling.top {
|
||||
paddings.top = px(0.0);
|
||||
}
|
||||
if tiling.bottom {
|
||||
paddings.bottom = px(0.0);
|
||||
}
|
||||
if tiling.left {
|
||||
paddings.left = px(0.0);
|
||||
}
|
||||
if tiling.right {
|
||||
paddings.right = px(0.0);
|
||||
}
|
||||
paddings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the window resize edge.
|
||||
fn resize_edge(
|
||||
pos: Point<Pixels>,
|
||||
shadow_size: Pixels,
|
||||
window_size: Size<Pixels>,
|
||||
tiling: Tiling,
|
||||
) -> Option<ResizeEdge> {
|
||||
let bounds = Bounds::new(Point::default(), window_size).inset(shadow_size * 1.5);
|
||||
if bounds.contains(&pos) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let corner_size = size(shadow_size * 1.5, shadow_size * 1.5);
|
||||
let top_left_bounds = Bounds::new(Point::new(px(0.), px(0.)), corner_size);
|
||||
if !tiling.top && top_left_bounds.contains(&pos) {
|
||||
return Some(ResizeEdge::TopLeft);
|
||||
}
|
||||
|
||||
let top_right_bounds = Bounds::new(
|
||||
Point::new(window_size.width - corner_size.width, px(0.)),
|
||||
corner_size,
|
||||
);
|
||||
if !tiling.top && top_right_bounds.contains(&pos) {
|
||||
return Some(ResizeEdge::TopRight);
|
||||
}
|
||||
|
||||
let bottom_left_bounds = Bounds::new(
|
||||
Point::new(px(0.), window_size.height - corner_size.height),
|
||||
corner_size,
|
||||
);
|
||||
if !tiling.bottom && bottom_left_bounds.contains(&pos) {
|
||||
return Some(ResizeEdge::BottomLeft);
|
||||
}
|
||||
|
||||
let bottom_right_bounds = Bounds::new(
|
||||
Point::new(
|
||||
window_size.width - corner_size.width,
|
||||
window_size.height - corner_size.height,
|
||||
),
|
||||
corner_size,
|
||||
);
|
||||
if !tiling.bottom && bottom_right_bounds.contains(&pos) {
|
||||
return Some(ResizeEdge::BottomRight);
|
||||
}
|
||||
|
||||
if !tiling.top && pos.y < shadow_size {
|
||||
Some(ResizeEdge::Top)
|
||||
} else if !tiling.bottom && pos.y > window_size.height - shadow_size {
|
||||
Some(ResizeEdge::Bottom)
|
||||
} else if !tiling.left && pos.x < shadow_size {
|
||||
Some(ResizeEdge::Left)
|
||||
} else if !tiling.right && pos.x > window_size.width - shadow_size {
|
||||
Some(ResizeEdge::Right)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,232 +1,209 @@
|
||||
use std::panic::Location;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, AnyElement, App, Bounds, Div, Element, ElementId, GlobalElementId,
|
||||
InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, ParentElement,
|
||||
Pixels, Position, ScrollHandle, SharedString, Size, Stateful, StatefulInteractiveElement,
|
||||
Style, StyleRefinement, Styled, Window,
|
||||
div, App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window,
|
||||
};
|
||||
|
||||
use super::{Scrollbar, ScrollbarAxis, ScrollbarState};
|
||||
use super::{Scrollbar, ScrollbarAxis};
|
||||
use crate::scroll::ScrollbarHandle;
|
||||
use crate::StyledExt;
|
||||
|
||||
/// A scroll view is a container that allows the user to scroll through a large amount of content.
|
||||
pub struct Scrollable<E> {
|
||||
/// A trait for elements that can be made scrollable with scrollbars.
|
||||
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
|
||||
/// Adds a scrollbar to the element.
|
||||
#[track_caller]
|
||||
fn scrollbar<H: ScrollbarHandle + Clone>(
|
||||
self,
|
||||
scroll_handle: &H,
|
||||
axis: impl Into<ScrollbarAxis>,
|
||||
) -> Self {
|
||||
self.child(ScrollbarLayer {
|
||||
id: "scrollbar_layer".into(),
|
||||
axis: axis.into(),
|
||||
scroll_handle: Rc::new(scroll_handle.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds a vertical scrollbar to the element.
|
||||
#[track_caller]
|
||||
fn vertical_scrollbar<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
|
||||
self.scrollbar(scroll_handle, ScrollbarAxis::Vertical)
|
||||
}
|
||||
/// Adds a horizontal scrollbar to the element.
|
||||
#[track_caller]
|
||||
fn horizontal_scrollbar<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
|
||||
self.scrollbar(scroll_handle, ScrollbarAxis::Horizontal)
|
||||
}
|
||||
|
||||
/// Almost equivalent to [`StatefulInteractiveElement::overflow_scroll`], but adds scrollbars.
|
||||
#[track_caller]
|
||||
fn overflow_scrollbar(self) -> Scrollable<Self> {
|
||||
Scrollable::new(self, ScrollbarAxis::Both)
|
||||
}
|
||||
|
||||
/// Almost equivalent to [`StatefulInteractiveElement::overflow_x_scroll`], but adds Horizontal scrollbar.
|
||||
#[track_caller]
|
||||
fn overflow_x_scrollbar(self) -> Scrollable<Self> {
|
||||
Scrollable::new(self, ScrollbarAxis::Horizontal)
|
||||
}
|
||||
|
||||
/// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar.
|
||||
#[track_caller]
|
||||
fn overflow_y_scrollbar(self) -> Scrollable<Self> {
|
||||
Scrollable::new(self, ScrollbarAxis::Vertical)
|
||||
}
|
||||
}
|
||||
|
||||
/// A scrollable element wrapper that adds scrollbars to an interactive element.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Scrollable<E: InteractiveElement + Styled + ParentElement + Element> {
|
||||
id: ElementId,
|
||||
element: Option<E>,
|
||||
element: E,
|
||||
axis: ScrollbarAxis,
|
||||
/// This is a fake element to handle Styled, InteractiveElement, not used.
|
||||
_element: Stateful<Div>,
|
||||
}
|
||||
|
||||
impl<E> Scrollable<E>
|
||||
where
|
||||
E: Element,
|
||||
E: InteractiveElement + Styled + ParentElement + Element,
|
||||
{
|
||||
pub(crate) fn new(axis: impl Into<ScrollbarAxis>, element: E) -> Self {
|
||||
let id = ElementId::Name(SharedString::from(
|
||||
format!("scrollable-{:?}", element.id(),),
|
||||
));
|
||||
|
||||
#[track_caller]
|
||||
fn new(element: E, axis: impl Into<ScrollbarAxis>) -> Self {
|
||||
let caller = Location::caller();
|
||||
Self {
|
||||
element: Some(element),
|
||||
_element: div().id("fake"),
|
||||
id,
|
||||
id: ElementId::CodeLocation(*caller),
|
||||
element,
|
||||
axis: axis.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set only a vertical scrollbar.
|
||||
pub fn vertical(mut self) -> Self {
|
||||
self.set_axis(ScrollbarAxis::Vertical);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set only a horizontal scrollbar.
|
||||
/// In current implementation, this is not supported yet.
|
||||
pub fn horizontal(mut self) -> Self {
|
||||
self.set_axis(ScrollbarAxis::Horizontal);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the axis of the scroll view.
|
||||
pub fn set_axis(&mut self, axis: impl Into<ScrollbarAxis>) {
|
||||
self.axis = axis.into();
|
||||
}
|
||||
|
||||
fn with_element_state<R>(
|
||||
&mut self,
|
||||
id: &GlobalElementId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
f: impl FnOnce(&mut Self, &mut ScrollViewState, &mut Window, &mut App) -> R,
|
||||
) -> R {
|
||||
window.with_optional_element_state::<ScrollViewState, _>(
|
||||
Some(id),
|
||||
|element_state, window| {
|
||||
let mut element_state = element_state.unwrap().unwrap_or_default();
|
||||
let result = f(self, &mut element_state, window, cx);
|
||||
(result, Some(element_state))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ScrollViewState {
|
||||
state: ScrollbarState,
|
||||
handle: ScrollHandle,
|
||||
}
|
||||
|
||||
impl Default for ScrollViewState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
handle: ScrollHandle::new(),
|
||||
state: ScrollbarState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> ParentElement for Scrollable<E>
|
||||
where
|
||||
E: Element + ParentElement,
|
||||
{
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
if let Some(element) = &mut self.element {
|
||||
element.extend(elements);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> Styled for Scrollable<E>
|
||||
where
|
||||
E: Element + Styled,
|
||||
E: InteractiveElement + Styled + ParentElement + Element,
|
||||
{
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
if let Some(element) = &mut self.element {
|
||||
element.style()
|
||||
} else {
|
||||
self._element.style()
|
||||
}
|
||||
self.element.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> InteractiveElement for Scrollable<E>
|
||||
impl<E> ParentElement for Scrollable<E>
|
||||
where
|
||||
E: Element + InteractiveElement,
|
||||
E: InteractiveElement + Styled + ParentElement + Element,
|
||||
{
|
||||
fn interactivity(&mut self) -> &mut Interactivity {
|
||||
if let Some(element) = &mut self.element {
|
||||
element.interactivity()
|
||||
} else {
|
||||
self._element.interactivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<E> StatefulInteractiveElement for Scrollable<E> where E: Element + StatefulInteractiveElement {}
|
||||
|
||||
impl<E> IntoElement for Scrollable<E>
|
||||
where
|
||||
E: Element,
|
||||
{
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
|
||||
self.element.extend(elements)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> Element for Scrollable<E>
|
||||
impl InteractiveElement for Scrollable<Div> {
|
||||
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||
self.element.interactivity()
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for Scrollable<Stateful<Div>> {
|
||||
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||
self.element.interactivity()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> RenderOnce for Scrollable<E>
|
||||
where
|
||||
E: Element,
|
||||
E: InteractiveElement + Styled + ParentElement + Element + 'static,
|
||||
{
|
||||
type PrepaintState = ScrollViewState;
|
||||
type RequestLayoutState = AnyElement;
|
||||
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let scroll_handle = window
|
||||
.use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::default())
|
||||
.read(cx)
|
||||
.clone();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
id: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let style = Style {
|
||||
position: Position::Relative,
|
||||
flex_grow: 1.0,
|
||||
flex_shrink: 1.0,
|
||||
size: Size {
|
||||
width: relative(1.).into(),
|
||||
height: relative(1.).into(),
|
||||
},
|
||||
// Inherit the size from the element style.
|
||||
let style = StyleRefinement {
|
||||
size: self.element.style().size.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let axis = self.axis;
|
||||
let scroll_id = self.id.clone();
|
||||
let content = self.element.take().map(|c| c.into_any_element());
|
||||
|
||||
self.with_element_state(id.unwrap(), window, cx, |_, element_state, window, cx| {
|
||||
let mut element = div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.id(scroll_id)
|
||||
.track_scroll(&element_state.handle)
|
||||
.overflow_scroll()
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(div().children(content)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(
|
||||
Scrollbar::both(&element_state.state, &element_state.handle).axis(axis),
|
||||
),
|
||||
)
|
||||
.into_any_element();
|
||||
|
||||
let element_id = element.request_layout(window, cx);
|
||||
let layout_id = window.request_layout(style, vec![element_id], cx);
|
||||
|
||||
(layout_id, element)
|
||||
})
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
element: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
element.prepaint(window, cx);
|
||||
// do nothing
|
||||
ScrollViewState::default()
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
element: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
element.paint(window, cx)
|
||||
div()
|
||||
.id(self.id)
|
||||
.size_full()
|
||||
.refine_style(&style)
|
||||
.relative()
|
||||
.child(
|
||||
div()
|
||||
.id("scroll-area")
|
||||
.flex()
|
||||
.size_full()
|
||||
.track_scroll(&scroll_handle)
|
||||
.map(|this| match self.axis {
|
||||
ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(),
|
||||
ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
|
||||
ScrollbarAxis::Both => this.overflow_scroll(),
|
||||
})
|
||||
.child(
|
||||
self.element
|
||||
// Refine element size to `flex_1`.
|
||||
.size_auto()
|
||||
.flex_1(),
|
||||
),
|
||||
)
|
||||
.child(render_scrollbar(
|
||||
"scrollbar",
|
||||
&scroll_handle,
|
||||
self.axis,
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollableElement for Div {}
|
||||
impl<E> ScrollableElement for Stateful<E>
|
||||
where
|
||||
E: ParentElement + Styled + Element,
|
||||
Self: InteractiveElement,
|
||||
{
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
|
||||
id: ElementId,
|
||||
axis: ScrollbarAxis,
|
||||
scroll_handle: Rc<H>,
|
||||
}
|
||||
|
||||
impl<H> RenderOnce for ScrollbarLayer<H>
|
||||
where
|
||||
H: ScrollbarHandle + Clone + 'static,
|
||||
{
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
render_scrollbar(self.id, self.scroll_handle.as_ref(), self.axis, window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[track_caller]
|
||||
fn render_scrollbar<H: ScrollbarHandle + Clone>(
|
||||
id: impl Into<ElementId>,
|
||||
scroll_handle: &H,
|
||||
axis: ScrollbarAxis,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Div {
|
||||
// Do not render scrollbar when inspector is picking elements,
|
||||
// to allow us to pick the background elements.
|
||||
let is_inspector_picking = window.is_inspector_picking(cx);
|
||||
if is_inspector_picking {
|
||||
return div();
|
||||
}
|
||||
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::new(scroll_handle).id(id).axis(axis))
|
||||
}
|
||||
|
||||
@@ -1,43 +1,50 @@
|
||||
use std::cell::Cell;
|
||||
use std::ops::Deref;
|
||||
use std::panic::Location;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{
|
||||
fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner,
|
||||
CursorStyle, Edges, Element, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId,
|
||||
IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
|
||||
Position, ScrollHandle, ScrollWheelEvent, Size, UniformListScrollHandle, Window,
|
||||
CursorStyle, Edges, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
|
||||
InspectorElementId, IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent,
|
||||
MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style,
|
||||
UniformListScrollHandle, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, ScrollbarMode};
|
||||
|
||||
use crate::AxisExt;
|
||||
|
||||
const WIDTH: Pixels = px(2. * 2. + 8.);
|
||||
/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
|
||||
const WIDTH: Pixels = px(1. * 2. + 8.);
|
||||
const MIN_THUMB_SIZE: f32 = 48.;
|
||||
|
||||
const THUMB_WIDTH: Pixels = px(6.);
|
||||
const THUMB_RADIUS: Pixels = px(6. / 2.);
|
||||
const THUMB_INSET: Pixels = px(2.);
|
||||
const THUMB_INSET: Pixels = px(1.);
|
||||
|
||||
const THUMB_ACTIVE_WIDTH: Pixels = px(8.);
|
||||
const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.);
|
||||
const THUMB_ACTIVE_INSET: Pixels = px(2.);
|
||||
const THUMB_ACTIVE_INSET: Pixels = px(1.);
|
||||
|
||||
const FADE_OUT_DURATION: f32 = 3.0;
|
||||
const FADE_OUT_DELAY: f32 = 2.0;
|
||||
|
||||
pub trait ScrollHandleOffsetable {
|
||||
/// A trait for scroll handles that can get and set offset.
|
||||
pub trait ScrollbarHandle: 'static {
|
||||
/// Get the current offset of the scroll handle.
|
||||
fn offset(&self) -> Point<Pixels>;
|
||||
/// Set the offset of the scroll handle.
|
||||
fn set_offset(&self, offset: Point<Pixels>);
|
||||
fn is_uniform_list(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The full size of the content, including padding.
|
||||
fn content_size(&self) -> Size<Pixels>;
|
||||
/// Called when start dragging the scrollbar thumb.
|
||||
fn start_drag(&self) {}
|
||||
/// Called when end dragging the scrollbar thumb.
|
||||
fn end_drag(&self) {}
|
||||
}
|
||||
|
||||
impl ScrollHandleOffsetable for ScrollHandle {
|
||||
impl ScrollbarHandle for ScrollHandle {
|
||||
fn offset(&self) -> Point<Pixels> {
|
||||
self.offset()
|
||||
}
|
||||
@@ -51,7 +58,7 @@ impl ScrollHandleOffsetable for ScrollHandle {
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollHandleOffsetable for UniformListScrollHandle {
|
||||
impl ScrollbarHandle for UniformListScrollHandle {
|
||||
fn offset(&self) -> Point<Pixels> {
|
||||
self.0.borrow().base_handle.offset()
|
||||
}
|
||||
@@ -60,21 +67,41 @@ impl ScrollHandleOffsetable for UniformListScrollHandle {
|
||||
self.0.borrow_mut().base_handle.set_offset(offset)
|
||||
}
|
||||
|
||||
fn is_uniform_list(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn content_size(&self) -> Size<Pixels> {
|
||||
let base_handle = &self.0.borrow().base_handle;
|
||||
base_handle.max_offset() + base_handle.bounds().size
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollbarState(Rc<Cell<ScrollbarStateInner>>);
|
||||
impl ScrollbarHandle for ListState {
|
||||
fn offset(&self) -> Point<Pixels> {
|
||||
self.scroll_px_offset_for_scrollbar()
|
||||
}
|
||||
|
||||
fn set_offset(&self, offset: Point<Pixels>) {
|
||||
self.set_offset_from_scrollbar(offset);
|
||||
}
|
||||
|
||||
fn content_size(&self) -> Size<Pixels> {
|
||||
self.viewport_bounds().size + self.max_offset_for_scrollbar()
|
||||
}
|
||||
|
||||
fn start_drag(&self) {
|
||||
self.scrollbar_drag_started();
|
||||
}
|
||||
|
||||
fn end_drag(&self) {
|
||||
self.scrollbar_drag_ended();
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct ScrollbarState(Rc<Cell<ScrollbarStateInner>>);
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ScrollbarStateInner {
|
||||
struct ScrollbarStateInner {
|
||||
hovered_axis: Option<Axis>,
|
||||
hovered_on_thumb: Option<Axis>,
|
||||
dragged_axis: Option<Axis>,
|
||||
@@ -83,6 +110,7 @@ pub struct ScrollbarStateInner {
|
||||
last_scroll_time: Option<Instant>,
|
||||
// Last update offset
|
||||
last_update: Instant,
|
||||
idle_timer_scheduled: bool,
|
||||
}
|
||||
|
||||
impl Default for ScrollbarState {
|
||||
@@ -95,6 +123,7 @@ impl Default for ScrollbarState {
|
||||
last_scroll_offset: point(px(0.), px(0.)),
|
||||
last_scroll_time: None,
|
||||
last_update: Instant::now(),
|
||||
idle_timer_scheduled: false,
|
||||
})))
|
||||
}
|
||||
}
|
||||
@@ -167,6 +196,12 @@ impl ScrollbarStateInner {
|
||||
state
|
||||
}
|
||||
|
||||
fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self {
|
||||
let mut state = *self;
|
||||
state.idle_timer_scheduled = scheduled;
|
||||
state
|
||||
}
|
||||
|
||||
fn is_scrollbar_visible(&self) -> bool {
|
||||
// On drag
|
||||
if self.dragged_axis.is_some() {
|
||||
@@ -182,10 +217,14 @@ impl ScrollbarStateInner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrollbar axis.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScrollbarAxis {
|
||||
/// Vertical scrollbar.
|
||||
Vertical,
|
||||
/// Horizontal scrollbar.
|
||||
Horizontal,
|
||||
/// Show both vertical and horizontal scrollbars.
|
||||
Both,
|
||||
}
|
||||
|
||||
@@ -200,25 +239,30 @@ impl From<Axis> for ScrollbarAxis {
|
||||
|
||||
impl ScrollbarAxis {
|
||||
/// Return true if the scrollbar axis is vertical.
|
||||
#[inline]
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
matches!(self, Self::Vertical)
|
||||
}
|
||||
|
||||
/// Return true if the scrollbar axis is horizontal.
|
||||
#[inline]
|
||||
pub fn is_horizontal(&self) -> bool {
|
||||
matches!(self, Self::Horizontal)
|
||||
}
|
||||
|
||||
/// Return true if the scrollbar axis is both vertical and horizontal.
|
||||
#[inline]
|
||||
pub fn is_both(&self) -> bool {
|
||||
matches!(self, Self::Both)
|
||||
}
|
||||
|
||||
/// Return true if the scrollbar has vertical axis.
|
||||
#[inline]
|
||||
pub fn has_vertical(&self) -> bool {
|
||||
matches!(self, Self::Vertical | Self::Both)
|
||||
}
|
||||
|
||||
/// Return true if the scrollbar has horizontal axis.
|
||||
#[inline]
|
||||
pub fn has_horizontal(&self) -> bool {
|
||||
matches!(self, Self::Horizontal | Self::Both)
|
||||
@@ -238,9 +282,10 @@ impl ScrollbarAxis {
|
||||
|
||||
/// Scrollbar control for scroll-area or a uniform-list.
|
||||
pub struct Scrollbar {
|
||||
pub(crate) id: ElementId,
|
||||
axis: ScrollbarAxis,
|
||||
scroll_handle: Rc<Box<dyn ScrollHandleOffsetable>>,
|
||||
state: ScrollbarState,
|
||||
scrollbar_mode: Option<ScrollbarMode>,
|
||||
scroll_handle: Rc<dyn ScrollbarHandle>,
|
||||
scroll_size: Option<Size<Pixels>>,
|
||||
/// Maximum frames per second for scrolling by drag. Default is 120 FPS.
|
||||
///
|
||||
@@ -250,50 +295,46 @@ pub struct Scrollbar {
|
||||
}
|
||||
|
||||
impl Scrollbar {
|
||||
fn new(
|
||||
axis: impl Into<ScrollbarAxis>,
|
||||
state: &ScrollbarState,
|
||||
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
|
||||
) -> Self {
|
||||
/// Create a new scrollbar.
|
||||
///
|
||||
/// This will have both vertical and horizontal scrollbars.
|
||||
#[track_caller]
|
||||
pub fn new<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
|
||||
let caller = Location::caller();
|
||||
Self {
|
||||
state: state.clone(),
|
||||
axis: axis.into(),
|
||||
scroll_handle: Rc::new(Box::new(scroll_handle.clone())),
|
||||
id: ElementId::CodeLocation(*caller),
|
||||
axis: ScrollbarAxis::Both,
|
||||
scrollbar_mode: None,
|
||||
scroll_handle: Rc::new(scroll_handle.clone()),
|
||||
max_fps: 120,
|
||||
scroll_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with vertical and horizontal scrollbar.
|
||||
pub fn both(
|
||||
state: &ScrollbarState,
|
||||
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
|
||||
) -> Self {
|
||||
Self::new(ScrollbarAxis::Both, state, scroll_handle)
|
||||
}
|
||||
|
||||
/// Create with horizontal scrollbar.
|
||||
pub fn horizontal(
|
||||
state: &ScrollbarState,
|
||||
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
|
||||
) -> Self {
|
||||
Self::new(ScrollbarAxis::Horizontal, state, scroll_handle)
|
||||
#[track_caller]
|
||||
pub fn horizontal<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
|
||||
Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal)
|
||||
}
|
||||
|
||||
/// Create with vertical scrollbar.
|
||||
pub fn vertical(
|
||||
state: &ScrollbarState,
|
||||
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
|
||||
) -> Self {
|
||||
Self::new(ScrollbarAxis::Vertical, state, scroll_handle)
|
||||
#[track_caller]
|
||||
pub fn vertical<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
|
||||
Self::new(scroll_handle).axis(ScrollbarAxis::Vertical)
|
||||
}
|
||||
|
||||
/// Create vertical scrollbar for uniform list.
|
||||
pub fn uniform_scroll(
|
||||
state: &ScrollbarState,
|
||||
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
|
||||
) -> Self {
|
||||
Self::new(ScrollbarAxis::Vertical, state, scroll_handle)
|
||||
/// Set a specific element id, default is the [`Location::caller`].
|
||||
///
|
||||
/// NOTE: In most cases, you don't need to set a specific id for scrollbar.
|
||||
pub fn id(mut self, id: impl Into<ElementId>) -> Self {
|
||||
self.id = id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the scrollbar show mode [`ScrollbarShow`], if not set use the `cx.theme().scrollbar_show`.
|
||||
pub fn scrollbar_mode(mut self, mode: ScrollbarMode) -> Self {
|
||||
self.scrollbar_mode = Some(mode);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a special scroll size of the content area, default is None.
|
||||
@@ -315,11 +356,18 @@ impl Scrollbar {
|
||||
/// If you have very high CPU usage, consider reducing this value to improve performance.
|
||||
///
|
||||
/// Available values: 30..120
|
||||
pub fn max_fps(mut self, max_fps: usize) -> Self {
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn max_fps(mut self, max_fps: usize) -> Self {
|
||||
self.max_fps = max_fps.clamp(30, 120);
|
||||
self
|
||||
}
|
||||
|
||||
// Get the width of the scrollbar.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const fn width() -> Pixels {
|
||||
WIDTH
|
||||
}
|
||||
|
||||
fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
|
||||
(
|
||||
cx.theme().scrollbar_thumb_hover_background,
|
||||
@@ -353,11 +401,28 @@ impl Scrollbar {
|
||||
)
|
||||
}
|
||||
|
||||
fn style_for_idle(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
|
||||
let (width, inset, radius) = if cx.theme().scrollbar_mode.is_scrolling() {
|
||||
(THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS)
|
||||
} else {
|
||||
(THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS)
|
||||
fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
|
||||
let scrollbar_mode = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
|
||||
let (width, inset, radius) = match scrollbar_mode {
|
||||
ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
|
||||
_ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
|
||||
};
|
||||
|
||||
(
|
||||
cx.theme().scrollbar_thumb_background,
|
||||
cx.theme().scrollbar_track_background,
|
||||
gpui::transparent_black(),
|
||||
width,
|
||||
inset,
|
||||
radius,
|
||||
)
|
||||
}
|
||||
|
||||
fn style_for_idle(&self, _cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
|
||||
let scrollbar_mode = self.scrollbar_mode.unwrap_or(ScrollbarMode::Always);
|
||||
let (width, inset, radius) = match scrollbar_mode {
|
||||
ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
|
||||
_ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
|
||||
};
|
||||
|
||||
(
|
||||
@@ -379,11 +444,14 @@ impl IntoElement for Scrollbar {
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub struct PrepaintState {
|
||||
hitbox: Hitbox,
|
||||
scrollbar_state: ScrollbarState,
|
||||
states: Vec<AxisPrepaintState>,
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub struct AxisPrepaintState {
|
||||
axis: Axis,
|
||||
bar_hitbox: Hitbox,
|
||||
@@ -406,7 +474,7 @@ impl Element for Scrollbar {
|
||||
type RequestLayoutState = ();
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
None
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
@@ -420,11 +488,11 @@ impl Element for Scrollbar {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let style = gpui::Style {
|
||||
let style = Style {
|
||||
position: Position::Absolute,
|
||||
flex_grow: 1.0,
|
||||
flex_shrink: 1.0,
|
||||
size: gpui::Size {
|
||||
size: Size {
|
||||
width: relative(1.).into(),
|
||||
height: relative(1.).into(),
|
||||
},
|
||||
@@ -447,6 +515,11 @@ impl Element for Scrollbar {
|
||||
window.insert_hitbox(bounds, HitboxBehavior::Normal)
|
||||
});
|
||||
|
||||
let state = window
|
||||
.use_state(cx, |_, _| ScrollbarState::default())
|
||||
.read(cx)
|
||||
.clone();
|
||||
|
||||
let mut states = vec![];
|
||||
let mut has_both = self.axis.is_both();
|
||||
let scroll_size = self
|
||||
@@ -470,9 +543,8 @@ impl Element for Scrollbar {
|
||||
};
|
||||
|
||||
// The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
|
||||
|
||||
let margin_end = if has_both && !is_vertical {
|
||||
THUMB_ACTIVE_WIDTH
|
||||
WIDTH
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
@@ -512,11 +584,12 @@ impl Element for Scrollbar {
|
||||
},
|
||||
};
|
||||
|
||||
let state = self.state.clone();
|
||||
let is_always_to_show = cx.theme().scrollbar_mode.is_always();
|
||||
let is_hover_to_show = cx.theme().scrollbar_mode.is_hover();
|
||||
let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
|
||||
let is_always_to_show = scrollbar_show.is_always();
|
||||
let is_hover_to_show = scrollbar_show.is_hover();
|
||||
let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
|
||||
let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis);
|
||||
let is_offset_changed = state.get().last_scroll_offset != self.scroll_handle.offset();
|
||||
|
||||
let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) =
|
||||
if state.get().dragged_axis == Some(axis) {
|
||||
@@ -527,38 +600,47 @@ impl Element for Scrollbar {
|
||||
} else {
|
||||
Self::style_for_hovered_bar(cx)
|
||||
}
|
||||
} else if is_offset_changed {
|
||||
self.style_for_normal(cx)
|
||||
} else if is_always_to_show {
|
||||
#[allow(clippy::if_same_then_else)]
|
||||
if is_hovered_on_thumb {
|
||||
Self::style_for_hovered_thumb(cx)
|
||||
} else {
|
||||
Self::style_for_hovered_bar(cx)
|
||||
}
|
||||
} else {
|
||||
let mut idle_state = Self::style_for_idle(cx);
|
||||
let mut idle_state = self.style_for_idle(cx);
|
||||
// Delay 2s to fade out the scrollbar thumb (in 1s)
|
||||
if let Some(last_time) = state.get().last_scroll_time {
|
||||
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
|
||||
if elapsed < FADE_OUT_DURATION {
|
||||
if is_hovered_on_bar {
|
||||
state.set(state.get().with_last_scroll_time(Some(Instant::now())));
|
||||
idle_state = if is_hovered_on_thumb {
|
||||
Self::style_for_hovered_thumb(cx)
|
||||
} else {
|
||||
Self::style_for_hovered_bar(cx)
|
||||
};
|
||||
if is_hovered_on_bar {
|
||||
state.set(state.get().with_last_scroll_time(Some(Instant::now())));
|
||||
idle_state = if is_hovered_on_thumb {
|
||||
Self::style_for_hovered_thumb(cx)
|
||||
} else {
|
||||
if elapsed < FADE_OUT_DELAY {
|
||||
idle_state.0 = cx.theme().scrollbar_thumb_background;
|
||||
} else {
|
||||
// opacity = 1 - (x - 2)^10
|
||||
let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10);
|
||||
idle_state.0 =
|
||||
cx.theme().scrollbar_thumb_background.opacity(opacity);
|
||||
};
|
||||
Self::style_for_hovered_bar(cx)
|
||||
};
|
||||
} else if elapsed < FADE_OUT_DELAY {
|
||||
idle_state.0 = cx.theme().scrollbar_thumb_background;
|
||||
|
||||
window.request_animation_frame();
|
||||
if !state.get().idle_timer_scheduled {
|
||||
let state = state.clone();
|
||||
state.set(state.get().with_idle_timer_scheduled(true));
|
||||
let current_view = window.current_view();
|
||||
let next_delay = Duration::from_secs_f32(FADE_OUT_DELAY - elapsed);
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
cx.background_executor().timer(next_delay).await;
|
||||
state.set(state.get().with_idle_timer_scheduled(false));
|
||||
cx.update(|_, cx| cx.notify(current_view)).ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
} else if elapsed < FADE_OUT_DURATION {
|
||||
let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10);
|
||||
idle_state.0 = cx.theme().scrollbar_thumb_background.opacity(opacity);
|
||||
|
||||
window.request_animation_frame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,7 +699,11 @@ impl Element for Scrollbar {
|
||||
})
|
||||
}
|
||||
|
||||
PrepaintState { hitbox, states }
|
||||
PrepaintState {
|
||||
hitbox,
|
||||
states,
|
||||
scrollbar_state: state,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
@@ -630,19 +716,21 @@ impl Element for Scrollbar {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let scrollbar_state = &prepaint.scrollbar_state;
|
||||
let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
|
||||
let view_id = window.current_view();
|
||||
let hitbox_bounds = prepaint.hitbox.bounds;
|
||||
let is_visible =
|
||||
self.state.get().is_scrollbar_visible() || cx.theme().scrollbar_mode.is_always();
|
||||
let is_hover_to_show = cx.theme().scrollbar_mode.is_hover();
|
||||
let is_visible = scrollbar_state.get().is_scrollbar_visible() || scrollbar_show.is_always();
|
||||
let is_hover_to_show = scrollbar_show.is_hover();
|
||||
|
||||
// Update last_scroll_time when offset is changed.
|
||||
if self.scroll_handle.offset() != self.state.get().last_scroll_offset {
|
||||
self.state.set(
|
||||
self.state
|
||||
if self.scroll_handle.offset() != scrollbar_state.get().last_scroll_offset {
|
||||
scrollbar_state.set(
|
||||
scrollbar_state
|
||||
.get()
|
||||
.with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())),
|
||||
);
|
||||
cx.notify(view_id);
|
||||
}
|
||||
|
||||
window.with_content_mask(
|
||||
@@ -652,7 +740,10 @@ impl Element for Scrollbar {
|
||||
|window| {
|
||||
for state in prepaint.states.iter() {
|
||||
let axis = state.axis;
|
||||
let radius = state.radius;
|
||||
let mut radius = state.radius;
|
||||
if cx.theme().radius.is_zero() {
|
||||
radius = px(0.);
|
||||
}
|
||||
let bounds = state.bounds;
|
||||
let thumb_bounds = state.thumb_bounds;
|
||||
let scroll_area_size = state.scroll_size;
|
||||
@@ -686,7 +777,7 @@ impl Element for Scrollbar {
|
||||
});
|
||||
|
||||
window.on_mouse_event({
|
||||
let state = self.state.clone();
|
||||
let state = scrollbar_state.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
|
||||
move |event: &ScrollWheelEvent, phase, _, cx| {
|
||||
@@ -707,7 +798,7 @@ impl Element for Scrollbar {
|
||||
|
||||
if is_hover_to_show || is_visible {
|
||||
window.on_mouse_event({
|
||||
let state = self.state.clone();
|
||||
let state = scrollbar_state.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
|
||||
move |event: &MouseDownEvent, phase, _, cx| {
|
||||
@@ -718,6 +809,7 @@ impl Element for Scrollbar {
|
||||
// click on the thumb bar, set the drag position
|
||||
let pos = event.position - thumb_bounds.origin;
|
||||
|
||||
scroll_handle.start_drag();
|
||||
state.set(state.get().with_drag_pos(axis, pos));
|
||||
|
||||
cx.notify(view_id);
|
||||
@@ -755,7 +847,7 @@ impl Element for Scrollbar {
|
||||
|
||||
window.on_mouse_event({
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let state = self.state.clone();
|
||||
let state = scrollbar_state.clone();
|
||||
let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64);
|
||||
|
||||
move |event: &MouseMoveEvent, _, _, cx| {
|
||||
@@ -770,9 +862,7 @@ impl Element for Scrollbar {
|
||||
if state.get().hovered_axis != Some(axis) {
|
||||
notify = true;
|
||||
}
|
||||
} else if state.get().hovered_axis == Some(axis)
|
||||
&& state.get().hovered_axis.is_some()
|
||||
{
|
||||
} else if state.get().hovered_axis == Some(axis) {
|
||||
state.set(state.get().with_hovered(None));
|
||||
notify = true;
|
||||
}
|
||||
@@ -790,6 +880,9 @@ impl Element for Scrollbar {
|
||||
|
||||
// Move thumb position on dragging
|
||||
if state.get().dragged_axis == Some(axis) && event.dragging() {
|
||||
// Stop the event propagation to avoid selecting text or other side effects.
|
||||
cx.stop_propagation();
|
||||
|
||||
// drag_pos is the position of the mouse down event
|
||||
// We need to keep the thumb bar still at the origin down position
|
||||
let drag_pos = state.get().drag_pos;
|
||||
@@ -836,10 +929,12 @@ impl Element for Scrollbar {
|
||||
});
|
||||
|
||||
window.on_mouse_event({
|
||||
let state = self.state.clone();
|
||||
let state = scrollbar_state.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
|
||||
move |_event: &MouseUpEvent, phase, _, cx| {
|
||||
if phase.bubble() {
|
||||
scroll_handle.end_drag();
|
||||
state.set(state.get().with_unset_drag_pos());
|
||||
cx.notify(view_id);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ impl Skeleton {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secondary(mut self, secondary: bool) -> Self {
|
||||
self.secondary = secondary;
|
||||
pub fn secondary(mut self) -> Self {
|
||||
self.secondary = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
|
||||
use gpui::{div, px, App, Axis, Div, Element, Pixels, Refineable, StyleRefinement, Styled};
|
||||
use gpui::{div, px, App, Div, Pixels, Refineable, StyleRefinement, Styled};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::scroll::{Scrollable, ScrollbarAxis};
|
||||
|
||||
/// Returns a `Div` as horizontal flex layout.
|
||||
pub fn h_flex() -> Div {
|
||||
div().h_flex()
|
||||
@@ -18,7 +14,7 @@ pub fn v_flex() -> Div {
|
||||
|
||||
/// Returns a `Div` as divider.
|
||||
pub fn divider(cx: &App) -> Div {
|
||||
div().my_2().w_full().h_px().bg(cx.theme().border)
|
||||
div().my_2().w_full().h_px().bg(cx.theme().border_variant)
|
||||
}
|
||||
|
||||
macro_rules! font_weight {
|
||||
@@ -50,17 +46,6 @@ pub trait StyledExt: Styled + Sized {
|
||||
self.flex().flex_col()
|
||||
}
|
||||
|
||||
/// Wraps the element in a ScrollView.
|
||||
///
|
||||
/// Current this is only have a vertical scrollbar.
|
||||
#[inline]
|
||||
fn scrollable(self, axis: impl Into<ScrollbarAxis>) -> Scrollable<Self>
|
||||
where
|
||||
Self: Element,
|
||||
{
|
||||
Scrollable::new(axis, self)
|
||||
}
|
||||
|
||||
font_weight!(font_thin, THIN);
|
||||
font_weight!(font_extralight, EXTRA_LIGHT);
|
||||
font_weight!(font_light, LIGHT);
|
||||
@@ -183,39 +168,43 @@ impl<T: Styled> StyleSized<T> for T {
|
||||
|
||||
fn input_pl(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Large => self.pl_5(),
|
||||
Size::XSmall => self.pl_1(),
|
||||
Size::Medium => self.pl_3(),
|
||||
Size::Large => self.pl_5(),
|
||||
_ => self.pl_2(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_pr(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Large => self.pr_5(),
|
||||
Size::XSmall => self.pr_1(),
|
||||
Size::Medium => self.pr_3(),
|
||||
Size::Large => self.pr_5(),
|
||||
_ => self.pr_2(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_px(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Large => self.px_5(),
|
||||
Size::XSmall => self.px_1(),
|
||||
Size::Medium => self.px_3(),
|
||||
Size::Large => self.px_5(),
|
||||
_ => self.px_2(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_py(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Large => self.py_5(),
|
||||
Size::XSmall => self.py_0p5(),
|
||||
Size::Medium => self.py_2(),
|
||||
Size::Large => self.py_5(),
|
||||
_ => self.py_1(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_h(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::XSmall => self.h_7(),
|
||||
Size::XSmall => self.h_6(),
|
||||
Size::Small => self.h_8(),
|
||||
Size::Medium => self.h_9(),
|
||||
Size::Large => self.h_12(),
|
||||
@@ -255,74 +244,6 @@ impl<T: Styled> StyleSized<T> for T {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AxisExt {
|
||||
fn is_horizontal(&self) -> bool;
|
||||
fn is_vertical(&self) -> bool;
|
||||
}
|
||||
|
||||
impl AxisExt for Axis {
|
||||
fn is_horizontal(&self) -> bool {
|
||||
self == &Axis::Horizontal
|
||||
}
|
||||
|
||||
fn is_vertical(&self) -> bool {
|
||||
self == &Axis::Vertical
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Placement {
|
||||
Top,
|
||||
Bottom,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Display for Placement {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Placement::Top => write!(f, "Top"),
|
||||
Placement::Bottom => write!(f, "Bottom"),
|
||||
Placement::Left => write!(f, "Left"),
|
||||
Placement::Right => write!(f, "Right"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
pub fn is_horizontal(&self) -> bool {
|
||||
matches!(self, Placement::Left | Placement::Right)
|
||||
}
|
||||
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
matches!(self, Placement::Top | Placement::Bottom)
|
||||
}
|
||||
|
||||
pub fn axis(&self) -> Axis {
|
||||
match self {
|
||||
Placement::Top | Placement::Bottom => Axis::Vertical,
|
||||
Placement::Left | Placement::Right => Axis::Horizontal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A enum for defining the side of the element.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Side {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
pub(crate) fn is_left(&self) -> bool {
|
||||
matches!(self, Self::Left)
|
||||
}
|
||||
|
||||
pub(crate) fn is_right(&self) -> bool {
|
||||
matches!(self, Self::Right)
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for defining element that can be collapsed.
|
||||
pub trait Collapsible {
|
||||
fn collapsed(self, collapsed: bool) -> Self;
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
canvas, div, point, px, AnyElement, App, Bounds, CursorStyle, Decorations, Edges,
|
||||
HitboxBehavior, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels,
|
||||
Point, RenderOnce, ResizeEdge, Size, Styled as _, Window,
|
||||
};
|
||||
use theme::{CLIENT_SIDE_DECORATION_ROUNDING, CLIENT_SIDE_DECORATION_SHADOW};
|
||||
|
||||
const WINDOW_BORDER_WIDTH: Pixels = px(1.0);
|
||||
|
||||
/// Create a new window border.
|
||||
pub fn window_border() -> WindowBorder {
|
||||
WindowBorder::new()
|
||||
}
|
||||
|
||||
/// Window border use to render a custom window border and shadow for Linux.
|
||||
#[derive(IntoElement, Default)]
|
||||
pub struct WindowBorder {
|
||||
children: Vec<AnyElement>,
|
||||
}
|
||||
|
||||
/// Get the window paddings.
|
||||
pub fn window_paddings(window: &Window, _cx: &App) -> Edges<Pixels> {
|
||||
match window.window_decorations() {
|
||||
Decorations::Server => Edges::all(px(0.0)),
|
||||
Decorations::Client { tiling } => {
|
||||
let mut paddings = Edges::all(CLIENT_SIDE_DECORATION_SHADOW);
|
||||
if tiling.top {
|
||||
paddings.top = px(0.0);
|
||||
}
|
||||
if tiling.bottom {
|
||||
paddings.bottom = px(0.0);
|
||||
}
|
||||
if tiling.left {
|
||||
paddings.left = px(0.0);
|
||||
}
|
||||
if tiling.right {
|
||||
paddings.right = px(0.0);
|
||||
}
|
||||
paddings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowBorder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for WindowBorder {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WindowBorder {
|
||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let decorations = window.window_decorations();
|
||||
window.set_client_inset(CLIENT_SIDE_DECORATION_SHADOW);
|
||||
|
||||
div()
|
||||
.id("window-backdrop")
|
||||
.bg(gpui::transparent_black())
|
||||
.map(|div| match decorations {
|
||||
Decorations::Server => div,
|
||||
Decorations::Client { tiling, .. } => div
|
||||
.bg(gpui::transparent_black())
|
||||
.child(
|
||||
canvas(
|
||||
|_bounds, window, _cx| {
|
||||
window.insert_hitbox(
|
||||
Bounds::new(
|
||||
point(px(0.0), px(0.0)),
|
||||
window.window_bounds().get_bounds().size,
|
||||
),
|
||||
HitboxBehavior::Normal,
|
||||
)
|
||||
},
|
||||
move |_bounds, hitbox, window, _cx| {
|
||||
let mouse = window.mouse_position();
|
||||
let size = window.window_bounds().get_bounds().size;
|
||||
let Some(edge) =
|
||||
resize_edge(mouse, CLIENT_SIDE_DECORATION_SHADOW, size)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
window.set_cursor_style(
|
||||
match edge {
|
||||
ResizeEdge::Top | ResizeEdge::Bottom => {
|
||||
CursorStyle::ResizeUpDown
|
||||
}
|
||||
ResizeEdge::Left | ResizeEdge::Right => {
|
||||
CursorStyle::ResizeLeftRight
|
||||
}
|
||||
ResizeEdge::TopLeft | ResizeEdge::BottomRight => {
|
||||
CursorStyle::ResizeUpLeftDownRight
|
||||
}
|
||||
ResizeEdge::TopRight | ResizeEdge::BottomLeft => {
|
||||
CursorStyle::ResizeUpRightDownLeft
|
||||
}
|
||||
},
|
||||
&hitbox,
|
||||
);
|
||||
},
|
||||
)
|
||||
.size_full()
|
||||
.absolute(),
|
||||
)
|
||||
.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)
|
||||
})
|
||||
.when(!(tiling.bottom || tiling.right), |div| {
|
||||
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!(tiling.bottom || tiling.left), |div| {
|
||||
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!tiling.top, |div| div.pt(CLIENT_SIDE_DECORATION_SHADOW))
|
||||
.when(!tiling.bottom, |div| div.pb(CLIENT_SIDE_DECORATION_SHADOW))
|
||||
.when(!tiling.left, |div| div.pl(CLIENT_SIDE_DECORATION_SHADOW))
|
||||
.when(!tiling.right, |div| div.pr(CLIENT_SIDE_DECORATION_SHADOW))
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, _cx| {
|
||||
let size = window.window_bounds().get_bounds().size;
|
||||
let pos = window.mouse_position();
|
||||
|
||||
if let Some(edge) = resize_edge(pos, CLIENT_SIDE_DECORATION_SHADOW, size) {
|
||||
window.start_window_resize(edge)
|
||||
};
|
||||
}),
|
||||
})
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.map(|div| match decorations {
|
||||
Decorations::Server => div,
|
||||
Decorations::Client { tiling } => div
|
||||
.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)
|
||||
})
|
||||
.when(!(tiling.bottom || tiling.right), |div| {
|
||||
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!(tiling.bottom || tiling.left), |div| {
|
||||
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
|
||||
})
|
||||
.when(!tiling.top, |div| div.border_t(WINDOW_BORDER_WIDTH))
|
||||
.when(!tiling.bottom, |div| div.border_b(WINDOW_BORDER_WIDTH))
|
||||
.when(!tiling.left, |div| div.border_l(WINDOW_BORDER_WIDTH))
|
||||
.when(!tiling.right, |div| div.border_r(WINDOW_BORDER_WIDTH))
|
||||
.when(!tiling.is_tiled(), |div| {
|
||||
div.shadow(vec![gpui::BoxShadow {
|
||||
color: Hsla {
|
||||
h: 0.,
|
||||
s: 0.,
|
||||
l: 0.,
|
||||
a: 0.3,
|
||||
},
|
||||
blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2.,
|
||||
spread_radius: px(0.),
|
||||
offset: point(px(0.0), px(0.0)),
|
||||
}])
|
||||
}),
|
||||
})
|
||||
.on_mouse_move(|_e, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.bg(gpui::transparent_black())
|
||||
.size_full()
|
||||
.children(self.children),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn resize_edge(pos: Point<Pixels>, shadow_size: Pixels, size: Size<Pixels>) -> Option<ResizeEdge> {
|
||||
let edge = if pos.y < shadow_size && pos.x < shadow_size {
|
||||
ResizeEdge::TopLeft
|
||||
} else if pos.y < shadow_size && pos.x > size.width - shadow_size {
|
||||
ResizeEdge::TopRight
|
||||
} else if pos.y < shadow_size {
|
||||
ResizeEdge::Top
|
||||
} else if pos.y > size.height - shadow_size && pos.x < shadow_size {
|
||||
ResizeEdge::BottomLeft
|
||||
} else if pos.y > size.height - shadow_size && pos.x > size.width - shadow_size {
|
||||
ResizeEdge::BottomRight
|
||||
} else if pos.y > size.height - shadow_size {
|
||||
ResizeEdge::Bottom
|
||||
} else if pos.x < shadow_size {
|
||||
ResizeEdge::Left
|
||||
} else if pos.x > size.width - shadow_size {
|
||||
ResizeEdge::Right
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(edge)
|
||||
}
|
||||
120
crates/ui/src/window_ext.rs
Normal file
120
crates/ui/src/window_ext.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, Entity, SharedString, Window};
|
||||
|
||||
use crate::input::InputState;
|
||||
use crate::modal::Modal;
|
||||
use crate::notification::Notification;
|
||||
use crate::Root;
|
||||
|
||||
/// Extension trait for [`Window`] to add modal, notification .. functionality.
|
||||
pub trait WindowExtension: Sized {
|
||||
/// Opens a Modal.
|
||||
fn open_modal<F>(&mut self, cx: &mut App, builder: F)
|
||||
where
|
||||
F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static;
|
||||
|
||||
/// Return true, if there is an active Modal.
|
||||
fn has_active_modal(&mut self, cx: &mut App) -> bool;
|
||||
|
||||
/// Closes the last active Modal.
|
||||
fn close_modal(&mut self, cx: &mut App);
|
||||
|
||||
/// Closes all active Modals.
|
||||
fn close_all_modals(&mut self, cx: &mut App);
|
||||
|
||||
/// Returns number of notifications.
|
||||
fn notifications(&mut self, cx: &mut App) -> Rc<Vec<Entity<Notification>>>;
|
||||
|
||||
/// Pushes a notification to the notification list.
|
||||
fn push_notification<T>(&mut self, note: T, cx: &mut App)
|
||||
where
|
||||
T: Into<Notification>;
|
||||
|
||||
/// Clears a notification by its ID.
|
||||
fn clear_notification<T>(&mut self, id: T, cx: &mut App)
|
||||
where
|
||||
T: Into<SharedString>;
|
||||
|
||||
/// Clear all notifications
|
||||
fn clear_notifications(&mut self, cx: &mut App);
|
||||
|
||||
/// Return current focused Input entity.
|
||||
fn focused_input(&mut self, cx: &mut App) -> Option<Entity<InputState>>;
|
||||
|
||||
/// Returns true if there is a focused Input entity.
|
||||
fn has_focused_input(&mut self, cx: &mut App) -> bool;
|
||||
}
|
||||
|
||||
impl WindowExtension for Window {
|
||||
#[inline]
|
||||
fn open_modal<F>(&mut self, cx: &mut App, builder: F)
|
||||
where
|
||||
F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static,
|
||||
{
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.open_modal(builder, window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn has_active_modal(&mut self, cx: &mut App) -> bool {
|
||||
Root::read(self, cx).has_active_modals()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn close_modal(&mut self, cx: &mut App) {
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.close_modal(window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn close_all_modals(&mut self, cx: &mut App) {
|
||||
Root::update(self, cx, |root, window, cx| {
|
||||
root.close_all_modals(window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn push_notification<T>(&mut self, note: T, cx: &mut App)
|
||||
where
|
||||
T: Into<Notification>,
|
||||
{
|
||||
let note = note.into();
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.push_notification(note, window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clear_notification<T>(&mut self, id: T, cx: &mut App)
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let id = id.into();
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.clear_notification(id, window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clear_notifications(&mut self, cx: &mut App) {
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.clear_notifications(window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
fn notifications(&mut self, cx: &mut App) -> Rc<Vec<Entity<Notification>>> {
|
||||
let entity = Root::read(self, cx).notification.clone();
|
||||
Rc::new(entity.read(cx).notifications())
|
||||
}
|
||||
|
||||
fn has_focused_input(&mut self, cx: &mut App) -> bool {
|
||||
Root::read(self, cx).focused_input.is_some()
|
||||
}
|
||||
|
||||
fn focused_input(&mut self, cx: &mut App) -> Option<Entity<InputState>> {
|
||||
Root::read(self, cx).focused_input.clone()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user