10 Commits
Author SHA1 Message Date
reya c1757bbcae chore: fix web build 2026-11-04 13:05:03 +07:00
reya 74d548e5c6 nip4e: catch up psstpsst (#70)
Reviewed-on: #70
2026-11-04 03:12:52 +00:00
reya 690dc37fc5 chore: fix web build 2026-11-01 16:40:49 +07:00
reya 2f2661ecd2 chore: improve native theme usage 2026-11-01 16:15:28 +07:00
reya d045a7ef3a chore: update the unlock dialog 2026-10-30 20:17:37 +07:00
reya 23d7017f25 feat: add text to speech (#69)
Reviewed-on: #69
2026-10-30 07:16:46 +00:00
reya af2a276b1b chore: update deps 2026-10-28 10:08:47 +07:00
reya 69ca199a7d chore: drop macos x64 from release builds 2026-10-28 09:09:18 +07:00
reya 83a440d893 chore: release version 2.0.0-alpha3 2026-10-28 05:14:03 +07:00
reya 77b2a5f3cd feat: add support for speech to text (#68)
Reviewed-on: #68
2026-10-27 06:25:19 +00:00
50 changed files with 3906 additions and 799 deletions
-3
View File
@@ -20,9 +20,6 @@ jobs:
- platform: windows-arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- platform: macos-x64
os: macos-15-intel
target: x86_64-apple-darwin
- platform: macos-arm64
os: macos-latest
target: aarch64-apple-darwin
+3 -2
View File
@@ -39,8 +39,9 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-10-31
targets: wasm32-unknown-unknown
components: llvm-tools-preview
@@ -51,4 +52,4 @@ jobs:
env:
CC_wasm32_unknown_unknown: clang
AR_wasm32_unknown_unknown: llvm-ar
run: cargo +nightly check -p coop_web --target wasm32-unknown-unknown
run: cargo +nightly-2026-10-31 check -p coop_web --target wasm32-unknown-unknown
Generated
+1193 -237
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -4,27 +4,26 @@ members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "2.0.0-alpha2"
version = "2.0.0-alpha3"
edition = "2024"
publish = false
[workspace.dependencies]
gpui = { package = "gpui-pre", version = "0.3.8" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { package = "gpui-pre-linux", version = "0.3.8" }
gpui_windows = { package = "gpui-pre-windows", version = "0.3.8" }
gpui_macos = { package = "gpui-pre-macos", version = "0.3.8" }
gpui_web = { package = "gpui-pre-web", version = "0.3.8" }
gpui_util = { package = "gpui-pre-util", version = "0.3.8" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
gpui = { package = "gpui-pre", version = "0.3.10" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { package = "gpui-pre-linux", version = "0.3.10" }
gpui_windows = { package = "gpui-pre-windows", version = "0.3.10" }
gpui_macos = { package = "gpui-pre-macos", version = "0.3.10" }
gpui_web = { package = "gpui-pre-web", version = "0.3.10" }
gpui_util = { package = "gpui-pre-util", version = "0.3.10" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
gpui_tokio = { path = "crates/gpui_tokio" }
gpui-base = "0.7.1"
gpui-kit = "0.7.3"
# Native OS theme reading; the connector is written against gpui-component 0.7.x
# and gpui-pre 0.3.8, both satisfied by the versions above (0.7.3 / 0.3.9).
native-theme = { version = "0.6.1", features = ["watch"] }
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
gpui-base = "0.8.1"
gpui-kit = "0.8.1"
native-theme = { version = "0.7", features = ["native", "watch"] }
native-theme-gpui = { version = "0.7", default-features = false, features = ["system-fonts"] }
# Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
@@ -51,6 +50,7 @@ rand = { version = "0.10", default-features = false, features = [ "std", "sys_rn
# Others
anyhow = "1.0.44"
tts = { git = "https://github.com/ndarilek/tts-rs" }
chrono = { version = "0.4.38", features = ["wasmbind"] }
futures = "0.3"
itertools = "0.13.0"
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12.2126 10.7642L7.07919 15.8829C6.51216 16.4483 6.51216 17.365 7.07919 17.9304C7.64621 18.4958 8.56554 18.4958 9.13257 17.9304L19.3995 7.69303C19.9664 7.12769 20.25 6.38673 20.25 5.64575C20.25 4.90465 19.9665 4.16353 19.3995 3.59809C18.8324 3.03271 18.0893 2.75001 17.3461 2.75C16.6029 2.74999 15.8597 3.03269 15.2927 3.59809L5.02581 13.8354C4.17522 14.6836 3.74995 15.7952 3.75 16.9068C3.75005 18.0183 4.17532 19.1298 5.02581 19.9779C5.87635 20.826 6.99113 21.25 8.10591 21.25C9.22067 21.25 10.3354 20.8259 11.1859 19.9779L16.3194 14.8592" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 697 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M10.75 14.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.25 7.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.75 10.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M21.25 12.75V15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 5.25V3.75H7M11.25 5.25V3.75H7M7 3.75V12.25M7 12.25H5.25M7 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 726 B

-39
View File
@@ -1,39 +0,0 @@
use gpui::AssetSource;
#[test]
fn icons_resolve_from_the_vendored_folder() {
// Icons coop's code and the kit components draw, served by the vendored
// folder (signed's set, filled with the kit bundle where coop needs more).
let vendored = [
"icons/search.svg",
"icons/close.svg",
"icons/panel-left.svg",
"icons/window-minimize.svg",
"icons/settings-2.svg",
"icons/x.svg",
"icons/compass.svg",
"icons/radio-tower.svg",
];
for path in vendored {
let own = assets::Assets
.load(path)
.unwrap_or_else(|e| panic!("{path} errored in own embed: {e}"));
assert!(own.is_some(), "own embed is missing {path}");
let combined = assets::CombinedAssets.load(path).unwrap();
assert!(combined.is_some(), "CombinedAssets fails to serve {path}");
}
// Coop's own files still load through the combined source.
let own = [
"brand/coop.svg",
"brand/bg1.jpg",
"fonts/Inter/Inter-Medium.ttf",
];
for path in own {
assert!(
matches!(assets::CombinedAssets.load(path), Ok(Some(_))),
"CombinedAssets fails to serve coop asset {path}"
);
}
}
+3 -3
View File
@@ -37,9 +37,8 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
};
let arch = match (os, arch) {
// cargo-packager names the disk images `aarch64`/`x64`.
// cargo-packager names the disk images `aarch64`.
("macos", "aarch64") => "aarch64",
("macos", "x86_64") => "x64",
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
("linux", "aarch64") => "aarch64",
("linux", "x86_64") => "x86_64",
@@ -243,7 +242,7 @@ mod tests {
#[test]
fn picks_the_artifact_matching_os_and_architecture() {
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
assert_eq!(selected("macos", "x86_64"), None);
assert_eq!(
selected("linux", "aarch64"),
Some("coop-linux-aarch64.tar.gz")
@@ -258,6 +257,7 @@ mod tests {
fn has_no_target_for_windows_or_unknown_platforms() {
assert_eq!(asset_filter_for("windows", "x86_64"), None);
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
assert_eq!(asset_filter_for("macos", "x86_64"), None);
assert_eq!(asset_filter_for("macos", "riscv64"), None);
}
+24 -1
View File
@@ -947,7 +947,11 @@ async fn extract_rumor(
let Some(device_signer) = device_signer else {
return Err(error);
};
try_unwrap_with(device_signer, gift_wrap).await?
match try_unwrap_with(device_signer, gift_wrap).await {
Ok(unwrapped) => unwrapped,
// The wrap may be sealed to a key retired by a rotation
Err(_) => try_unwrap_archived(client, signer, gift_wrap).await?,
}
}
};
let mut rumor = unwrapped.rumor;
@@ -1003,6 +1007,25 @@ async fn try_unwrap_with(
})
}
/// Unwrap with an encryption key retired by a rotation; peers may still seal
/// to the previous key until their announcement cache refreshes
async fn try_unwrap_archived(
client: &Client,
signer: &UniversalSigner,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
for keys in DeviceRegistry::archived_keys(client, signer).await? {
let archived = UniversalSigner::new(keys);
if let Ok(unwrapped) = try_unwrap_with(&archived, gift_wrap).await {
return Ok(unwrapped);
}
}
Err(anyhow!(
"No archived encryption key could unwrap the gift wrap"
))
}
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let room_id = rumor.uniq_id().to_string();
+24
View File
@@ -16,6 +16,7 @@ use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them.";
const KEY_STALE: &str = "Your encryption key is out of date; sync it from another device in the settings to keep messaging.";
/// The outcome of delivering an encrypted rumor to a single receiver.
#[derive(Debug, Clone)]
@@ -553,6 +554,7 @@ impl Room {
let config = self.config.clone();
let device = DeviceRegistry::global(cx);
let resyncing = device.read(cx).is_resyncing();
let encryption_signer = device.read(cx).signer(cx);
let nostr = NostrRegistry::global(cx);
@@ -574,6 +576,28 @@ impl Room {
Some(cx.background_spawn(async move {
let signer_kind = config.signer_kind();
// Block sends while the local encryption key is stale against the
// current announcement; the replacement is being synced from
// another device
if resyncing && matches!(signer_kind, SignerKind::Auto | SignerKind::Encryption) {
let mut reports: Vec<SendReport> = receivers
.into_iter()
.map(|receiver| SendReport {
receiver: receiver.public_key(),
result: Err(KEY_STALE.into()),
})
.collect();
if config.backup() {
reports.push(SendReport {
receiver: sender.public_key(),
result: Err(KEY_STALE.into()),
});
}
return reports;
}
// Deliver to every receiver concurrently
let sends = receivers.into_iter().map(|receiver| {
deliver(
+3 -1
View File
@@ -6,14 +6,16 @@ publish.workspace = true
[dependencies]
state = { path = "../state" }
speech = { path = "../speech" }
ui = { path = "../ui" }
gpui-kit.workspace = true
gpui-kit = { workspace = true, features = ["speech"] }
common = { path = "../common" }
person = { path = "../person" }
chat = { path = "../chat" }
settings = { path = "../settings" }
gpui.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
+4 -1
View File
@@ -1,4 +1,4 @@
use gpui::Action;
use gpui::{Action, actions};
use nostr_sdk::prelude::*;
use serde::Deserialize;
use settings::SignerKind;
@@ -15,3 +15,6 @@ pub enum Command {
Njump(PublicKey),
Trace(EventId),
}
// Start or stop dictation in the focused composer
actions!(chat, [ToggleDictation]);
+209 -56
View File
@@ -1,5 +1,6 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, LazyLock, RwLock};
use std::time::Duration;
pub use actions::*;
use anyhow::Error;
@@ -20,6 +21,7 @@ use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, Textar
use gpui_kit::component::menu::DropdownMenu;
use gpui_kit::component::notification::Notification;
use gpui_kit::component::scroll::Scrollbar;
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
use gpui_kit::component::tooltip::Tooltip;
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
use itertools::Itertools;
@@ -28,13 +30,14 @@ use person::{Directory, Person};
use regex::Regex;
use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec};
use speech::speaker::{END_GRACE, Speaker};
use state::{
FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file,
};
use ui::{
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, WelcomeMessage, group_reactions,
opens_run, rendered_text,
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage,
group_reactions, opens_run, rendered_text,
};
use crate::file::*;
@@ -43,8 +46,7 @@ use crate::file::*;
const PRIVATE_NOTICE: &str =
"This conversation is private. Only members can see each other's messages.";
/// Regex matching strings that consist entirely of emoji characters,
/// zero-width joiners, variation selectors, and keycap combiners.
/// Regex matching strings that consist entirely of emoji characters.
static EMOJI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
@@ -63,60 +65,50 @@ pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity
pub struct ChatPanel {
id: SharedString,
focus_handle: FocusHandle,
/// Chat Room
room: WeakEntity<Room>,
/// Person directory shared with the room
directory: Entity<Directory>,
/// Message list state
list_state: ListState,
/// All messages (sorted by created_at)
messages: Vec<Message>,
/// O(1) message lookup by EventId
message_index: HashMap<EventId, usize>,
/// All reactions
reactions: BTreeMap<EventId, Vec<(SharedString, Option<SharedUri>, PublicKey)>>,
/// Mapping message ids to their rendered texts
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
/// Mapping message (rumor event) ids to their reports
reports_by_id: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
/// Chat input state
input: Entity<TextareaState>,
/// Dictation session for the chat input
speech: Entity<SpeechState>,
/// When the running dictation started, for the elapsed counter
speech_started: Option<instant::Instant>,
/// Reads messages aloud
speaker: Speaker,
/// The message being revealed in step with speech, with its estimated duration
speech_reveal: Option<(EventId, Duration)>,
/// Fallback reveal end for backends that cannot report utterance end
reveal_timeout: Option<Task<Result<(), Error>>>,
/// Subject input state
subject_input: Entity<InputState>,
/// Subject bar visibility
subject_bar: Entity<bool>,
/// Replies to
replies_to: Entity<HashSet<EventId>>,
/// Media Attachment
attachments: Entity<Vec<Url>>,
/// Uploaded, encrypted file attachments which are not sent yet
encrypted_attachments: Entity<Vec<PendingFile>>,
/// Decrypted attachments of file messages, by message id
decrypted_files: HashMap<EventId, DecryptedFile>,
/// Upload state
uploading: bool,
/// Async operations
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
subscriptions: SmallVec<[Subscription; 3]>,
subscriptions: SmallVec<[Subscription; 4]>,
}
impl ChatPanel {
@@ -160,6 +152,19 @@ impl ChatPanel {
.clean_on_escape()
});
// Define dictation state
let speech = cx.new(|cx| {
let state = SpeechState::new(cx)
.recognizer(speech::recognizer())
// Batch transcription of a long recording can take a while.
.stop_timeout(Duration::from_secs(180));
#[cfg(target_arch = "wasm32")]
let state = state.input(speech::input());
state
});
// Define subject input state
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
let subject_bar = cx.new(|_cx| false);
@@ -178,15 +183,39 @@ impl ChatPanel {
subscriptions.push(
// Subscribe the subject input event
cx.subscribe_in(
&subject_input,
window,
move |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.change_subject(window, cx);
};
},
),
cx.subscribe_in(&subject_input, window, move |this, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.change_subject(window, cx);
};
}),
);
subscriptions.push(
// Insert the dictated text into the chat input
cx.subscribe_in(&speech, window, move |this, _speech, event, window, cx| {
match event {
SpeechEvent::Started => {
this.speech_started = Some(instant::Instant::now());
cx.notify();
}
SpeechEvent::Final(text) => {
this.speech_started = None;
if !text.is_empty() {
this.input
.update(cx, |input, cx| input.insert(text.clone(), window, cx));
}
}
SpeechEvent::Error(error) => {
this.speech_started = None;
window.push_notification(Notification::error(error.to_string()), cx);
}
SpeechEvent::Cancelled => {
this.speech_started = None;
}
_ => {}
};
}),
);
// Define all functions that will run after the current cycle
@@ -196,6 +225,20 @@ impl ChatPanel {
this.get_messages(window, cx);
});
// End the speech reveal when the utterance finishes ahead of its estimate
let speaker = Speaker::new();
let end_events = speaker.end_events();
let reveal_end_task = cx.spawn(async move |this, cx| {
while end_events.recv_async().await.is_ok() {
this.update(cx, |this, cx| {
this.speech_reveal = None;
cx.notify();
})?;
}
Ok(())
});
Self {
focus_handle: cx.focus_handle(),
id,
@@ -206,6 +249,11 @@ impl ChatPanel {
directory,
list_state,
input,
speech,
speech_started: None,
speaker,
speech_reveal: None,
reveal_timeout: None,
subject_input,
subject_bar,
replies_to,
@@ -216,7 +264,7 @@ impl ChatPanel {
reports_by_id,
uploading: false,
subscriptions,
tasks: vec![],
tasks: vec![reveal_end_task],
}
}
@@ -835,6 +883,17 @@ impl ChatPanel {
self.directory.read(cx).person(public_key, cx)
}
fn toggle_dictation(
&mut self,
_: &ToggleDictation,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.speech.update(cx, |speech, cx| {
speech.toggle(cx);
});
}
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::Insert(content) => {
@@ -1134,6 +1193,15 @@ impl ChatPanel {
nostr.read(cx).current_user(),
));
let body = match self
.speech_reveal
.as_ref()
.filter(|(reveal_id, _)| *reveal_id == id)
{
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
None => rendered_text,
};
MessageRow::new(ix)
.show_author(show_author)
.hide_avatar(hide_avatar)
@@ -1158,7 +1226,7 @@ impl ChatPanel {
.when(has_replies, |this| {
this.children(self.render_message_replies(replies, cx))
})
.when(message.file.is_none(), |this| this.child(rendered_text))
.when(message.file.is_none(), |this| this.child(body))
.child(MessageMedia::new(&message.media))
.when_some(message.file.as_ref(), |this, file| {
this.child(self.render_message_file(&id, file, cx))
@@ -1390,21 +1458,63 @@ impl ChatPanel {
}
let reply_id = *id;
let copy_id = *id;
let dropdown_id = *id;
let public_key = *public_key;
actions
.reply(cx.listener(move |this, _event, _window, cx| {
this.reply_to(&reply_id, cx);
}))
.copy(cx.listener(move |this, _event, _window, cx| {
this.copy_message(&copy_id, cx);
}))
.dropdown(move |menu, _window, _cx| {
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
.menu("Seen on", Box::new(Command::Trace(dropdown_id)))
})
let actions = actions.reply(cx.listener(move |this, _event, _window, cx| {
this.reply_to(&reply_id, cx);
}));
let actions = if let Some(message) = self.message(id) {
actions.copy(
SharedString::from(format!("message-copy-{id}")),
message.preview(),
)
} else {
actions
};
let speaking = self.speech_reveal.is_some();
let speech_id = *id;
let actions = actions.speech(
speaking,
cx.listener(move |this, _event, window, cx| {
let text = this.message(&speech_id).map(|message| message.preview());
if let Some(text) = text {
match this.speaker.speak(&text) {
Ok(duration) => {
this.speech_reveal = Some((speech_id, duration));
// Re-enable speaking when no end event ever arrives
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
this.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
}
}),
);
actions.dropdown(move |menu, _window, _cx| {
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
.menu("Seen on", Box::new(Command::Trace(dropdown_id)))
})
}
fn render_attachment(&self, url: &Url, cx: &Context<Self>) -> impl IntoElement {
@@ -1669,7 +1779,12 @@ impl ChatPanel {
items
}
fn render_config_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
fn render_config_menu(
&self,
disabled: bool,
_window: &mut Window,
cx: &Context<Self>,
) -> impl IntoElement {
let (backup, signer_kind) = self
.room
.read_with(cx, |this, _cx| {
@@ -1682,6 +1797,7 @@ impl ChatPanel {
.icon(IconName::Settings2)
.tooltip("Configuration")
.ghost()
.disabled(disabled)
.dropdown_menu(move |this, _window, _cx| {
let auto = matches!(signer_kind, SignerKind::Auto);
let encryption = matches!(signer_kind, SignerKind::Encryption);
@@ -1712,7 +1828,12 @@ impl ChatPanel {
})
}
fn render_emoji_menu(&self, _window: &Window, cx: &Context<Self>) -> impl IntoElement {
fn render_emoji_menu(
&self,
disabled: bool,
_window: &Window,
cx: &Context<Self>,
) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
@@ -1729,6 +1850,7 @@ impl ChatPanel {
Button::new("emoji")
.icon(IconName::Emoji)
.ghost()
.disabled(disabled)
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
@@ -1802,10 +1924,12 @@ impl Render for ChatPanel {
let is_typing = !self.input.read(cx).value().trim().is_empty();
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
let dictating = self.speech.read(cx).status().is_active();
v_flex()
.image_cache(retain_all(self.id.clone()))
.on_action(cx.listener(Self::on_command))
.on_action(cx.listener(Self::toggle_dictation))
.size_full()
.when(*self.subject_bar.read(cx), |this| {
this.child(
@@ -1879,26 +2003,55 @@ impl Render for ChatPanel {
.items_end()
.child(
Button::new("upload")
.icon(IconName::Plus)
.icon(IconName::Paperclip)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(self.uploading)
.disabled(dictating || self.uploading)
.ghost()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.upload(window, cx);
})),
)
.child(Textarea::new(&self.input).appearance(false).flex_1())
.when(dictating, |this| {
this.child(
h_flex()
.h_8()
.flex_1()
.gap_2()
.pl_1()
.child(SpeechWaveform::new(&self.speech).small().flex_1())
.when_some(self.speech_started, |this, started| {
this.child(speech::elapsed(started, cx))
}),
)
})
.when(!dictating, |this| {
this.child(
Textarea::new(&self.input)
.appearance(false)
.small()
.min_h_8()
.py_1()
.flex_1(),
)
})
.child(
h_flex()
.items_end()
.gap_1()
.pl_1()
.child(SpeechButton::new(&self.speech)),
)
.child(
h_flex()
.pl_1()
.gap_1()
.child(self.render_emoji_menu(window, cx))
.child(self.render_config_menu(window, cx))
.child(self.render_emoji_menu(dictating, window, cx))
.child(self.render_config_menu(dictating, window, cx))
.child(
Button::new("send")
.icon(IconName::PaperPlaneFill)
.disabled(self.uploading)
.disabled(dictating || self.uploading)
.ghost()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.send_text_message(window, cx);
+3 -1
View File
@@ -11,11 +11,13 @@ common = { path = "../common" }
nip29 = { path = "../nip29" }
person = { path = "../person" }
settings = { path = "../settings" }
speech = { path = "../speech" }
state = { path = "../state" }
gpui-kit.workspace = true
gpui-kit = { workspace = true, features = ["speech"] }
ui = { path = "../ui" }
gpui.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
log.workspace = true
+149 -33
View File
@@ -1,11 +1,14 @@
use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::Result;
use chat_ui::ToggleDictation;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render,
SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
FollowMode, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState,
ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list,
px,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
@@ -15,6 +18,7 @@ use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{InputEvent, Textarea, TextareaState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::scroll::Scrollbar;
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
use nostr_sdk::prelude::*;
@@ -22,8 +26,9 @@ use person::Directory;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use speech::speaker::Speaker;
use state::{NostrRegistry, pick_file, upload_file};
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
use ui::{Avatar, RenderedText, RevealText, WelcomeMessage, rendered_text};
use util::{attachment_name, display_name, opens_run, pin_label, report};
mod details;
@@ -312,6 +317,16 @@ pub struct GroupPanel {
reply_to: Option<EventId>,
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
input: Entity<TextareaState>,
/// Dictation session for the composer input
speech: Entity<SpeechState>,
/// When the running dictation started, for the elapsed counter
speech_started: Option<instant::Instant>,
/// Reads messages aloud
speaker: Speaker,
/// The message being revealed in step with speech, with its estimated duration
speech_reveal: Option<(EventId, Duration)>,
/// Fallback reveal end for backends that cannot report utterance end
reveal_timeout: Option<Task<Result<()>>>,
/// Uploaded, non-encrypted attachments waiting to be sent.
attachments: Vec<Url>,
/// Whether an upload is in flight.
@@ -319,7 +334,7 @@ pub struct GroupPanel {
/// Whether the user closed the encryption notice this visit
notice_dismissed: bool,
tasks: SmallVec<[Task<Result<()>>; 4]>,
_subscriptions: SmallVec<[Subscription; 3]>,
_subscriptions: SmallVec<[Subscription; 4]>,
}
impl GroupPanel {
@@ -338,6 +353,18 @@ impl GroupPanel {
.clean_on_escape()
});
let speech = cx.new(|cx| {
let state = SpeechState::new(cx)
.recognizer(speech::recognizer())
// Batch transcription of a long recording can take a while.
.stop_timeout(Duration::from_secs(180));
#[cfg(target_arch = "wasm32")]
let state = state.input(speech::input());
state
});
let mut subscriptions = smallvec![];
subscriptions.push(
@@ -348,6 +375,34 @@ impl GroupPanel {
}),
);
subscriptions.push(
cx.subscribe_in(&speech, window, |this, _, event, window, cx| {
match event {
SpeechEvent::Started => {
this.speech_started = Some(instant::Instant::now());
cx.notify();
}
SpeechEvent::Final(text) => {
this.speech_started = None;
if !text.is_empty() {
this.input.update(cx, |input, cx| {
input.insert(text.clone(), window, cx);
});
}
}
SpeechEvent::Error(error) => {
this.speech_started = None;
window.push_notification(Notification::error(error.to_string()), cx);
}
SpeechEvent::Cancelled => {
this.speech_started = None;
}
_ => {}
};
}),
);
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
if !matches!(event, GroupEvent::Sent(_)) {
this.sync(cx);
@@ -371,9 +426,24 @@ impl GroupPanel {
}
},
));
this.track_participants(cx);
});
let mut this = Self {
// End the speech reveal when the utterance finishes ahead of its estimate
let speaker = Speaker::new();
let end_events = speaker.end_events();
let reveal_end_task = cx.spawn(async move |this, cx| {
while end_events.recv_async().await.is_ok() {
this.update(cx, |this, cx| {
this.speech_reveal = None;
cx.notify();
})?;
}
Ok(())
});
Self {
focus_handle: cx.focus_handle(),
group: group.downgrade(),
directory: Directory::new(cx),
@@ -383,19 +453,19 @@ impl GroupPanel {
notice_dismissed: false,
rendered_texts_by_id: BTreeMap::new(),
input,
speech,
speech_started: None,
speaker,
speech_reveal: None,
reveal_timeout: None,
attachments: Vec::new(),
uploading: false,
tasks: smallvec![],
tasks: smallvec![reveal_end_task],
_subscriptions: subscriptions,
};
this.track_participants(cx);
this
}
}
/// Track the keys rendered by this panel: members, message authors, and
/// activity subjects
/// Track the keys rendered by this panel.
fn track_participants(&mut self, cx: &mut Context<Self>) {
let Some(group) = self.group.upgrade() else {
return;
@@ -973,22 +1043,29 @@ impl GroupPanel {
let nostr = NostrRegistry::global(cx);
let directory = self.directory.clone();
let content = self
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
rendered_text(
&message.content,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
)
})
.element(ix.into(), window, cx);
let content = match self
.speech_reveal
.as_ref()
.filter(|(reveal_id, _)| *reveal_id == message.id)
{
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
None => self
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
rendered_text(
&message.content,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
)
})
.element(ix.into(), window, cx),
};
let admin = nostr
.read(cx)
@@ -1009,12 +1086,24 @@ impl GroupPanel {
panel: cx.weak_entity(),
admin,
pinned,
speaking: self.speech_reveal.is_some(),
persons: self.directory.clone(),
},
cx,
)
}
fn toggle_dictation(
&mut self,
_ev: &ToggleDictation,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.speech.update(cx, |speech, cx| {
speech.toggle(cx);
});
}
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
let notice = self.group.upgrade().and_then(|group| {
let group = group.read(cx);
@@ -1040,6 +1129,8 @@ impl GroupPanel {
return self.render_notice(&notice, cx);
}
let dictating = self.speech.read(cx).status().is_active();
v_flex()
.flex_shrink_0()
.w_full()
@@ -1055,21 +1146,45 @@ impl GroupPanel {
.items_end()
.child(
Button::new("upload")
.icon(IconName::Upload)
.tooltip("Upload file")
.icon(ui::IconName::Paperclip)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(self.uploading)
.disabled(dictating || self.uploading)
.ghost()
.on_click(cx.listener(|this, _event, window, cx| {
this.upload(window, cx);
})),
)
.child(Textarea::new(&self.input).appearance(false).flex_1())
.when(dictating, |this| {
this.child(
h_flex()
.h_8()
.flex_1()
.gap_2()
.child(SpeechWaveform::new(&self.speech).small().flex_1())
.when_some(self.speech_started, |this, started| {
this.child(speech::elapsed(started, cx))
}),
)
})
.when(!dictating, |this| {
this.child(
Textarea::new(&self.input)
.appearance(false)
.bg(cx.theme().input)
.small()
.min_h_8()
.py_1()
.flex_1(),
)
})
.child(SpeechButton::new(&self.speech))
.child(
Button::new("send")
.icon(ui::IconName::PaperPlaneFill)
.tooltip("Send")
.ghost()
.disabled(dictating || self.uploading)
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
})),
@@ -1267,6 +1382,7 @@ impl Render for GroupPanel {
v_flex()
.size_full()
.min_w_0()
.on_action(cx.listener(Self::toggle_dictation))
.child(self.render_encryption_notice(cx))
.child(self.render_banner(cx))
.child(self.render_pins(cx))
+44 -6
View File
@@ -8,15 +8,17 @@ use gpui::{
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex};
use nip29::{Activity, Group, Reaction};
use nostr_sdk::prelude::{EventId, Kind};
use person::Directory;
use settings::AppSettings;
use speech::speaker::END_GRACE;
use state::NostrRegistry;
use ui::{
Avatar, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, group_reactions,
react_menu,
Avatar, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview,
group_reactions, react_menu,
};
use crate::GroupPanel;
@@ -26,6 +28,8 @@ pub(crate) struct Actions {
pub panel: WeakEntity<GroupPanel>,
pub admin: bool,
pub pinned: bool,
/// Whether a message is currently being spoken, disabling the speech buttons
pub speaking: bool,
pub persons: Entity<Directory>,
}
@@ -87,10 +91,44 @@ pub(crate) fn render(
let admin = actions.admin;
let pinned = actions.pinned;
let group = actions.group;
let panel = actions.panel;
let speech_panel = actions.panel;
let panel = speech_panel.clone();
let speech_text = message.preview();
let speech_id = message.id;
let speaking = actions.speaking;
let row = row.overlay(
MessageActions::new().speech(speaking, move |_event, _window, cx| {
_ = speech_panel.update_in(cx, |panel, window, cx| {
match panel.speaker.speak(&speech_text) {
Ok(duration) => {
panel.speech_reveal = Some((speech_id, duration));
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
panel.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
});
}),
);
// Reading the panel here would re-enter its ongoing update, so resolve
// the current user's custom emojis through the shared person directory.
let custom = nostr
.read(cx)
.current_user()
+488 -165
View File
@@ -1,9 +1,10 @@
use std::cell::Cell;
use std::collections::HashSet;
use std::collections::{HashSet, VecDeque};
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
@@ -16,12 +17,14 @@ use gpui_kit::component::notification::{Notification, NotificationType};
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::Directory;
use settings::AppSettings;
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS};
use smallvec::{SmallVec, smallvec};
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
use ui::Avatar;
const IDENTIFIER: &str = "coop:device";
mod storage;
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
pub fn init(cx: &mut App) {
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
@@ -59,10 +62,18 @@ impl DeviceEvent {
#[derive(Debug)]
pub struct DeviceRegistry {
signer: Entity<Option<UniversalSigner>>,
/// Whether there is a pending request for encryption key approval
pub(crate) pending_request: bool,
/// Ephemeral client key for the in-flight key request; never persisted
app_keys: Option<Keys>,
/// The key request currently awaiting user approval on this device
pending_request: Option<PendingRequest>,
/// Handled key-sync request ids, bounded and persisted
processed_requests: VecDeque<EventId>,
/// Whether an announcement has been made for this device
pub(crate) announcement_existed: Arc<AtomicBool>,
/// Whether the local key is stale against the current announcement.
resync: Arc<AtomicBool>,
/// Whether a key rotation is in flight, to coalesce overlapping requests
rotating: bool,
/// Person directory for requesters shown in notifications
directory: Entity<Directory>,
/// Relay notification listener
@@ -108,6 +119,21 @@ impl DeviceRegistry {
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
// The old account's state must not leak into the new one
this.announcement_existed.store(false, Ordering::Relaxed);
this.resync.store(false, Ordering::Relaxed);
// The in-flight request and client key belong to the old account
this.app_keys = None;
if this.pending_request.take().is_some() {
// The old approval must not target the new account's signer
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
}
this.handle_notifications(cx);
this.get_announcement(cx);
}
@@ -115,15 +141,19 @@ impl DeviceRegistry {
);
cx.defer(move |cx| {
entity
.update(cx, |this, cx| this.handle_notifications(cx))
.ok();
let _ = entity.update(cx, |this, cx| {
this.handle_notifications(cx);
});
});
Self {
signer,
pending_request: false,
app_keys: None,
pending_request: None,
processed_requests: VecDeque::new(),
announcement_existed: Arc::new(AtomicBool::new(false)),
resync: Arc::new(AtomicBool::new(false)),
rotating: false,
directory: Directory::new(cx),
notification_listener: None,
tasks: vec![],
@@ -141,6 +171,8 @@ impl DeviceRegistry {
let announcement_existed = self.announcement_existed.clone();
let (tx, rx) = flume::bounded::<Event>(100);
let read = credentials::read_processed_requests(cx);
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
@@ -175,6 +207,21 @@ impl DeviceRegistry {
}));
self.tasks.push(cx.spawn(async move |this, cx| {
// Hydrate the processed-request log before routing any events
match read.await {
Ok(bytes) => {
let processed = bytes
.map(|bytes| parse_processed_requests(&bytes))
.unwrap_or_default();
this.update(cx, |this, _cx| {
this.processed_requests = processed;
})
.ok();
}
Err(e) => log::error!("Failed to read processed key-sync requests: {e}"),
}
while let Ok(event) = rx.recv_async().await {
match event.kind {
Kind::Custom(10044) => {
@@ -191,7 +238,7 @@ impl DeviceRegistry {
// New response event from the master device
Kind::Custom(4455) => {
this.update(cx, |this, cx| {
this.extract_encryption(event, cx);
this.handle_transfer(event, cx);
})?;
}
_ => {}
@@ -201,12 +248,62 @@ impl DeviceRegistry {
}));
}
/// Set whether there is a pending request for encryption key approval
fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
self.pending_request = pending;
/// Record a handled key-sync request id, persisting a bounded log
fn mark_request_processed(&mut self, event_id: EventId, cx: &mut Context<Self>) {
if self.processed_requests.contains(&event_id) {
return;
}
self.processed_requests.push_back(event_id);
while self.processed_requests.len() > PROCESSED_REQUESTS_LIMIT {
self.processed_requests.pop_front();
}
let bytes: Vec<u8> = self
.processed_requests
.iter()
.flat_map(|id| id.to_hex().into_bytes())
.collect();
let write = credentials::write_processed_requests(cx, &bytes);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Failed to persist processed key-sync requests: {e}");
}
})
.detach();
}
/// Withdraw the pending approval.
fn resolve_pending_request(&mut self, cx: &mut Context<Self>) {
let Some(pending) = self.pending_request.take() else {
return;
};
self.mark_request_processed(pending.event.id, cx);
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
cx.notify();
}
/// Route a key transfer.
fn handle_transfer(&mut self, event: Event, cx: &mut Context<Self>) {
let resolved = self
.pending_request
.as_ref()
.is_some_and(|pending| transfer_addresses(&event, pending.client_key));
if resolved {
self.resolve_pending_request(cx);
return;
}
self.extract_encryption(event, cx);
}
/// Get the signer
pub fn signer(&self, cx: &App) -> Option<UniversalSigner> {
self.signer.read(cx).clone()
@@ -218,9 +315,16 @@ impl DeviceRegistry {
*this = Some(UniversalSigner::new(new_signer));
cx.notify();
});
// The local key is now consistent with the announcement
self.resync.store(false, Ordering::Release);
cx.emit(DeviceEvent::Set);
}
/// Whether the local encryption key is stale against the current announcement.
pub fn is_resyncing(&self) -> bool {
self.resync.load(Ordering::Acquire)
}
/// Backup the encryption's secret key, returning the `nsec1...` content
pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> {
let nostr = NostrRegistry::global(cx);
@@ -323,6 +427,88 @@ impl DeviceRegistry {
}));
}
/// Replace the encryption key with a new one, archiving the old key.
pub fn rotate(&mut self, cx: &mut Context<Self>) {
if self.rotating {
return;
}
self.rotating = true;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let result: Result<Keys, Error> = async {
// Archive the current key before replacing it
if let Ok(old) = get_keys(&client, &signer).await {
archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?;
}
let created = this
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
.await?;
this.update(cx, |this, cx| {
this.set_signer(created.clone(), cx);
this.wait_for_request(cx);
})?;
Ok(created)
}
.await;
this.update(cx, |this, cx| {
this.rotating = false;
if let Err(e) = result {
cx.emit(DeviceEvent::error(e.to_string()));
}
})
.ok();
Ok(())
}));
}
/// Rotate the encryption key when the configured interval has elapsed.
///
/// Runs after the announcement reconciled, outside the blocking boot path.
pub fn rotate_if_due(&mut self, cx: &mut Context<Self>) {
let Some(interval_days) = AppSettings::get_nip4e_rotation_days(cx) else {
return;
};
let interval_days = interval_days.clamp(NIP4E_ROTATION_MIN_DAYS, NIP4E_ROTATION_MAX_DAYS);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let Ok(keys) = load_device_keys(&client, &signer).await else {
return Ok(());
};
let elapsed = Timestamp::now()
.as_secs()
.saturating_sub(keys.created_at.as_secs());
if elapsed < u64::from(interval_days) * SECONDS_PER_DAY {
return Ok(());
}
this.update(cx, |this, cx| this.rotate(cx)).ok();
Ok(())
}));
}
/// Load encryption keys retired by a rotation from the local database.
pub async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
storage::archived_keys(client, signer).await
}
/// Create new encryption key and announce it to user's relay list
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
let nostr = NostrRegistry::global(cx);
@@ -331,6 +517,7 @@ impl DeviceRegistry {
let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key();
let created_at = Timestamp::now();
cx.background_spawn(async move {
// Construct an announcement event
@@ -340,7 +527,7 @@ impl DeviceRegistry {
.await?;
// Persist the key locally before announcing it
set_keys(&client, &signer, &secret).await?;
set_keys(&client, &signer, &secret, created_at).await?;
// Publish announcement
client
@@ -383,10 +570,12 @@ impl DeviceRegistry {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.wait_for_request(cx);
this.rotate_if_due(cx);
})?;
} else {
this.update(cx, |this, cx| {
this.request(cx);
this.resync.store(true, Ordering::Release);
this.request(Some(device_pubkey), cx);
})?;
}
Ok(())
@@ -414,15 +603,17 @@ impl DeviceRegistry {
}
/// Request encryption keys from other device
pub(crate) fn request(&mut self, cx: &mut Context<Self>) {
pub(crate) fn request(
&mut self,
expected_encryption: Option<PublicKey>,
cx: &mut Context<Self>,
) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let app_keys_task = get_or_init_app_keys(cx);
let app_keys = self.app_keys.get_or_insert_with(Keys::generate).clone();
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let app_pubkey = app_keys.public_key();
let public_key = signer.get_public_key_async().await?;
@@ -434,8 +625,7 @@ impl DeviceRegistry {
.limit(1);
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
// Only reuse the stored approval if its key is still valid,
// otherwise it's stale and a fresh request is needed
// Only reuse the stored approval if its key is still valid
if decrypt_approval(&client, &app_keys, public_key, &event)
.await
.is_ok()
@@ -444,9 +634,26 @@ impl DeviceRegistry {
}
}
// No usable approval event found, construct a request event
// No usable approval event found, construct a request event.
//
// The `pubkey` tag is a compatibility alias for the draft's `P`,
// `relay` hints tell other devices where the response is accepted,
// `n` names the exact key this device expects to be transferred
let mut tags = vec![
Tag::custom("P", vec![app_pubkey]),
Tag::custom("pubkey", vec![app_pubkey]),
];
for url in write_relay_hints(&client, public_key).await? {
tags.push(Tag::custom("relay", vec![url.to_string()]));
}
if let Some(expected) = expected_encryption {
tags.push(Tag::custom("n", vec![expected]));
}
let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(vec![Tag::custom("P", vec![app_pubkey])])
.tags(tags)
.finalize_async(&signer)
.await?;
@@ -483,19 +690,13 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let app_keys_task = get_or_init_app_keys(cx);
let Some(app_keys) = self.app_keys.clone() else {
log::warn!("Cannot wait for approval without a client key");
return;
};
self.tasks.push(cx.spawn(async move |this, cx| {
let app_keys = match app_keys_task.await {
Ok(app_keys) => app_keys,
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
return Ok(());
}
};
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::Requesting(authorization_code(
&app_keys.public_key(),
@@ -505,7 +706,7 @@ impl DeviceRegistry {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
// Only approvals addressed to this device's app key matter
// Only approvals addressed to this device's client key matter
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
@@ -529,15 +730,18 @@ impl DeviceRegistry {
/// Parse the approval event to get encryption key then set it
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
let app_keys_task = get_or_init_app_keys(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let Some(app_keys) = self.app_keys.clone() else {
log::warn!("Ignoring key transfer addressed to another device");
return;
};
let approval_id = event.id;
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let identity = signer.get_public_key_async().await?;
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
@@ -549,14 +753,14 @@ impl DeviceRegistry {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
// The client key was consumed; start fresh on the next request
this.app_keys = None;
// Clean up our request and the approval event from relays
this.cleanup_sync_events(approval_id, cx);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
log::warn!("Ignoring key transfer: {e}");
}
}
Ok(())
@@ -610,40 +814,58 @@ impl DeviceRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Get user's write relays
let event = event.clone();
let request_id = event.id;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Get device keys
let keys = get_keys(&client, &signer).await?;
let secret = keys.secret_key().to_secret_hex();
let identity = signer.get_public_key_async().await?;
// Extract the target public key from the event tags
let target = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Target is not a valid public key")?;
let target =
client_pubkey_from_event(&event).context("Target is not a valid public key")?;
// Encrypt the device keys with the user's signer
let payload = keys.nip44_encrypt_async(&target, &secret).await?;
// The requester may pin the exact key it expects
if let Some(requested) = requested_encryption_key(&event)
&& requested != keys.public_key()
{
return Err(anyhow!(
"The requesting device expects an encryption key this device no longer holds"
));
}
// Encrypt to a fresh one-time keypair so the transfer event
// can never be decrypted with the long-lived encryption key.
let sender = Keys::generate();
let payload = sender.nip44_encrypt_async(&target, &secret).await?;
// Construct the response event
//
// P tag: the current device's public key
// p tag: the requester's public key
let event = EventBuilder::new(Kind::Custom(4455), payload)
// P tag: the one-time sender key used for the encryption
// p tag: the account identity, for inbox routing
// p tag: the requester's client key
let response = EventBuilder::new(Kind::Custom(4455), payload)
.tags(vec![
Tag::custom("P", vec![keys.public_key().to_hex()]),
Tag::custom("P", vec![sender.public_key().to_hex()]),
Tag::public_key(identity),
Tag::public_key(target),
])
.finalize_async(&signer)
.await?;
// Send the response event to the user's relay list
client.send_event(&event).to_nip65().await?;
let relays = merge_relays(
write_relay_hints(&client, identity).await?,
request_relay_hints(&event),
);
let send = client.send_event(&response);
if relays.is_empty() {
send.to_nip65().await?;
} else {
send.to(relays).await?;
}
Ok(())
});
@@ -652,8 +874,11 @@ impl DeviceRegistry {
match task.await {
Ok(_) => {
this.update(cx, |this, cx| {
// Record the request as handled so it cannot resurface
this.mark_request_processed(request_id, cx);
// Allow future requests to surface again
this.set_pending_request(false, cx);
this.pending_request = None;
cx.notify();
})?;
cx.update(|window, cx| {
@@ -678,21 +903,70 @@ impl DeviceRegistry {
/// Handle encryption request
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
// Ignore if there is already a pending request
if self.pending_request {
let Some(client_key) = client_pubkey_from_event(&event) else {
log::warn!("Ignoring invalid encryption key request: no valid requester key");
return;
}
self.set_pending_request(true, cx);
};
let notification = match self.notification(event, cx) {
let read = credentials::read_processed_requests(cx);
cx.spawn(async move |this, cx| {
let processed = match read.await {
Ok(bytes) => bytes
.map(|bytes| parse_processed_requests(&bytes).contains(&event.id))
.unwrap_or(false),
Err(e) => {
log::error!("Failed to read processed key-sync requests: {e}");
false
}
};
this.update(cx, |this, cx| {
if processed || this.processed_requests.contains(&event.id) {
return;
}
match this.pending_request.take() {
// A retry signs a new request id but keeps the same client key
Some(mut pending) if pending.client_key == client_key => {
this.mark_request_processed(event.id, cx);
pending.event = event;
this.pending_request = Some(pending);
}
// A different client key is a remounted requester
other => {
if let Some(pending) = other {
this.mark_request_processed(pending.event.id, cx);
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
}
this.show_approval(event, client_key, cx);
}
}
})
.ok();
})
.detach();
}
/// Build and surface the approval notification for a key request
fn show_approval(&mut self, event: Event, client_key: PublicKey, cx: &mut Context<Self>) {
let notification = match self.notification(event.clone(), cx) {
Ok(notification) => notification,
Err(e) => {
log::warn!("Ignoring invalid encryption key request: {e}");
self.set_pending_request(false, cx);
return;
}
};
self.pending_request = Some(PendingRequest { event, client_key });
cx.notify();
if let Some(window) = cx.windows().first().copied() {
if let Err(error) = window.update(cx, |_view, window, cx| {
window.push_notification(notification, cx);
@@ -715,19 +989,17 @@ impl DeviceRegistry {
compare the code with the one shown on the requesting \
device.";
let requester = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Request has no valid requester key")?;
let requester =
client_pubkey_from_event(&event).context("Request has no valid requester key")?;
let code = authorization_code(&requester);
let directory = self.directory.clone();
directory.update(cx, |directory, cx| directory.track([requester], cx));
let code_label = code.clone();
let directory = self.directory.clone();
directory.update(cx, |directory, cx| {
directory.track([requester], cx);
});
let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false));
@@ -831,6 +1103,111 @@ impl DeviceRegistry {
struct DeviceNotification;
/// A key request awaiting user approval on this device
#[derive(Debug)]
struct PendingRequest {
event: Event,
client_key: PublicKey,
}
/// Keep the durable processed-request log bounded
const PROCESSED_REQUESTS_LIMIT: usize = 256;
/// Parse concatenated hex event ids
fn parse_processed_requests(bytes: &[u8]) -> VecDeque<EventId> {
let Ok(hex) = std::str::from_utf8(bytes) else {
return VecDeque::new();
};
hex.as_bytes()
.as_chunks::<64>()
.0
.iter()
.filter_map(|chunk| EventId::from_hex(std::str::from_utf8(chunk).ok()?).ok())
.collect()
}
/// Extract the client public key from an event.
fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> {
let key = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok());
key.or_else(|| {
event
.tags
.iter()
.find(|tag| tag.kind() == "pubkey")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
})
}
/// Whether the transfer event is p-tagged to the given client key
fn transfer_addresses(event: &Event, client_key: PublicKey) -> bool {
event.tags.iter().any(|tag| {
tag.kind() == "p"
&& tag
.content()
.and_then(|content| PublicKey::parse(content).ok())
.is_some_and(|key| key == client_key)
})
}
/// Parse the `relay` hints attached to a key request.
fn request_relay_hints(event: &Event) -> Vec<RelayUrl> {
event
.tags
.iter()
.filter(|tag| tag.kind() == "relay")
.filter_map(|tag| {
tag.content()
.and_then(|content| RelayUrl::parse(content).ok())
})
.collect()
}
/// Parse the optional `n` tag of a key request.
fn requested_encryption_key(event: &Event) -> Option<PublicKey> {
event
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
}
/// Collect the user's NIP-65 write relays.
async fn write_relay_hints(client: &Client, identity: PublicKey) -> Result<Vec<RelayUrl>, Error> {
let filter = Filter::new()
.kind(Kind::RelayList)
.author(identity)
.limit(1);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(vec![]);
};
// Unmarked relays serve both reads and writes
Ok(nip65::extract_relay_list(&event)
.filter(|(_, metadata)| *metadata != Some(RelayMetadata::Read))
.map(|(url, _)| url)
.collect())
}
/// Merge relay lists, dropping duplicates
fn merge_relays(first: Vec<RelayUrl>, second: Vec<RelayUrl>) -> Vec<RelayUrl> {
first
.into_iter()
.chain(second)
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
/// The first eight hexadecimal characters of the client public key.
fn authorization_code(public_key: &PublicKey) -> SharedString {
let hex = public_key.to_hex();
@@ -838,35 +1215,6 @@ fn authorization_code(public_key: &PublicKey) -> SharedString {
SharedString::from(format!("{} {}", &code[..4], &code[4..]))
}
/// Get or create new app keys (async, returns a task)
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
let read = credentials::read_app_keys(cx);
cx.spawn(async move |cx| {
if let Ok(Some(secret)) = read.await
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
{
return Ok(keys);
}
// No stored keys found or invalid — generate new ones
let keys = Keys::generate();
let secret = keys.secret_key().to_secret_bytes();
cx.update(|cx| {
let write = credentials::write_app_keys(cx, &secret);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Failed to persist app keys: {e}")
}
})
.detach();
});
Ok(keys)
})
}
/// Decrypt and validate an approval event against the current announcement.
async fn decrypt_approval(
client: &Client,
@@ -874,13 +1222,7 @@ async fn decrypt_approval(
identity: PublicKey,
event: &Event,
) -> Result<Keys, Error> {
let master = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let master = client_pubkey_from_event(event).context("Invalid event's tags")?;
let decrypted = app_keys
.nip44_decrypt_async(&master, event.content.as_str())
@@ -889,71 +1231,52 @@ async fn decrypt_approval(
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
// Reject approvals carrying a key that no longer matches the published announcement.
// Reject approvals carrying a key that no longer matches the published announcement
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(identity)
.limit(1);
if let Some(announcement) = client.database().query(filter).await?.into_iter().next() {
let announced = announcement
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Announcement has no valid encryption key")?;
let announcement = client
.database()
.query(filter.clone())
.await?
.into_iter()
.next();
if keys.public_key() != announced {
return Err(anyhow!("Approval key doesn't match the announcement"));
let announcement = match announcement {
Some(announcement) => Some(announcement),
None => {
let mut stream = client
.stream_events(filter)
.timeout(Duration::from_secs(3))
.await?;
stream
.next()
.await
.map(|(_url, result)| result)
.transpose()?
}
};
let Some(announcement) = announcement else {
return Err(anyhow!(
"No encryption announcement available to validate the approval"
));
};
let announced = announcement
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Announcement has no valid encryption key")?;
if keys.public_key() != announced {
return Err(anyhow!("Approval key doesn't match the announcement"));
}
Ok(keys)
}
/// Encrypt and store device keys in the local database.
async fn set_keys<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.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<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
if let Some(event) = client.database().query(filter).await?.first() {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let secret = SecretKey::parse(&content)?;
let keys = Keys::new(secret);
Ok(keys)
} else {
Err(anyhow!("Key not found"))
}
}
+174
View File
@@ -0,0 +1,174 @@
use anyhow::{Error, anyhow};
use nostr_sdk::prelude::*;
/// The storage identifier for the device encryption key
const IDENTIFIER: &str = "coop:device";
/// The storage identifier for encryption keys retired by a rotation
const ARCHIVE_IDENTIFIER: &str = "coop:device-archive";
/// Keep the archived key list bounded
const ARCHIVE_LIMIT: usize = 10;
/// Seconds in a day, for rotation intervals
pub(crate) const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
/// Device encryption key material with its creation time
#[derive(Debug, Clone)]
pub(crate) struct DeviceKeys {
pub(crate) keys: Keys,
pub(crate) created_at: Timestamp,
}
/// Encode the stored key content: secret hex and creation time
fn encode_keys_content(secret: &str, created_at: &Timestamp) -> String {
format!("{secret}\n{}", created_at.as_secs())
}
/// Parse stored key content.
fn parse_keys_content(content: &str) -> Result<(SecretKey, Timestamp), Error> {
match content.split_once('\n') {
Some((secret, created_at)) => {
let created_at = created_at
.trim()
.parse::<u64>()
.unwrap_or_else(|_| Timestamp::now().as_secs());
Ok((
SecretKey::from_hex(secret)?,
Timestamp::from_secs(created_at),
))
}
None => Ok((SecretKey::from_hex(content)?, Timestamp::now())),
}
}
/// Parse archived secrets from stored content, one hex secret per line
fn parse_archived_keys(content: &str) -> Vec<Keys> {
content
.lines()
.filter_map(|line| SecretKey::from_hex(line.trim()).ok())
.map(Keys::new)
.collect()
}
/// Load the device keys with their creation time from the local database.
pub(crate) async fn load_device_keys<S>(client: &Client, signer: &S) -> Result<DeviceKeys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Err(anyhow!("Key not found"));
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let (secret, created_at) = parse_keys_content(&content)?;
Ok(DeviceKeys {
keys: Keys::new(secret),
created_at,
})
}
/// Encrypt and store device keys in the local database.
pub(crate) async fn set_keys<S>(
client: &Client,
signer: &S,
secret: &str,
created_at: Timestamp,
) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer
.nip44_encrypt_async(&public_key, &encode_keys_content(secret, &created_at))
.await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Append an encryption secret to the bounded archive of retired keys.
pub(crate) async fn archive_key<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let mut archived: Vec<String> = match client.database().query(filter).await?.into_iter().next()
{
Some(event) => {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
content.lines().map(str::to_string).collect()
}
None => vec![],
};
archived.insert(0, secret.to_string());
archived.truncate(ARCHIVE_LIMIT);
let content = signer
.nip44_encrypt_async(&public_key, &archived.join("\n"))
.await?;
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(ARCHIVE_IDENTIFIER))
.finalize_async(signer)
.await?;
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
pub(crate) async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
Ok(load_device_keys(client, signer).await?.keys)
}
/// Load encryption keys retired by a rotation from the local database.
pub(crate) async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(vec![]);
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
Ok(parse_archived_keys(&content))
}
+7 -3
View File
@@ -54,7 +54,7 @@ impl EventEmitter<PersonEvent> for PersonRegistry {}
#[derive(Debug, Clone)]
enum Dispatch {
Person(Person),
Person(Box<Person>),
Announcement(Event),
Relays(Event),
Emojis(Event),
@@ -111,7 +111,7 @@ impl PersonRegistry {
this.update(cx, |this, cx| {
match event {
Dispatch::Person(person) => {
this.insert(person, cx);
this.insert(*person, cx);
}
Dispatch::Announcement(event) => {
this.set_announcement(&event, cx);
@@ -175,7 +175,11 @@ impl PersonRegistry {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata);
if tx.send_async(Dispatch::Person(person)).await.is_err() {
if tx
.send_async(Dispatch::Person(Box::new(person)))
.await
.is_err()
{
log::warn!("Channel closed, dropping metadata event");
}
}
+7
View File
@@ -144,6 +144,13 @@ impl Person {
/// Set profile encryption keys announcement
pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
// A delayed or repeated announcement must not roll the key back
if let Some(current) = &self.announcement
&& !announcement.is_newer_than(current)
{
return;
}
self.announcement = Some(announcement);
}
+43 -14
View File
@@ -100,7 +100,12 @@ pub fn persisted_theme() -> ThemeSettings {
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band";
/// Minimum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1;
/// Maximum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90;
/// Days between automatic encryption key rotations when enabled
pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
/// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
@@ -147,6 +152,7 @@ setting_accessors! {
pub screening: bool,
pub wot_auto_trust: bool,
pub nip4e: bool,
pub nip4e_rotation_days: Option<u32>,
pub hide_group_notice: bool,
pub file_server: Url,
pub panel_background: bool,
@@ -222,6 +228,9 @@ pub struct Settings {
pub wot_auto_trust: bool,
/// Enable decoupling encryption key
pub nip4e: bool,
/// Days between automatic encryption key rotations; None disables rotation
#[serde(default)]
pub nip4e_rotation_days: Option<u32>,
/// Hide the notice that messages in public groups are not encrypted
#[serde(default)]
pub hide_group_notice: bool,
@@ -254,6 +263,7 @@ impl Default for Settings {
screening: true,
wot_auto_trust: true,
nip4e: false,
nip4e_rotation_days: None,
hide_group_notice: false,
theme: ThemeSettings::default(),
panel_background: true,
@@ -330,22 +340,40 @@ impl AppSettings {
});
}
/// Apply the persisted theme tuning on top of the active theme
pub fn apply_theme_style(cx: &mut App) {
Self::apply_theme_tuning(Self::get_theme(cx), cx);
}
fn apply_theme_tuning(tuning: ThemeSettings, cx: &mut App) {
use gpui_kit::component::Theme;
let theme = Self::get_theme(cx);
let styled = Theme::global_mut(cx);
styled.font_size = px(theme.font_size);
styled.focus_ring = theme.focus_ring;
if let Some(radius) = theme.radius {
styled.radius = px(radius);
{
let styled = Theme::global_mut(cx);
styled.font_size = px(tuning.font_size);
styled.focus_ring = tuning.focus_ring;
if let Some(radius) = tuning.radius {
styled.radius = px(radius);
}
if let Some(radius_lg) = tuning.radius_lg {
styled.radius_lg = px(radius_lg);
}
}
if let Some(radius_lg) = theme.radius_lg {
styled.radius_lg = px(radius_lg);
}
Theme::sync_base(cx);
cx.refresh_windows();
Theme::update(cx, |styled| {
let background = styled.background;
let foreground = styled.foreground;
let (hover_alpha, active_alpha) = if styled.is_dark() {
(0.08, 0.12)
} else {
(0.04, 0.06)
};
if styled.list_hover == background {
styled.list_hover = foreground.alpha(hover_alpha);
}
if styled.tab_active == background {
styled.tab_active = foreground.alpha(active_alpha);
}
});
}
/// Load settings
@@ -416,7 +444,8 @@ impl AppSettings {
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
}
cx.refresh_windows();
let tuning = self.inner.read(cx).theme;
Self::apply_theme_tuning(tuning, cx);
}
/// Check if decoupling encryption key is enabled
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "speech"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
log.workspace = true
gpui.workspace = true
gpui-kit = { workspace = true, features = ["speech"] }
flume.workspace = true
instant.workspace = true
tts.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
common = { path = "../common" }
parakeet-rs = "=0.3.8"
ort = "=2.0.0-rc.13"
reqwest = { version = "0.12", default-features = false, features = [
"blocking",
"rustls-tls",
] }
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["Window"] }
+54
View File
@@ -0,0 +1,54 @@
#[cfg(not(target_arch = "wasm32"))]
pub mod model;
#[cfg(not(target_arch = "wasm32"))]
mod parakeet;
pub mod speaker;
#[cfg(target_arch = "wasm32")]
mod web;
use std::time::Duration;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, IntoElement as _, ParentElement as _,
Styled as _, div,
};
use gpui_kit::component::ActiveTheme as _;
use gpui_kit::component::speech::SpeechRecognizer;
/// A ticking `M:SS` counter for the dictation in progress.
pub fn elapsed(started: instant::Instant, cx: &App) -> AnyElement {
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.with_animation(
"dictation-elapsed",
Animation::new(Duration::from_secs(1))
.repeat()
.with_max_fps(1.),
move |counter, _| counter.child(format_elapsed(started)),
)
.into_any_element()
}
fn format_elapsed(started: instant::Instant) -> String {
let seconds = started.elapsed().as_secs();
format!("{}:{:02}", seconds / 60, seconds % 60)
}
/// The recognizer backing the composer's microphone button.
#[cfg(not(target_arch = "wasm32"))]
pub fn recognizer() -> impl SpeechRecognizer {
parakeet::ParakeetRecognizer
}
/// The recognizer backing the composer's microphone button.
#[cfg(target_arch = "wasm32")]
pub fn recognizer() -> impl SpeechRecognizer {
web::WebSpeechRecognizer
}
/// The audio source backing the composer's microphone button on the web.
#[cfg(target_arch = "wasm32")]
pub fn input() -> impl gpui_kit::component::speech::AudioInput {
web::SelfCapturedInput
}
+23
View File
@@ -0,0 +1,23 @@
{
"repository": "istupakov/parakeet-tdt-0.6b-v3-onnx",
"revision": "8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce",
"source": "nvidia/parakeet-tdt-0.6b-v3",
"license": "CC-BY-4.0",
"files": [
{
"name": "decoder_joint-model.int8.onnx",
"size": 18202004,
"sha256": "eea7483ee3d1a30375daedc8ed83e3960c91b098812127a0d99d1c8977667a70"
},
{
"name": "encoder-model.int8.onnx",
"size": 652183999,
"sha256": "6139d2fa7e1b086097b277c7149725edbab89cc7c7ae64b23c741be4055aff09"
},
{
"name": "vocab.txt",
"size": 93939,
"sha256": "d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d"
}
]
}
+295
View File
@@ -0,0 +1,295 @@
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::{fs, io};
use anyhow::{Result, bail};
use serde::Deserialize;
/// How the model is currently available.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
NotInstalled,
Downloading { downloaded: u64, total: u64 },
Installed,
}
impl Status {
pub fn is_downloading(self) -> bool {
matches!(self, Status::Downloading { .. })
}
}
#[derive(Debug, Deserialize)]
struct Manifest {
repository: String,
revision: String,
files: Vec<Artifact>,
}
#[derive(Debug, Deserialize)]
struct Artifact {
name: String,
size: u64,
sha256: String,
}
fn manifest() -> &'static Manifest {
static MANIFEST: OnceLock<Manifest> = OnceLock::new();
MANIFEST.get_or_init(|| {
serde_json::from_str(include_str!("model.json")).expect("pinned model manifest")
})
}
/// The directory the model artifacts are downloaded into.
pub fn dir() -> PathBuf {
common::config_dir()
.join("speech")
.join("parakeet-tdt-0.6b-v3")
}
/// Total download size in bytes.
pub fn total_size() -> u64 {
manifest().files.iter().map(|file| file.size).sum()
}
/// The revision of the installed model, if its artifacts are complete.
fn installed_revision() -> Option<String> {
let manifest = manifest();
let dir = dir();
let complete = manifest.files.iter().all(|file| {
fs::metadata(dir.join(&file.name)).is_ok_and(|metadata| metadata.len() == file.size)
});
if complete
&& fs::read_to_string(dir.join("verified"))
.is_ok_and(|revision| revision == manifest.revision)
{
Some(manifest.revision.clone())
} else {
None
}
}
/// Whether the model is ready to load.
pub fn installed() -> bool {
status() == Status::Installed
}
/// The current status, reconciling the download state with the disk.
pub fn status() -> Status {
let state = state().lock().unwrap_or_else(|error| error.into_inner());
if let Download::Running {
downloaded, total, ..
} = state.download
{
return Status::Downloading { downloaded, total };
}
if installed_revision().is_some() {
Status::Installed
} else {
Status::NotInstalled
}
}
/// Take the last download failure, if it has not been reported yet.
pub fn take_error() -> Option<String> {
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
state.error.take()
}
/// Start downloading the model, unless a download is already running.
pub fn start_download() {
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
if matches!(state.download, Download::Running { .. }) {
return;
}
let cancel = Arc::new(AtomicBool::new(false));
state.error = None;
state.download = Download::Running {
downloaded: 0,
total: total_size(),
cancel: cancel.clone(),
};
if let Err(error) = std::thread::Builder::new()
.name("speech-model-download".into())
.spawn(move || download(cancel))
{
state.download = Download::Idle;
state.error = Some(error.to_string());
}
}
/// Request cancellation of the running download.
pub fn cancel_download() {
let state = state().lock().unwrap_or_else(|error| error.into_inner());
if let Download::Running { cancel, .. } = &state.download {
cancel.store(true, Ordering::Release);
}
}
/// Delete the downloaded model and any partial download.
pub fn delete() {
super::parakeet::unload();
cancel_download();
if let Err(error) = fs::remove_dir_all(dir())
&& error.kind() != io::ErrorKind::NotFound
{
log::warn!("failed to remove the speech model: {error}");
}
}
fn download(cancel: Arc<AtomicBool>) {
let result = download_inner(&cancel);
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
state.download = Download::Idle;
// A cancelled download must not report a spurious failure.
if !cancelled(&cancel)
&& let Err(error) = result
{
state.error = Some(format!("{error:#}"));
}
}
fn download_inner(cancel: &AtomicBool) -> Result<()> {
let manifest = manifest();
let dir = dir();
let client = reqwest::blocking::Client::builder()
.connect_timeout(std::time::Duration::from_secs(20))
.build()?;
fs::create_dir_all(&dir)?;
for file in &manifest.files {
if cancelled(cancel) {
bail!("Cancelled");
}
let staging = dir.join(format!("{}.part", file.name));
let result = download_file(&client, manifest, file, &staging, cancel);
if result.is_err() || cancelled(cancel) {
let _unused = fs::remove_file(&staging);
result?;
bail!("Cancelled");
}
}
fs::write(dir.join("verified"), &manifest.revision)?;
Ok(())
}
fn download_file(
client: &reqwest::blocking::Client,
manifest: &Manifest,
file: &Artifact,
staging: &Path,
cancel: &AtomicBool,
) -> Result<()> {
use std::io::{Read as _, Write as _};
use sha2::Digest as _;
let url = format!(
"https://huggingface.co/{}/resolve/{}/{}",
manifest.repository, manifest.revision, file.name
);
let mut response = client.get(url).send()?.error_for_status()?;
let mut out = fs::File::create(staging)?;
let mut hash = sha2::Sha256::new();
let mut size = 0u64;
loop {
if cancelled(cancel) {
bail!("Cancelled");
}
let mut chunk = [0u8; 64 * 1024];
let read = response.read(&mut chunk)?;
if read == 0 {
break;
}
size += read as u64;
if size > file.size {
bail!("Unexpected model size");
}
hash.update(&chunk[..read]);
out.write_all(&chunk[..read])?;
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
if let Download::Running { downloaded, .. } = &mut state.download {
*downloaded += read as u64;
}
}
if size != file.size || format!("{:x}", hash.finalize()) != file.sha256 {
bail!("Model checksum verification failed");
}
out.sync_all()?;
fs::rename(staging, dir().join(&file.name))?;
Ok(())
}
fn cancelled(cancel: &AtomicBool) -> bool {
cancel.load(Ordering::Acquire)
}
enum Download {
Idle,
Running {
downloaded: u64,
total: u64,
cancel: Arc<AtomicBool>,
},
}
struct State {
download: Download,
error: Option<String>,
}
fn state() -> &'static Mutex<State> {
static STATE: Mutex<State> = Mutex::new(State {
download: Download::Idle,
error: None,
});
&STATE
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_pins_v3_artifacts_with_exact_sizes() {
let manifest = manifest();
assert_eq!(manifest.repository, "istupakov/parakeet-tdt-0.6b-v3-onnx");
assert_eq!(manifest.revision.len(), 40);
assert_eq!(manifest.files.len(), 3);
assert_eq!(total_size(), 670_479_942);
assert!(
manifest
.files
.iter()
.all(|file| file.sha256.len() == 64 && !file.name.contains('/'))
);
}
}
+214
View File
@@ -0,0 +1,214 @@
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow};
use gpui::{App, Task};
use gpui_kit::component::speech::{
AudioFormat, RecognitionSession, SpeechError, SpeechRecognizer, SpeechSink,
};
use parakeet_rs::{ParakeetTDT, Transcriber as _};
const MODEL_RATE: u32 = 16_000;
const MAX_SECONDS: usize = 120;
/// The on-device recognizer.
pub struct ParakeetRecognizer;
impl SpeechRecognizer for ParakeetRecognizer {
fn audio_format(&self) -> AudioFormat {
AudioFormat::new(MODEL_RATE, 1)
}
fn is_available(&self, _cx: &App) -> bool {
true
}
fn start(
&self,
sink: SpeechSink,
cx: &mut App,
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
if super::model::installed() {
worker().send_or_log(Command::Warm(super::model::dir()));
} else if super::model::status().is_downloading() {
return Err(SpeechError::recognizer(anyhow!(
"The speech model is still downloading. Try again when it finishes."
)));
} else {
return Err(SpeechError::recognizer(anyhow!(
"No speech model is installed. Download it from Settings."
)));
}
// The session buffers everything it is pushed,
// so audio is accepted right away.
sink.ready(cx);
Ok(Box::new(ParakeetSession {
sink,
samples: Vec::new(),
_result: None,
}))
}
}
struct ParakeetSession {
sink: SpeechSink,
samples: Vec<f32>,
_result: Option<Task<()>>,
}
impl RecognitionSession for ParakeetSession {
fn push_audio(&mut self, samples: &[i16], _cx: &mut App) {
if self.samples.len() < MAX_SECONDS * MODEL_RATE as usize {
self.samples
.extend(samples.iter().map(|sample| f32::from(*sample) / 32_768.0));
}
}
fn finish(&mut self, cx: &mut App) {
let samples = std::mem::take(&mut self.samples);
let sink = self.sink.clone();
// Speech too short to transcribe ends the session at once.
let silent = samples.iter().all(|sample| sample.abs() < 0.0001);
if samples.len() < MODEL_RATE as usize / 5 || silent {
sink.finish(cx);
return;
}
let (sender, receiver) = flume::unbounded();
worker().send_or_log(Command::Transcribe { samples, sender });
self._result = Some(cx.spawn(async move |cx| {
let result = receiver.recv_async().await;
cx.update(|cx| match result {
Ok(Ok(text)) if !text.is_empty() => {
sink.phrase(text, cx);
sink.finish(cx);
}
Ok(Ok(_)) => sink.finish(cx),
Ok(Err(error)) => sink.error(SpeechError::recognizer(error), cx),
Err(_) => sink.error(
SpeechError::recognizer(anyhow!("The speech worker is unavailable")),
cx,
),
});
}));
}
}
enum Command {
Warm(PathBuf),
Transcribe {
samples: Vec<f32>,
sender: flume::Sender<Result<String, anyhow::Error>>,
},
Unload,
}
/// A handle to the single inference worker.
fn worker() -> &'static flume::Sender<Command> {
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new();
WORKER.get_or_init(|| {
let (sender, receiver) = flume::unbounded::<Command>();
let spawned = std::thread::Builder::new()
.name("speech-parakeet".into())
.spawn(move || run_worker(receiver));
if let Err(error) = spawned {
log::error!("failed to spawn the speech worker: {error}");
}
sender
})
}
fn run_worker(receiver: flume::Receiver<Command>) {
let mut cached: Option<(PathBuf, ParakeetTDT)> = None;
while let Ok(command) = receiver.recv_timeout(Duration::from_secs(30)) {
match command {
Command::Unload => {
cached = None;
}
Command::Warm(dir) => {
let loaded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
load(&mut cached, &dir).map(|_| ())
}));
match flatten_panic(loaded) {
Ok(Ok(())) => {}
Ok(Err(error)) | Err(error) => {
log::warn!("failed to load the speech model: {error:#}")
}
}
}
Command::Transcribe { samples, sender } => {
let transcribed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
transcribe(&mut cached, samples)
}));
let result = match flatten_panic(transcribed) {
Ok(result) => result,
Err(_) => {
cached = None;
Err(anyhow!("Dictation stopped unexpectedly. Try again."))
}
};
if sender.send(result).is_err() {
log::debug!("speech session was cancelled before its result arrived");
}
}
}
}
}
fn load<'a>(
cached: &'a mut Option<(PathBuf, ParakeetTDT)>,
dir: &PathBuf,
) -> Result<&'a mut ParakeetTDT> {
if cached.as_ref().is_none_or(|(path, _)| path != dir) {
let model = ParakeetTDT::from_pretrained(dir, None)
.map_err(|error| anyhow!("Could not load the speech model: {error}"))?;
*cached = Some((dir.clone(), model));
}
let (_, model) = cached.as_mut().context("speech model was unloaded")?;
Ok(model)
}
fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) -> Result<String> {
let model = load(cached, &super::model::dir())?;
model
.transcribe_samples(samples, MODEL_RATE, 1, None)
.map(|result| result.text)
.map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
}
trait SendOrLog {
fn send_or_log(&self, command: Command);
}
impl SendOrLog for flume::Sender<Command> {
fn send_or_log(&self, command: Command) {
if self.send(command).is_err() {
log::debug!("speech worker is gone; command dropped");
}
}
}
/// Retire cached weights, e.g. after the model was deleted.
pub(crate) fn unload() {
worker().send_or_log(Command::Unload);
}
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
result.map_err(|_| anyhow!("The speech worker panicked"))
}
+88
View File
@@ -0,0 +1,88 @@
use std::time::Duration;
use tts::Tts;
/// Words per minute of an engine at its normal rate, for estimating speech duration.
const NORMAL_WPM: f32 = 160.0;
/// Extra time past the estimate before speech is considered finished, for backends that
/// cannot report utterance end.
pub const END_GRACE: Duration = Duration::from_secs(5);
/// Reads text aloud through the platform's speech backend, created on first use.
pub struct Speaker {
tts: Option<Tts>,
end_tx: flume::Sender<()>,
end_rx: flume::Receiver<()>,
}
impl Speaker {
pub fn new() -> Self {
let (end_tx, end_rx) = flume::unbounded();
Self {
tts: None,
end_tx,
end_rx,
}
}
/// Cloneable stream of utterance-finish events.
pub fn end_events(&self) -> flume::Receiver<()> {
self.end_rx.clone()
}
/// Speak the text, interrupting what this speaker is already saying.
pub fn speak(&mut self, text: &str) -> Result<Duration, tts::Error> {
let end_tx = self.end_tx.clone();
let tts = match self.tts.as_ref() {
Some(tts) => tts,
None => self.tts.insert({
let tts = Tts::default()?;
if let Err(error) = tts.on_utterance_end(move |_| {
// A send error only means the panel dropped its event loop.
let _ = end_tx.send(());
}) {
log::warn!("utterance end events unavailable ({error})");
}
tts
}),
};
tts.speak(text, true)?;
Ok(Self::estimate(tts, text))
}
/// Stop speaking, a speaker that never spoke is already silent.
pub fn stop(&mut self) -> Result<(), tts::Error> {
match self.tts.as_ref() {
Some(tts) => tts.stop()?,
None => return Ok(()),
}
Ok(())
}
/// Best-effort speaking state, a speaker that never spoke is silent.
pub fn speaking(&self) -> bool {
self.tts
.as_ref()
.and_then(|tts| tts.is_speaking().ok())
.unwrap_or(false)
}
/// How long speaking the text is expected to take at the engine's current speed.
fn estimate(tts: &Tts, text: &str) -> Duration {
let words = text.split_whitespace().count().max(1) as f32;
let speed = match (tts.get_rate(), tts.normal_rate()) {
(Ok(rate), Ok(normal)) if normal > 0. => (rate / normal).clamp(0.2, 4.),
_ => 1.,
};
Duration::from_secs_f32(words / NORMAL_WPM * 60. / speed)
}
}
impl Default for Speaker {
fn default() -> Self {
Self::new()
}
}
+256
View File
@@ -0,0 +1,256 @@
use std::cell::RefCell;
use anyhow::anyhow;
use gpui::App;
use gpui_kit::Subscription;
use gpui_kit::component::speech::{
AudioFormat, AudioInput, AudioSink, RecognitionSession, SpeechError, SpeechRecognizer,
SpeechSink,
};
use js_sys::{Array, Function, JsString, Reflect};
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast as _, JsValue};
use web_sys::window;
/// The recognizer backed by `SpeechRecognition` (or its `webkit` prefix).
pub struct WebSpeechRecognizer;
impl SpeechRecognizer for WebSpeechRecognizer {
fn audio_format(&self) -> AudioFormat {
AudioFormat::default()
}
fn is_available(&self, _cx: &App) -> bool {
true
}
fn start(
&self,
sink: SpeechSink,
cx: &mut App,
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
let recognition = Recognition::open(sink, cx).map_err(SpeechError::recognizer)?;
Ok(Box::new(recognition))
}
}
pub struct SelfCapturedInput;
impl AudioInput for SelfCapturedInput {
fn start(
&self,
_format: AudioFormat,
_sink: AudioSink,
_cx: &mut App,
) -> Result<Subscription, SpeechError> {
Ok(Subscription::new(|| {}))
}
}
enum Event {
Hypothesis(String),
Phrase(String),
Error(String),
End,
}
struct Recognition {
recognition: JsValue,
on_result: Option<Closure<dyn FnMut(JsValue)>>,
on_error: Option<Closure<dyn FnMut(JsValue)>>,
on_end: Option<Closure<dyn FnMut(JsValue)>>,
_task: gpui::Task<()>,
}
impl Recognition {
fn open(sink: SpeechSink, cx: &mut App) -> anyhow::Result<Self> {
let window = window().ok_or_else(|| anyhow!("No browser window"))?;
let constructor = Reflect::get(&window, &JsString::from("SpeechRecognition"))
.ok()
.filter(|value| value.is_function())
.or_else(|| {
Reflect::get(&window, &JsString::from("webkitSpeechRecognition"))
.ok()
.filter(|value| value.is_function())
})
.ok_or_else(|| anyhow!("This browser does not support speech recognition"))?;
let recognition =
Reflect::construct(constructor.unchecked_ref::<Function>(), &Array::new())
.map_err(|error| js_error("constructing the recognizer", error))?;
let (sender, receiver) = flume::unbounded::<Event>();
let error_sender = sender.clone();
let end_sender = sender.clone();
let finalized = RefCell::new(0usize);
let on_result = Closure::wrap(Box::new(move |event: JsValue| {
let read = |value: &JsValue, property: &str| -> Option<JsValue> {
Reflect::get(value, &JsString::from(property)).ok()
};
let Some(results) = read(&event, "results") else {
return;
};
let length = read(&results, "length")
.and_then(|length| length.as_f64())
.unwrap_or(0.0) as usize;
let mut finalized = finalized.borrow_mut();
let mut interim = String::new();
for index in *finalized..length {
let Some(result) = read(&results, &index.to_string()) else {
continue;
};
let Some(alternative) = read(&result, "0") else {
continue;
};
let Some(text) = read(&alternative, "transcript").and_then(|text| text.as_string())
else {
continue;
};
let is_final = read(&result, "isFinal")
.and_then(|final_value| final_value.as_bool())
.unwrap_or(false);
if is_final {
*finalized += 1;
if sender.send(Event::Phrase(text)).is_err() {
log::debug!("speech session was cancelled mid-utterance");
}
} else {
interim.push_str(&text);
}
}
if !interim.is_empty() && sender.send(Event::Hypothesis(interim)).is_err() {
log::debug!("speech session was cancelled mid-utterance");
}
}) as Box<dyn FnMut(JsValue)>);
let on_error = Closure::wrap(Box::new(move |event: JsValue| {
let message = Reflect::get(&event, &JsString::from("error"))
.ok()
.and_then(|error| error.as_string())
.unwrap_or_else(|| "unknown speech error".into());
if error_sender.send(Event::Error(message)).is_err() {
log::debug!("speech session was cancelled before its error arrived");
}
}) as Box<dyn FnMut(JsValue)>);
let on_end = Closure::wrap(Box::new(move |_event: JsValue| {
if end_sender.send(Event::End).is_err() {
log::debug!("speech session was cancelled before its end arrived");
}
}) as Box<dyn FnMut(JsValue)>);
set_callback(&recognition, "onresult", on_result.as_ref().unchecked_ref())?;
set_callback(&recognition, "onerror", on_error.as_ref().unchecked_ref())?;
set_callback(&recognition, "onend", on_end.as_ref().unchecked_ref())?;
set_bool(&recognition, "continuous", true)?;
set_bool(&recognition, "interimResults", true)?;
call_method(&recognition, "start")?;
// The browser consumes audio as soon as it has permission
sink.ready(cx);
let task = cx.spawn(async move |cx| {
while let Ok(event) = receiver.recv_async().await {
let done = matches!(event, Event::End);
// The speech state ignores sink calls once its session ended,
// so a late event after a cancel is harmless.
cx.update(|cx| match event {
Event::Hypothesis(text) => sink.hypothesis(text, cx),
Event::Phrase(text) => sink.phrase(text, cx),
Event::Error(message) => {
sink.error(SpeechError::recognizer(anyhow!("{message}")), cx)
}
Event::End => sink.finish(cx),
});
if done {
break;
}
}
});
Ok(Self {
recognition,
on_result: Some(on_result),
on_error: Some(on_error),
on_end: Some(on_end),
_task: task,
})
}
fn stop(&mut self) {
self.on_result = None;
self.on_error = None;
self.on_end = None;
let _ignored = call_method(&self.recognition, "abort");
}
}
impl Drop for Recognition {
fn drop(&mut self) {
self.stop();
}
}
impl RecognitionSession for Recognition {
fn push_audio(&mut self, _samples: &[i16], _cx: &mut App) {
// The browser recognizer captures the microphone itself.
// the state still pushes audio through its input, which is a no-op here.
}
fn finish(&mut self, _cx: &mut App) {
// The end handler reports completion, so no result is fabricated here.
let _ignored = call_method(&self.recognition, "stop");
}
}
fn set_callback(target: &JsValue, property: &str, callback: &Function) -> anyhow::Result<()> {
Reflect::set(target, &JsString::from(property), callback)
.map_err(|error| js_error(property, error))?;
Ok(())
}
fn set_bool(target: &JsValue, property: &str, value: bool) -> anyhow::Result<()> {
Reflect::set(target, &JsString::from(property), &JsValue::from(value))
.map_err(|error| js_error(property, error))?;
Ok(())
}
fn call_method(target: &JsValue, method: &str) -> anyhow::Result<()> {
let function = Reflect::get(target, &JsString::from(method))
.map_err(|error| js_error(method, error))?
.unchecked_into::<Function>();
function
.call0(target)
.map(|_| ())
.map_err(|error| js_error(method, error))
}
fn js_error(what: &str, error: JsValue) -> anyhow::Error {
let description = error
.as_string()
.or_else(|| {
Reflect::get(&error, &JsString::from("message"))
.ok()
.and_then(|message| message.as_string())
})
.unwrap_or_else(|| "unknown error".into());
anyhow!("{what} failed: {description}")
}
+8 -8
View File
@@ -5,8 +5,8 @@ use gpui::{App, AppContext as _, Task};
use crate::USER_KEYRING;
/// The storage key for the NIP-4e app keys
const APP_KEYS_KEY: &str = "coop/nip4e/app-keys";
/// The storage key for the log of handled NIP-4e key-sync request ids
const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests";
/// Read the stored credential as `(username, secret)`
#[allow(clippy::type_complexity)]
@@ -94,12 +94,12 @@ fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
}
}
/// Load the NIP-4e app keys from storage, as raw secret bytes
pub fn read_app_keys(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
read_named(cx, APP_KEYS_KEY)
/// Read the log of handled key-sync request ids, as concatenated hex ids
pub fn read_processed_requests(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
read_named(cx, PROCESSED_REQUESTS_KEY)
}
/// Persist the NIP-4e app keys, as raw secret bytes
pub fn write_app_keys(cx: &App, secret: &[u8]) -> Task<Result<()>> {
write_named(cx, APP_KEYS_KEY, secret)
/// Persist the log of handled key-sync request ids, as concatenated hex ids
pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task<Result<()>> {
write_named(cx, PROCESSED_REQUESTS_KEY, ids)
}
+14 -1
View File
@@ -5,6 +5,10 @@ use nostr_sdk::prelude::*;
pub struct Announcement {
/// The public key of the device that created this announcement.
public_key: PublicKey,
/// The id of the announcement event, for ordering
event_id: EventId,
/// The creation time of the announcement event
created_at: Timestamp,
}
impl Announcement {
@@ -16,11 +20,20 @@ impl Announcement {
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())?;
Some(Self { public_key })
Some(Self {
public_key,
event_id: event.id,
created_at: event.created_at,
})
}
/// Returns the public key of the device that created this announcement.
pub fn public_key(&self) -> PublicKey {
self.public_key
}
pub fn is_newer_than(&self, other: &Self) -> bool {
self.created_at > other.created_at
|| (self.created_at == other.created_at && self.event_id < other.event_id)
}
}
+6
View File
@@ -20,14 +20,17 @@ pub enum IconName {
Link,
Lock,
Message,
Paperclip,
PaperPlaneFill,
Plus,
PlusChat,
Profile,
Relay,
Speech,
Search,
Settings,
Settings2,
Unlock,
Upload,
Warning,
}
@@ -51,14 +54,17 @@ impl IconNamed for IconName {
Self::Link => "icons/link.svg",
Self::Lock => "icons/lock.svg",
Self::Message => "icons/message.svg",
Self::Paperclip => "icons/paperclip.svg",
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
Self::Plus => "icons/plus.svg",
Self::PlusChat => "icons/plus-chat.svg",
Self::Profile => "icons/profile.svg",
Self::Relay => "icons/relay.svg",
Self::Speech => "icons/speech.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Unlock => "icons/unlock.svg",
Self::Upload => "icons/upload.svg",
Self::Warning => "icons/warning.svg",
}
+80 -16
View File
@@ -1,18 +1,21 @@
use std::collections::BTreeMap;
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Context, ElementId, InteractiveElement as _, IntoElement,
MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString,
SharedUri, StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div,
img, px,
Animation, AnimationExt as _, AnyElement, App, ClickEvent, Context, ElementId,
InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ObjectFit,
ParentElement as _, RenderOnce, SharedString, SharedUri, StatefulInteractiveElement as _,
StyleRefinement, Styled, StyledImage, Window, div, img, px,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_kit::component::{
ActiveTheme, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex,
ActiveTheme, Disableable, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex,
v_flex,
};
use smallvec::SmallVec;
@@ -596,13 +599,14 @@ pub fn react_menu(
})
}
/// A hovered message's action bar: quick reactions, reply, copy, and an overflow menu.
/// A hovered message's action bar: quick reactions, reply, copy, read aloud, and an overflow menu.
#[derive(IntoElement)]
pub struct MessageActions {
style: StyleRefinement,
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
reply: Option<SharedClickListener>,
copy: Option<SharedClickListener>,
copy: Option<(ElementId, SharedString)>,
speech: Option<(bool, SharedClickListener)>,
dropdown: Option<DropdownBuilder>,
}
@@ -614,6 +618,7 @@ impl MessageActions {
reactions: SmallVec::new(),
reply: None,
copy: None,
speech: None,
dropdown: None,
}
}
@@ -634,9 +639,20 @@ impl MessageActions {
self
}
/// Add the copy button.
pub fn copy(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.copy = Some(Rc::new(handler));
/// Add the copy button for the given value.
/// The id must be unique per bar, the clipboard component keys its copied state on it.
pub fn copy(mut self, id: impl Into<ElementId>, value: impl Into<SharedString>) -> Self {
self.copy = Some((id.into(), value.into()));
self
}
/// Add the read-aloud button, disabled while a message is already being spoken.
pub fn speech(
mut self,
disabled: bool,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.speech = Some((disabled, Rc::new(handler)));
self
}
@@ -669,10 +685,13 @@ impl RenderOnce for MessageActions {
reactions,
reply,
copy,
speech,
dropdown,
} = self;
let has_center = reply.is_some() || copy.is_some();
// The leading separator only separates reactions from the center buttons
let has_center =
!reactions.is_empty() && (reply.is_some() || copy.is_some() || speech.is_some());
h_flex()
.p_0p5()
@@ -707,14 +726,18 @@ impl RenderOnce for MessageActions {
.on_click(move |event, window, cx| reply(event, window, cx)),
)
})
.when_some(copy, |this, copy| {
.when_some(copy, |this, (id, value)| {
this.child(Clipboard::new(id).value(value).tooltip("Copy").small())
})
.when_some(speech, |this, (disabled, speech)| {
this.child(
Button::new("copy")
.icon(IconName::Copy)
.tooltip("Copy")
Button::new("speech")
.icon(crate::IconName::Speech)
.tooltip("Read aloud")
.small()
.ghost()
.on_click(move |event, window, cx| copy(event, window, cx)),
.disabled(disabled)
.on_click(move |event, window, cx| speech(event, window, cx)),
)
})
.when_some(dropdown, |this, dropdown| {
@@ -731,3 +754,44 @@ impl RenderOnce for MessageActions {
.refine_style(&style)
}
}
/// A message's text revealed word by word, paced by the estimated speech duration.
#[derive(IntoElement)]
pub struct RevealText {
text: SharedString,
duration: Duration,
}
impl RevealText {
pub fn new(text: impl Into<SharedString>, duration: Duration) -> Self {
Self {
text: text.into(),
duration,
}
}
}
impl RenderOnce for RevealText {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let words: Vec<SharedString> = self
.text
.split_whitespace()
.map(SharedString::from)
.collect();
let total = words.len().max(1) as f32;
div().flex().flex_wrap().gap_x_1().with_animation(
"speech-reveal",
Animation::new(self.duration).with_max_fps(30.),
move |this, progress| {
let visible = ((progress * total).ceil() as usize).min(words.len());
this.children(words.iter().enumerate().map(|(ix, word)| {
div()
.when(ix >= visible, |this| this.invisible())
.child(word.clone())
}))
},
)
}
}
+1
View File
@@ -15,6 +15,7 @@ chat_ui = { path = "../chat_ui" }
nip29 = { path = "../nip29" }
nip29_ui = { path = "../nip29_ui" }
settings = { path = "../settings" }
speech = { path = "../speech" }
person = { path = "../person" }
auto_update = { path = "../auto_update" }
+88 -44
View File
@@ -1,15 +1,18 @@
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div, px, relative,
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Task, Window, div, px, relative,
};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_kit::component::form::{field, v_form};
use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
use instant::Duration;
use nostr_connect::prelude::*;
use state::{NostrRegistry, StateEvent};
use ui::IconName;
pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
@@ -32,7 +35,6 @@ pub fn open_unlock(window: &mut Window, cx: &mut App) {
this.close_button(false)
.overlay_closable(false)
.keyboard(false)
.title("Unlock your identity")
.child(import.clone())
});
}
@@ -74,7 +76,15 @@ impl ImportIdentity {
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let pass_input = cx.new(|cx| {
let input = InputState::new(window, cx).masked(true);
if mode == Mode::Unlock {
input.placeholder("Passphrase to unlock your identity")
} else {
input
}
});
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
@@ -235,15 +245,59 @@ impl ImportIdentity {
Ok(())
}));
}
fn render_unlock(&self, cx: &mut Context<Self>) -> Div {
v_flex()
.gap_4()
.child(
DialogHeader::new()
.child(DialogTitle::new().child("Unlock your identity"))
.child(
DialogDescription::new()
.child("Enter the passphrase used to encrypt this identity."),
),
)
.child(
v_form().child(
field()
.label("Passphrase")
.required(true)
.child(Input::new(&self.pass_input)),
),
)
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_sm()
.text_color(cx.theme().danger)
.child(error.clone()),
)
})
.child(
DialogFooter::new().child(
Button::new("unlock")
.primary()
.icon(IconName::Unlock)
.label("Unlock")
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.login(cx);
})),
),
)
}
}
impl Render for ImportIdentity {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.mode == Mode::Unlock {
return self.render_unlock(cx);
}
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
let is_unlock = self.mode == Mode::Unlock;
let value = self.key_input.read(cx).value().trim().to_string();
let require_password = value.starts_with("ncryptsec1");
let require_encrypt = value.starts_with("nsec1");
@@ -257,25 +311,19 @@ impl Render for ImportIdentity {
.child(
v_flex()
.gap_2()
.when(!is_unlock, |this| {
.child(
v_flex()
.gap_1()
.text_color(cx.theme().muted_foreground)
.child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
.when(require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().muted_foreground)
.child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
})
.when(is_unlock || require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().muted_foreground)
.child(if is_unlock {
"Passphrase to unlock your identity:"
} else {
"Decrypt Password:"
})
.child("Decrypt Password:")
.child(Input::new(&self.pass_input)),
)
})
@@ -323,7 +371,7 @@ impl Render for ImportIdentity {
.child({
let content = v_flex().gap_1().child(
Button::new("login")
.label(if is_unlock { "Unlock" } else { "Continue" })
.label("Continue")
.primary()
.loading(self.loading)
.disabled(self.loading)
@@ -333,30 +381,26 @@ impl Render for ImportIdentity {
);
#[cfg(not(target_arch = "wasm32"))]
let content = content.when(!is_unlock, |this| {
this.child(
Button::new("proxy")
.label("Connect via Web Extension")
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.proxy(cx);
})),
)
});
let content = content.child(
Button::new("proxy")
.label("Connect via Web Extension")
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.proxy(cx);
})),
);
#[cfg(target_arch = "wasm32")]
let content = content.when(!is_unlock, |this| {
this.child(
Button::new("extension")
.label("Continue with Browser Extension")
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.extension(cx);
})),
)
});
let content = content.child(
Button::new("extension")
.label("Continue with Browser Extension")
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.extension(cx);
})),
);
content
})
+2 -4
View File
@@ -10,7 +10,6 @@ use gpui::{
Window, div, px, uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::input::{Input, InputEvent, InputState};
@@ -205,6 +204,7 @@ impl NewChat {
Ok(())
}));
return;
}
@@ -369,15 +369,13 @@ impl NewChat {
.p_0p5()
.rounded_full()
.bg(cx.theme().secondary)
.text_xs()
.font_medium()
.hover(|this| this.bg(cx.theme().primary))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.xsmall(),
)
.child(div().max_w_32().truncate().child(profile.name()))
.child(div().max_w_32().truncate().text_xs().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_member(&public_key, cx);
}))
+12 -55
View File
@@ -4,17 +4,17 @@ use anyhow::Error;
use chat::{ChatRegistry, Room, RoomKind};
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, Render,
SharedString, Styled, Task, Window, div, retain_all,
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Task, Window, div, retain_all,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dock::DockPlacement;
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
use settings::AppSettings;
@@ -49,8 +49,6 @@ struct ProfileDialog {
website_input: Entity<InputState>,
/// Uploading state
uploading: bool,
/// Copied states
copied: bool,
/// Updating state
updating: bool,
/// Tasks
@@ -92,7 +90,6 @@ impl ProfileDialog {
bio_input,
website_input,
uploading: false,
copied: false,
updating: false,
tasks: vec![],
}
@@ -141,35 +138,6 @@ impl ProfileDialog {
cx.notify();
}
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
let item = ClipboardItem::new_string(value);
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(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.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
Ok(())
}));
}
}
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.uploading = status;
cx.notify();
@@ -368,7 +336,8 @@ impl Render for ProfileDialog {
.w_full()
.items_center()
.justify_center()
.bg(cx.theme().sidebar)
.rounded(cx.theme().radius_lg)
.bg(cx.theme().muted)
.child(Avatar::new(avatar).seed(profile.avatar_seed()).large())
.when(self.editing, |this| {
this.child(
@@ -393,25 +362,13 @@ impl Render for ProfileDialog {
.justify_center()
.gap_3()
.rounded(cx.theme().radius)
.bg(cx.theme().sidebar)
.bg(cx.theme().muted)
.text_xs()
.child(shorten_pkey)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CircleCheck
} else {
IconName::Copy
}
})
.xsmall()
.secondary()
.on_click(cx.listener(move |this, _ev, window, cx| {
let pk = this.public_key.to_bech32().unwrap();
this.copy(pk, window, cx)
})),
),
.child(Clipboard::new("copy").value_fn({
let public_key = self.public_key;
move |_, _| public_key.to_bech32().unwrap_or_default().into()
})),
),
)
.when(self.editing, |this| {
@@ -544,7 +501,7 @@ fn field(
};
v_flex()
.gap_1p5()
.gap_1()
.child(label(text, cx))
.child(div().text_xs().text_color(color).child(content))
.child(div().text_sm().text_color(color).child(content))
}
+1 -1
View File
@@ -264,7 +264,7 @@ impl RelayManager {
fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
TabBar::new("relays-tabs")
.segmented()
.pill()
.selected_index(self.active_tab)
.on_click(cx.listener(|this, ix: &usize, _window, cx| {
this.active_tab = *ix;
+160 -5
View File
@@ -1,19 +1,30 @@
#[cfg(not(target_arch = "wasm32"))]
use std::time::Duration;
use chat_ui::ToggleDictation;
#[cfg(not(target_arch = "wasm32"))]
use gpui::PathPromptOptions;
#[cfg(not(target_arch = "wasm32"))]
use gpui::Task;
use gpui::http_client::Url;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription,
Window, div,
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled,
Subscription, Window, div,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::kbd::Kbd;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::notification::Notification;
#[cfg(not(target_arch = "wasm32"))]
use gpui_kit::component::progress::Progress;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
use settings::{AppSettings, AppearanceMode};
use settings::{AppSettings, AppearanceMode, NIP4E_ROTATION_DEFAULT_DAYS};
#[cfg(not(target_arch = "wasm32"))]
use speech::model::{self as speech_model};
use ui::{settings_block, settings_row};
/// Bounds for the theme number inputs
@@ -29,6 +40,8 @@ pub struct Preferences {
font_size_input: Entity<InputState>,
radius_input: Entity<InputState>,
radius_lg_input: Entity<InputState>,
#[cfg(not(target_arch = "wasm32"))]
_speech_poll: Task<()>,
/// Keeps the control subscriptions alive while the dialog is open
_subscriptions: Vec<Subscription>,
}
@@ -94,11 +107,40 @@ impl Preferences {
}),
];
#[cfg(not(target_arch = "wasm32"))]
let _speech_poll = cx.spawn_in(window, async move |this, cx| {
let timer = Duration::from_millis(500);
let mut last = speech_model::status();
loop {
cx.background_executor().timer(timer).await;
let status = speech_model::status();
let error = speech_model::take_error();
if status == last {
continue;
}
last = status;
this.update_in(cx, |_, window, cx| {
if let Some(error) = error {
window.push_notification(Notification::error(error).autohide(false), cx);
}
cx.notify();
})
.ok();
}
});
Self {
file_input,
font_size_input,
radius_input,
radius_lg_input,
#[cfg(not(target_arch = "wasm32"))]
_speech_poll,
_subscriptions: subscriptions,
}
}
@@ -188,10 +230,102 @@ impl Preferences {
.detach();
}
}
fn render_speech(
&mut self,
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context<Self>,
) -> AnyElement {
#[cfg(target_arch = "wasm32")]
{
return div().into_any_element();
}
#[cfg(not(target_arch = "wasm32"))]
{
let size = speech_model::total_size() / 1_000_000;
let status = speech_model::status();
let (description, control) = match &status {
speech_model::Status::NotInstalled => (
format!("{size} MB download"),
Button::new("download-model")
.label("Download")
.secondary()
.small()
.on_click(|_, _window, cx| {
speech_model::start_download();
cx.refresh_windows();
})
.into_any_element(),
),
speech_model::Status::Downloading { downloaded, total } => (
format!(
"Downloading · {} of {} MB",
downloaded / 1_000_000,
total / 1_000_000
),
Button::new("cancel-download")
.label("Cancel")
.ghost()
.small()
.on_click(|_, _window, cx| {
speech_model::cancel_download();
cx.refresh_windows();
})
.into_any_element(),
),
speech_model::Status::Installed => (
format!("Parakeet v3 · {size} MB"),
Button::new("remove-model")
.label("Remove")
.secondary()
.small()
.on_click(|_, _window, cx| {
speech_model::delete();
cx.refresh_windows();
})
.into_any_element(),
),
};
let mut block = v_flex().w_full().gap_3().child(settings_row(
"Speech model",
description,
control,
cx,
));
if let speech_model::Status::Downloading { downloaded, total } = &status {
let (downloaded, total) = (*downloaded, *total);
block = block.child(
Progress::new("speech-model-download")
.value(downloaded as f32 / total.max(1) as f32 * 100.)
.small(),
);
}
block.into_any_element()
}
}
/// Shows the keystroke actually bound to the dictation action at startup
fn render_dictation_shortcut(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let control = match Kbd::global_binding_for_action(&ToggleDictation, window) {
Some(kbd) => kbd.into_any_element(),
None => div().into_any_element(),
};
settings_row("Shortcut", "Press to start or stop dictation", control, cx)
}
}
impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
const WOT: &str = "Treat people followed by your contacts as known senders.";
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
@@ -203,6 +337,7 @@ impl Render for Preferences {
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
const FOCUS_RING: &str = "Draw a ring around focused controls.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
const BACKGROUND: &str = "Show an image behind chat and group panels.";
const MEDIA: &str = "Server for media attachments. Only blossom services are supported.";
@@ -210,6 +345,7 @@ impl Render for Preferences {
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx);
let nip4e_rotation_days = AppSettings::get_nip4e_rotation_days(cx);
let appearance_mode = AppSettings::get_appearance_mode(cx);
let theme = AppSettings::get_theme(cx);
let panel_background = AppSettings::get_panel_background(cx);
@@ -221,7 +357,7 @@ impl Render for Preferences {
};
v_flex()
.mt_2()
.my_2()
.gap_4()
.w_full()
.child(
@@ -373,6 +509,25 @@ impl Render for Preferences {
}),
cx,
))
.child(settings_row(
"Rotate Encryption Key",
NIP4E_ROTATION,
Switch::new("nip4e_rotation")
.checked(nip4e_rotation_days.is_some())
.on_click(move |_, _window, cx| {
let enabled = AppSettings::get_nip4e_rotation_days(cx).is_some();
let days = if enabled {
None
} else {
Some(NIP4E_ROTATION_DEFAULT_DAYS)
};
AppSettings::update_nip4e_rotation_days(days, cx);
}),
cx,
))
.child(Separator::horizontal())
.child(self.render_speech(cx))
.child(self.render_dictation_shortcut(window, cx))
.child(Separator::horizontal())
.child(settings_block(
"Media Upload Service",
+1 -2
View File
@@ -1,5 +1,4 @@
use std::cell::Cell;
use std::ops::Deref as _;
use std::rc::Rc;
use std::sync::Arc;
@@ -166,7 +165,7 @@ impl CoopDockSkin {
.on_drag(ResizePanel, move |info, _, _, cx| {
cx.stop_propagation();
shared.resizing_dock().set(Some(placement));
cx.new(|_| info.deref().clone())
cx.new(|_| info.clone())
})
}
}
+5 -4
View File
@@ -91,11 +91,12 @@ impl Workspace {
let workspace = cx.entity().downgrade();
let mut subscriptions = smallvec![];
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
Theme::sync_system_appearance(Some(window), cx);
}));
}
AppSettings::apply_theme_style(cx);
}
}));
subscriptions.push(
// Subscribe to the nostr events
+10 -49
View File
@@ -1,16 +1,14 @@
use anyhow::Error;
use gpui::{
App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Styled, Task, Window, div,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, Sizable, v_flex};
use instant::Duration;
use gpui_kit::component::{ActiveTheme, v_flex};
use nostr_sdk::prelude::*;
use state::USER_KEYRING;
@@ -32,9 +30,6 @@ pub struct BackupPanel {
/// Secret key input
nsec_input: Entity<InputState>,
/// Copied status
copied: bool,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -54,7 +49,6 @@ impl BackupPanel {
focus_handle: cx.focus_handle(),
npub_input,
nsec_input,
copied: false,
tasks: vec![],
}
}
@@ -81,31 +75,6 @@ impl BackupPanel {
Ok(())
}));
}
fn copy_secret(&mut self, cx: &mut Context<Self>) {
let value = self.nsec_input.read(cx).value();
let item = ClipboardItem::new_string(value.to_string());
cx.write_to_clipboard(item);
// Set the copied status to true
self.set_copied(true, cx);
}
fn set_copied(&mut self, status: bool, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
self.tasks.push(cx.spawn(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.set_copied(false, cx);
})?;
Ok(())
}));
}
}
impl gpui_kit::component::dock::BasePanel for BackupPanel {
@@ -174,20 +143,12 @@ impl Render for BackupPanel {
.child(Input::new(&self.nsec_input).disabled(true)),
)
.child(
Button::new("copy")
.icon(IconName::Copy)
.label({
if self.copied {
"Copied"
} else {
"Copy secret key"
}
})
.primary()
.small()
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.copy_secret(cx);
})),
Clipboard::new("copy-secret")
.accessibility_label("Copy secret key")
.value_fn({
let nsec_input = self.nsec_input.clone();
move |_, cx| nsec_input.read(cx).value()
}),
),
)
}
+102 -3
View File
@@ -1,18 +1,25 @@
#[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater;
use gpui::prelude::FluentBuilder;
#[cfg(not(target_arch = "wasm32"))]
use gpui::{AnyElement, Entity, IntoElement};
use gpui::{
App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px,
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
Stateful, Styled, Window, div, px,
};
#[cfg(not(target_arch = "wasm32"))]
use gpui::{AnyElement, Entity};
use gpui_kit::base::StyledExt as _;
#[cfg(not(target_arch = "wasm32"))]
use gpui_kit::component::Disableable;
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::popover::Popover;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::shorten_pubkey;
use settings::AppSettings;
use state::{CLIENT_NAME, NostrRegistry};
use ui::{Avatar, IconName};
@@ -31,6 +38,10 @@ impl Sidebar {
let updater = AutoUpdater::try_global(cx);
let profile = self.directory.read(cx).person(current_user, cx);
let avatar = profile.avatar();
let encryption_key = profile
.announcement()
.map(|announcement| announcement.public_key());
let encryption_enabled = AppSettings::get_nip4e(cx);
h_flex()
.id("sidebar-user")
@@ -71,6 +82,11 @@ impl Sidebar {
});
actions
.when(encryption_enabled, |actions| {
actions.when_some(encryption_key, |actions, encryption_key| {
actions.child(self.render_encryption(encryption_key))
})
})
.child(
Button::new("sidebar-search")
.icon(IconName::Search)
@@ -111,6 +127,89 @@ impl Sidebar {
})
}
/// The encryption key popover: public key with copy, backup and reset
fn render_encryption(&self, encryption_key: PublicKey) -> impl IntoElement {
Popover::new("sidebar-encryption")
.anchor(Anchor::TopRight)
.trigger(
Button::new("encryption-key")
.icon(IconName::Lock)
.tooltip("Encryption Key")
.ghost()
.small(),
)
.content(move |_, _, cx| {
let popover = cx.entity();
let Ok(npub) = encryption_key.to_bech32();
v_flex()
.w_64()
.gap_2()
.text_sm()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Public key"),
)
.child(
h_flex()
.items_center()
.gap_1()
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(shorten_pubkey(encryption_key, 12))),
)
.child(
Clipboard::new("encryption-key-copy")
.tooltip("Copy public key")
.value(npub),
),
)
.child(Separator::horizontal())
.child(
h_flex()
.justify_end()
.gap_2()
.child(
Button::new("encryption-backup")
.label("Backup")
.outline()
.small()
.on_click({
let popover = popover.clone();
move |_, window, cx| {
popover
.update(cx, |state, cx| state.dismiss(window, cx));
window.dispatch_action(
Box::new(Command::BackupEncryption),
cx,
);
}
}),
)
.child(
Button::new("encryption-reset")
.label("Reset")
.ghost()
.small()
.on_click(move |_, window, cx| {
popover.update(cx, |state, cx| state.dismiss(window, cx));
window.dispatch_action(
Box::new(Command::ResetEncryption),
cx,
);
}),
),
)
})
}
pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div {
v_flex()
.p_2()
+10 -2
View File
@@ -21,6 +21,7 @@ use nostr_sdk::prelude::*;
use person::{Directory, PersonRegistry};
pub(crate) use rows::open_room;
use search::SearchResult;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent};
use ui::title_bar_drag_handlers;
@@ -104,6 +105,8 @@ impl Sidebar {
let chat = ChatRegistry::global(cx);
let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
let directory = Directory::new(cx);
let find_input = cx.new(|cx| {
InputState::new(window, cx)
@@ -206,6 +209,11 @@ impl Sidebar {
},
));
// The user header reads settings and the tracked profiles on render, so
// the encryption button reacts to toggles and announcement arrivals
subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify()));
subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify()));
tasks.push(cx.spawn(async move |this, cx| {
let timer = Duration::from_millis(PROGRESS_TICK_MS);
loop {
@@ -229,7 +237,7 @@ impl Sidebar {
chats_open: None,
contacts_open: false,
contacts: None,
directory: Directory::new(cx),
directory,
selected_contacts: HashSet::new(),
requests_open: false,
new_requests: false,
@@ -340,7 +348,7 @@ impl Sidebar {
)
.child(
div()
.text_sm()
.text_xs()
.text_color(cx.theme().sidebar_foreground)
.child(label),
)
+7 -1
View File
@@ -14,7 +14,7 @@ product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "2.0.0-alpha2"
version = "2.0.0-alpha3"
out-dir = "../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
@@ -26,6 +26,11 @@ icons = [
"resources/icon.ico",
]
# Merged over the generated Info.plist; required for the speech input to
# request microphone and speech recognition access on macOS.
[package.metadata.packager.macos]
info_plist_path = "resources/Info.plist"
[dependencies]
assets = { path = "../crates/assets" }
workspace = { path = "../crates/workspace" }
@@ -33,6 +38,7 @@ ui = { path = "../crates/ui" }
state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" }
chat = { path = "../crates/chat" }
chat_ui = { path = "../crates/chat_ui" }
nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" }
auto_update = { path = "../crates/auto_update" }
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Dictate text into messages.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Turn your speech into text.</string>
</dict>
</plist>
+18 -18
View File
@@ -1,5 +1,6 @@
use std::sync::{Arc, Mutex};
use chat_ui::ToggleDictation;
use gpui::{
App, Bounds, Global, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions,
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
@@ -45,13 +46,19 @@ fn main() {
// Register the `quit` function
cx.on_action(quit);
// Register the `quit` function with CMD+Q (macOS)
// Register the `quit` function with CMD+Q (macOS) and dictation with CMD+D
#[cfg(target_os = "macos")]
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
cx.bind_keys([
KeyBinding::new("cmd-q", Quit, None),
KeyBinding::new("cmd-d", ToggleDictation, None),
]);
// Register the `quit` function with Super+Q (others)
// Register the `quit` function with Super+Q (others) and dictation with Ctrl+D
#[cfg(not(target_os = "macos"))]
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
cx.bind_keys([
KeyBinding::new("super-q", Quit, None),
KeyBinding::new("ctrl-d", ToggleDictation, None),
]);
// Set menu items
cx.set_menus(vec![Menu {
@@ -65,14 +72,13 @@ fn main() {
let appearance = settings::persisted_appearance_mode();
let font_size = settings::persisted_theme().font_size;
match apply_native_theme(appearance, font_size, cx) {
Ok(()) => start_theme_watcher(appearance, cx),
Err(error) => {
log::warn!("native theme unavailable ({error})");
apply_appearance(appearance, cx);
AppSettings::apply_theme_style(cx);
}
if let Err(error) = apply_native_theme(appearance, font_size, cx) {
log::warn!("native theme unavailable ({error})");
apply_appearance(appearance, cx);
AppSettings::apply_theme_style(cx);
}
start_theme_watcher(cx);
// Initialize the nostr client
state::init(cx, cli_key);
@@ -206,14 +212,12 @@ fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
/// Holds the watcher subscription so it lives as long as the app.
struct ThemeWatcher {
_subscription: ThemeSubscription,
/// The appearance mode the observer in `workspace` was registered with.
appearance: std::cell::Cell<AppearanceMode>,
}
impl Global for ThemeWatcher {}
/// Start a background task to watch for OS theme changes and re-apply the native theme.
fn start_theme_watcher(initial_appearance: AppearanceMode, cx: &mut App) {
fn start_theme_watcher(cx: &mut App) {
let (sender, receiver) = flume::unbounded();
match native_theme::watch::on_theme_change(move |event| {
if let Err(error) = sender.send(event) {
@@ -223,7 +227,6 @@ fn start_theme_watcher(initial_appearance: AppearanceMode, cx: &mut App) {
Ok(subscription) => {
cx.set_global(ThemeWatcher {
_subscription: subscription,
appearance: std::cell::Cell::new(initial_appearance),
});
cx.spawn(async move |cx| {
while receiver.recv_async().await.is_ok() {
@@ -231,9 +234,6 @@ fn start_theme_watcher(initial_appearance: AppearanceMode, cx: &mut App) {
cx.update(|cx| {
let appearance = AppSettings::get_appearance_mode(cx);
let font_size = AppSettings::get_theme(cx).font_size;
if let Some(watcher) = cx.try_global::<ThemeWatcher>() {
watcher.appearance.set(appearance);
}
if let Err(error) = apply_native_theme(appearance, font_size, cx) {
log::warn!("failed to apply updated OS theme ({error})");
}
-1
View File
@@ -3,7 +3,6 @@ channel = "1.98"
profile = "minimal"
components = ["rustfmt", "clippy"]
targets = [
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
+1
View File
@@ -14,6 +14,7 @@ ui = { path = "../crates/ui" }
state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" }
chat = { path = "../crates/chat" }
chat_ui = { path = "../crates/chat_ui" }
nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" }
person = { path = "../crates/person" }
+5 -5
View File
@@ -21,12 +21,12 @@ if [[ "$1" == "--release" ]]; then
echo -e "${YELLOW}Building in release mode${NC}"
fi
# wasm_thread requires a nightly compiler for its atomics features
CARGO="cargo +nightly"
NIGHTLY="nightly-2026-10-31"
CARGO="cargo +$NIGHTLY"
# Step 0: Toolchain prerequisites
echo -e "${GREEN}Step 0: Checking toolchain...${NC}"
rustup target add wasm32-unknown-unknown --toolchain nightly
rustup target add wasm32-unknown-unknown --toolchain "$NIGHTLY"
if [[ "$(uname)" == "Darwin" ]]; then
# secp256k1-sys compiles C for wasm; Apple clang has the wasm32 target and
@@ -35,7 +35,7 @@ if [[ "$(uname)" == "Darwin" ]]; then
echo -e "${RED}Apple clang not found. Install the Xcode command line tools: xcode-select --install${NC}"
exit 1
fi
rustup component add llvm-tools-preview --toolchain nightly
rustup component add llvm-tools-preview --toolchain "$NIGHTLY"
RUSTUP_HOME=$(rustup show home)
if [[ "$(uname -m)" == "arm64" ]]; then
@@ -43,7 +43,7 @@ if [[ "$(uname)" == "Darwin" ]]; then
else
HOST_ARCH="x86_64-apple-darwin"
fi
LLVM_BIN="$RUSTUP_HOME/toolchains/nightly-$HOST_ARCH/lib/rustlib/$HOST_ARCH/bin"
LLVM_BIN="$RUSTUP_HOME/toolchains/$NIGHTLY-$HOST_ARCH/lib/rustlib/$HOST_ARCH/bin"
export CC_wasm32_unknown_unknown=clang
export AR_wasm32_unknown_unknown="$LLVM_BIN/llvm-ar"
+5
View File
@@ -78,6 +78,11 @@ pub fn run() -> Result<(), JsValue> {
APPLICATION.with(|application| {
*application.borrow_mut() = Some(app.run_embedded(|cx| {
cx.bind_keys([
KeyBinding::new("cmd-d", chat_ui::ToggleDictation, None),
KeyBinding::new("ctrl-d", chat_ui::ToggleDictation, None),
]);
gpui_kit::init(cx);
load_embedded_fonts(cx);