Files
coop/crates/coop/src/main.rs
T
c188f12993 chore: Refine the UI (#102)
* update deps

* update window options

* linux title bar

* fix build

* .

* fix build

* rounded corners on linux

* .

* .

* fix i18n key

* fix change subject modal

* .

* update new account

* .

* update relay modal

* .

* fix i18n keys

---------

Co-authored-by: reya <reya@macbook.local>
2025-08-02 11:37:15 +07:00

597 lines
21 KiB
Rust

use std::collections::BTreeSet;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{anyhow, Error};
use assets::Assets;
use auto_update::AutoUpdater;
use global::constants::{
ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT,
METADATA_BATCH_TIMEOUT, NEW_MESSAGE_SUB_ID, SEARCH_RELAYS,
};
use global::{nostr_client, NostrSignal};
use gpui::{
actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
SharedString, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations,
WindowKind, WindowOptions,
};
use identity::Identity;
use nostr_sdk::prelude::*;
use registry::Registry;
use smol::channel::{self, Sender};
use theme::Theme;
use ui::Root;
pub(crate) mod chatspace;
pub(crate) mod views;
i18n::init!();
actions!(coop, [Quit]);
fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
// Initialize the Nostr Client
let client = nostr_client();
// Initialize the Application
let app = Application::new()
.with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
let (signal_tx, signal_rx) = channel::bounded::<NostrSignal>(2048);
let (mta_tx, mta_rx) = channel::bounded::<PublicKey>(1024);
let (event_tx, event_rx) = channel::unbounded::<Event>();
let signal_tx_clone = signal_tx.clone();
let mta_tx_clone = mta_tx.clone();
app.background_executor()
.spawn(async move {
// Subscribe for app updates from the bootstrap relays.
if let Err(e) = connect(client).await {
log::error!("Failed to connect to bootstrap relays: {e}");
}
// Connect to bootstrap relays.
if let Err(e) = subscribe_for_app_updates(client).await {
log::error!("Failed to subscribe for app updates: {e}");
}
// Handle Nostr notifications.
//
// Send the redefined signal back to GPUI via channel.
if let Err(e) =
handle_nostr_notifications(client, &signal_tx_clone, &mta_tx_clone, &event_tx).await
{
log::error!("Failed to handle Nostr notifications: {e}");
}
})
.detach();
app.background_executor()
.spawn(async move {
let duration = Duration::from_millis(METADATA_BATCH_TIMEOUT);
let mut batch: BTreeSet<PublicKey> = BTreeSet::new();
/// Internal events for the metadata batching system
enum BatchEvent {
NewKeys(PublicKey),
Timeout,
Closed,
}
loop {
let duration = smol::Timer::after(duration);
let recv = || async {
if let Ok(public_key) = mta_rx.recv().await {
BatchEvent::NewKeys(public_key)
} else {
BatchEvent::Closed
}
};
let timeout = || async {
duration.await;
BatchEvent::Timeout
};
match smol::future::or(recv(), timeout()).await {
BatchEvent::NewKeys(public_key) => {
batch.insert(public_key);
// Process immediately if batch limit reached
if batch.len() >= METADATA_BATCH_LIMIT {
sync_data_for_pubkeys(client, std::mem::take(&mut batch)).await;
}
}
BatchEvent::Timeout => {
if !batch.is_empty() {
sync_data_for_pubkeys(client, std::mem::take(&mut batch)).await;
}
}
BatchEvent::Closed => {
if !batch.is_empty() {
sync_data_for_pubkeys(client, std::mem::take(&mut batch)).await;
}
break;
}
}
}
})
.detach();
app.background_executor()
.spawn(async move {
let mut counter = 0;
loop {
// Signer is unset, probably user is not ready to retrieve gift wrap events
if client.signer().await.is_err() {
continue;
}
let duration = smol::Timer::after(Duration::from_secs(30));
let recv = || async {
// prevent inline format
(event_rx.recv().await).ok()
};
let timeout = || async {
duration.await;
None
};
match smol::future::or(recv(), timeout()).await {
Some(event) => {
// Process the gift wrap event unwrapping
let is_cached =
try_unwrap_event(client, &signal_tx, &mta_tx, &event, false).await;
// Increment the total messages counter if message is not from cache
if !is_cached {
counter += 1;
}
// Send partial finish signal to GPUI
if counter >= 20 {
signal_tx.send(NostrSignal::PartialFinish).await.ok();
// Reset counter
counter = 0;
}
}
None => {
signal_tx.send(NostrSignal::Finish).await.ok();
break;
}
}
}
// Event channel is no longer needed when all gift wrap events have been processed
event_rx.close();
})
.detach();
// Run application
app.run(move |cx| {
// Load embedded fonts in assets/fonts
load_embedded_fonts(cx);
// Register the `quit` function
cx.on_action(quit);
// Register the `quit` function with CMD+Q (macOS)
#[cfg(target_os = "macos")]
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
// Register the `quit` function with Super+Q (others)
#[cfg(not(target_os = "macos"))]
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
// Set menu items
cx.set_menus(vec![Menu {
name: "Coop".into(),
items: vec![MenuItem::action("Quit", Quit)],
}]);
// Set up the window bounds
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
// Set up the window options
let opts = WindowOptions {
window_background: WindowBackgroundAppearance::Opaque,
window_decorations: Some(WindowDecorations::Client),
window_bounds: Some(WindowBounds::Windowed(bounds)),
window_min_size: Some(size(px(800.0), px(600.0))),
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
..Default::default()
};
// Open a window with default options
cx.open_window(opts, |window, cx| {
// Automatically sync theme with system appearance
window
.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
})
.detach();
// Root Entity
cx.new(|cx| {
cx.activate(true);
// Initialize the tokio runtime
gpui_tokio::init(cx);
// Initialize components
ui::init(cx);
// Initialize app registry
registry::init(cx);
// Initialize settings
settings::init(cx);
// Initialize client keys
client_keys::init(cx);
// Initialize identity
identity::init(window, cx);
// Initialize auto update
auto_update::init(cx);
// Spawn a task to handle events from nostr channel
cx.spawn_in(window, async move |_, cx| {
let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
while let Ok(signal) = signal_rx.recv().await {
cx.update(|window, cx| {
let registry = Registry::global(cx);
let auto_updater = AutoUpdater::global(cx);
let identity = Identity::read_global(cx);
match signal {
// Load chat rooms and stop the loading status
NostrSignal::Finish => {
registry.update(cx, |this, cx| {
this.load_rooms(window, cx);
this.set_loading(false, cx);
});
}
// Load chat rooms without setting as finished
NostrSignal::PartialFinish => {
registry.update(cx, |this, cx| {
this.load_rooms(window, cx);
});
}
// Load chat rooms without setting as finished
NostrSignal::Eose(subscription_id) => {
// Only load chat rooms if the subscription ID matches the all_messages_sub_id
if subscription_id == all_messages_sub_id {
registry.update(cx, |this, cx| {
this.load_rooms(window, cx);
});
}
}
// Add the new metadata to the registry or update the existing one
NostrSignal::Metadata(event) => {
registry.update(cx, |this, cx| {
this.insert_or_update_person(event, cx);
});
}
// Convert the gift wrapped message to a message
NostrSignal::GiftWrap(event) => {
if let Some(public_key) = identity.public_key() {
registry.update(cx, |this, cx| {
this.event_to_message(public_key, event, window, cx);
});
}
}
NostrSignal::Notice(_msg) => {
// window.push_notification(msg, cx);
}
NostrSignal::AppUpdate(event) => {
auto_updater.update(cx, |this, cx| {
this.update(event, cx);
});
}
};
})
.ok();
}
})
.detach();
Root::new(chatspace::init(window, cx).into(), window, cx)
})
})
.expect("Failed to open window. Please restart the application.");
});
}
fn load_embedded_fonts(cx: &App) {
let asset_source = cx.asset_source();
let font_paths = asset_source.list("fonts").unwrap();
let embedded_fonts = Mutex::new(Vec::new());
let executor = cx.background_executor();
executor.block(executor.scoped(|scope| {
for font_path in &font_paths {
if !font_path.ends_with(".ttf") {
continue;
}
scope.spawn(async {
let font_bytes = asset_source.load(font_path).unwrap().unwrap();
embedded_fonts.lock().unwrap().push(font_bytes);
});
}
}));
cx.text_system()
.add_fonts(embedded_fonts.into_inner().unwrap())
.unwrap();
}
fn quit(_: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
}
async fn connect(client: &Client) -> Result<(), Error> {
for relay in BOOTSTRAP_RELAYS.into_iter() {
client.add_relay(relay).await?;
}
log::info!("Connected to bootstrap relays");
for relay in SEARCH_RELAYS.into_iter() {
client.add_relay(relay).await?;
}
log::info!("Connected to search relays");
// Establish connection to relays
client.connect().await;
Ok(())
}
async fn handle_nostr_notifications(
client: &Client,
signal_tx: &Sender<NostrSignal>,
mta_tx: &Sender<PublicKey>,
event_tx: &Sender<Event>,
) -> Result<(), Error> {
let new_messages_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let mut notifications = client.notifications();
let mut processed_events: BTreeSet<EventId> = BTreeSet::new();
let mut processed_dm_relays: BTreeSet<PublicKey> = BTreeSet::new();
while let Ok(notification) = notifications.recv().await {
let RelayPoolNotification::Message { message, .. } = notification else {
continue;
};
match message {
RelayMessage::Event {
event,
subscription_id,
} => {
if processed_events.contains(&event.id) {
continue;
}
// Skip events that have already been processed
processed_events.insert(event.id);
match event.kind {
Kind::GiftWrap => {
if *subscription_id == new_messages_sub_id {
let event = event.as_ref();
_ = try_unwrap_event(client, signal_tx, mta_tx, event, false).await;
} else {
event_tx.send(event.into_owned()).await.ok();
}
}
Kind::Metadata => {
signal_tx
.send(NostrSignal::Metadata(event.into_owned()))
.await
.ok();
}
Kind::ContactList => {
if let Ok(true) = check_author(client, &event).await {
for public_key in event.tags.public_keys().copied() {
mta_tx.send(public_key).await.ok();
}
}
}
Kind::RelayList => {
if processed_dm_relays.contains(&event.pubkey) {
continue;
}
// Skip public keys that have already been processed
processed_dm_relays.insert(event.pubkey);
let filter = Filter::new()
.author(event.pubkey)
.kind(Kind::InboxRelays)
.limit(1);
if let Ok(output) = client.subscribe(filter, Some(opts)).await {
log::info!(
"Subscribed to get DM relays: {} - Relays: {:?}",
event.pubkey.to_bech32().unwrap(),
output.success
)
}
}
Kind::ReleaseArtifactSet => {
let ids = event.tags.event_ids().copied();
let filter = Filter::new().ids(ids).kind(Kind::FileMetadata);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
.ok();
signal_tx
.send(NostrSignal::AppUpdate(event.into_owned()))
.await
.ok();
}
_ => {}
}
}
RelayMessage::EndOfStoredEvents(subscription_id) => {
signal_tx
.send(NostrSignal::Eose(subscription_id.into_owned()))
.await?;
}
_ => {}
}
}
Ok(())
}
async fn subscribe_for_app_updates(client: &Client) -> Result<(), Error> {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let coordinate = Coordinate {
kind: Kind::Custom(32267),
public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"),
identifier: APP_ID.into(),
};
let filter = Filter::new()
.kind(Kind::ReleaseArtifactSet)
.coordinate(&coordinate)
.limit(1);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
Ok(())
}
async fn check_author(client: &Client, event: &Event) -> Result<bool, Error> {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
Ok(public_key == event.pubkey)
}
async fn sync_data_for_pubkeys(client: &Client, public_keys: BTreeSet<PublicKey>) {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList];
let filter = Filter::new()
.limit(public_keys.len() * kinds.len())
.authors(public_keys)
.kinds(kinds);
if let Err(e) = client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
{
log::error!("Failed to sync metadata: {e}");
}
}
/// Stores an unwrapped event in local database with reference to original
async fn set_unwrapped(client: &Client, root: EventId, event: &Event) -> Result<(), Error> {
// Must be use the random generated keys to sign this event
let event = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
.tags(vec![Tag::identifier(root), Tag::event(root)])
.sign(&Keys::generate())
.await?;
// Only save this event into the local database
client.database().save_event(&event).await?;
Ok(())
}
/// Retrieves a previously unwrapped event from local database
async fn get_unwrapped(client: &Client, target: EventId) -> Result<Event, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(target)
.event(target)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
Ok(Event::from_json(event.content)?)
} else {
Err(anyhow!("Event is not cached yet"))
}
}
/// Unwraps a gift-wrapped event and processes its contents.
///
/// # Arguments
/// * `event` - The gift-wrapped event to unwrap
/// * `incoming` - Whether this is a newly received event (true) or old
///
/// # Returns
/// Returns `true` if the event was successfully loaded from cache or saved after unwrapping.
async fn try_unwrap_event(
client: &Client,
signal_tx: &Sender<NostrSignal>,
mta_tx: &Sender<PublicKey>,
event: &Event,
incoming: bool,
) -> bool {
let mut is_cached = false;
let event = match get_unwrapped(client, event.id).await {
Ok(event) => {
is_cached = true;
event
}
Err(_) => {
match client.unwrap_gift_wrap(event).await {
Ok(unwrap) => {
let Ok(unwrapped) = unwrap.rumor.sign_with_keys(&Keys::generate()) else {
return false;
};
// Save this event to the database for future use.
if let Err(e) = set_unwrapped(client, event.id, &unwrapped).await {
log::error!("Failed to save event: {e}")
}
unwrapped
}
Err(_) => return false,
}
}
};
// Save the event to the database, use for query directly.
if let Err(e) = client.database().save_event(&event).await {
log::error!("Failed to save event: {e}")
}
// Send all pubkeys to the batch to sync metadata
mta_tx.send(event.pubkey).await.ok();
for public_key in event.tags.public_keys().copied() {
mta_tx.send(public_key).await.ok();
}
// Send a notify to GPUI if this is a new message
if incoming {
signal_tx.send(NostrSignal::GiftWrap(event)).await.ok();
}
is_cached
}