diff --git a/package.json b/package.json index e9271fb..e643067 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "@phosphor-icons/react": "^2.1.7", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0", + "@tanstack/query-sync-storage-persister": "^5.51.15", "@tanstack/react-query": "^5.51.11", + "@tanstack/react-query-persist-client": "^5.51.15", "@tanstack/react-router": "^1.45.8", "@tauri-apps/api": ">=2.0.0-beta.0", "@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.5", @@ -21,6 +23,7 @@ "@tauri-apps/plugin-os": "2.0.0-beta.7", "@tauri-apps/plugin-shell": ">=2.0.0-beta.0", "dayjs": "^1.11.12", + "lru-cache": "^11.0.0", "minidenticons": "^4.2.1", "nostr-tools": "^2.7.1", "react": "19.0.0-rc-d025ddd3-20240722", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b97caf..dea387b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,15 @@ importers: '@radix-ui/react-scroll-area': specifier: ^1.1.0 version: 1.1.0(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@tanstack/query-sync-storage-persister': + specifier: ^5.51.15 + version: 5.51.15 '@tanstack/react-query': specifier: ^5.51.11 version: 5.51.11(react@19.0.0-rc-d025ddd3-20240722) + '@tanstack/react-query-persist-client': + specifier: ^5.51.15 + version: 5.51.15(@tanstack/react-query@5.51.11(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722) '@tanstack/react-router': specifier: ^1.45.8 version: 1.45.8(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722) @@ -41,6 +47,9 @@ importers: dayjs: specifier: ^1.11.12 version: 1.11.12 + lru-cache: + specifier: ^11.0.0 + version: 11.0.0 minidenticons: specifier: ^4.2.1 version: 4.2.1 @@ -696,9 +705,24 @@ packages: resolution: {integrity: sha512-n4XXInV9irIq0obRvINIkESkGk280Q+xkIIbswmM0z9nAu2wsIRZNvlmPrtYh6bgNWtItOWWoihFUjLTW8g6Jg==} engines: {node: '>=12'} + '@tanstack/query-core@5.51.15': + resolution: {integrity: sha512-xyobHDJ0yhPE3+UkSQ2/4X1fLSg7ICJI5J1JyU9yf7F3deQfEwSImCDrB1WSRrauJkMtXW7YIEcC0oA6ZZWt5A==} + '@tanstack/query-core@5.51.9': resolution: {integrity: sha512-HsAwaY5J19MD18ykZDS3aVVh+bAt0i7m6uQlFC2b77DLV9djo+xEN7MWQAQQTR8IM+7r/zbozTQ7P0xr0bHuew==} + '@tanstack/query-persist-client-core@5.51.15': + resolution: {integrity: sha512-y/gsUFINiVkxV06Jz5TqFNrXbKs81lIfj7PDamheEk+vWTmlBKTh0rMXthoDdUIVm4JO0+No9CXYahRSm683pg==} + + '@tanstack/query-sync-storage-persister@5.51.15': + resolution: {integrity: sha512-Jrqqv4zBQQmfkuArsx++JQfKi1nobFGry9T702c4h80jA01MGnAd2Eev6uG7mRYor4vnUzjP1CrGS+xzRq5oMQ==} + + '@tanstack/react-query-persist-client@5.51.15': + resolution: {integrity: sha512-SxSfGc9JggTUzv/dwIirKdMOe47OAdT0LBaVrkowI9QdVxJByjo+uaPCXZLoTlkaclvzFxd4G3YmmjsHXV/33A==} + peerDependencies: + '@tanstack/react-query': ^5.51.15 + react: ^18 || ^19 + '@tanstack/react-query@5.51.11': resolution: {integrity: sha512-4Kq2x0XpDlpvSnaLG+8pHNH60zEc3mBvb3B2tOMDjcPCi/o+Du3p/9qpPLwJOTliVxxPJAP27fuIhLrsRdCr7A==} peerDependencies: @@ -1163,6 +1187,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.0.0: + resolution: {integrity: sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2074,8 +2102,25 @@ snapshots: '@tanstack/history@1.45.3': {} + '@tanstack/query-core@5.51.15': {} + '@tanstack/query-core@5.51.9': {} + '@tanstack/query-persist-client-core@5.51.15': + dependencies: + '@tanstack/query-core': 5.51.15 + + '@tanstack/query-sync-storage-persister@5.51.15': + dependencies: + '@tanstack/query-core': 5.51.15 + '@tanstack/query-persist-client-core': 5.51.15 + + '@tanstack/react-query-persist-client@5.51.15(@tanstack/react-query@5.51.11(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)': + dependencies: + '@tanstack/query-persist-client-core': 5.51.15 + '@tanstack/react-query': 5.51.11(react@19.0.0-rc-d025ddd3-20240722) + react: 19.0.0-rc-d025ddd3-20240722 + '@tanstack/react-query@5.51.11(react@19.0.0-rc-d025ddd3-20240722)': dependencies: '@tanstack/query-core': 5.51.9 @@ -2536,6 +2581,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.0.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7e94d8f..e27570c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -593,7 +593,7 @@ dependencies = [ [[package]] name = "border" version = "0.1.0" -source = "git+https://github.com/ahkohd/tauri-toolkit?branch=v2#bb267e4d34b34fc01a94a60a14579fb6cdd2a256" +source = "git+https://github.com/ahkohd/tauri-toolkit?branch=v2#4ca90fd2a56565af26aeb5d0518d2e85d80a35b0" dependencies = [ "cocoa", "color", @@ -868,7 +868,7 @@ dependencies = [ [[package]] name = "color" version = "0.1.0" -source = "git+https://github.com/ahkohd/tauri-toolkit?branch=v2#bb267e4d34b34fc01a94a60a14579fb6cdd2a256" +source = "git+https://github.com/ahkohd/tauri-toolkit?branch=v2#4ca90fd2a56565af26aeb5d0518d2e85d80a35b0" dependencies = [ "cocoa", "tauri", @@ -2874,7 +2874,7 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" [[package]] name = "nostr" version = "0.33.0" -source = "git+https://github.com/rust-nostr/nostr#66d4b5905c8722952644d914ca480b2c33fa1395" +source = "git+https://github.com/rust-nostr/nostr#52b4f439c55033a871f2d00aa916160bcd35f6ff" dependencies = [ "aes", "base64 0.21.7", @@ -2903,7 +2903,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.33.0" -source = "git+https://github.com/rust-nostr/nostr#66d4b5905c8722952644d914ca480b2c33fa1395" +source = "git+https://github.com/rust-nostr/nostr#52b4f439c55033a871f2d00aa916160bcd35f6ff" dependencies = [ "async-trait", "flatbuffers", @@ -2917,7 +2917,7 @@ dependencies = [ [[package]] name = "nostr-relay-pool" version = "0.33.0" -source = "git+https://github.com/rust-nostr/nostr#66d4b5905c8722952644d914ca480b2c33fa1395" +source = "git+https://github.com/rust-nostr/nostr#52b4f439c55033a871f2d00aa916160bcd35f6ff" dependencies = [ "async-utility", "async-wsocket", @@ -2932,7 +2932,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.33.0" -source = "git+https://github.com/rust-nostr/nostr#66d4b5905c8722952644d914ca480b2c33fa1395" +source = "git+https://github.com/rust-nostr/nostr#52b4f439c55033a871f2d00aa916160bcd35f6ff" dependencies = [ "async-utility", "atomic-destructor", @@ -2952,7 +2952,7 @@ dependencies = [ [[package]] name = "nostr-signer" version = "0.33.0" -source = "git+https://github.com/rust-nostr/nostr#66d4b5905c8722952644d914ca480b2c33fa1395" +source = "git+https://github.com/rust-nostr/nostr#52b4f439c55033a871f2d00aa916160bcd35f6ff" dependencies = [ "async-utility", "nostr", @@ -2965,7 +2965,7 @@ dependencies = [ [[package]] name = "nostr-sqlite" version = "0.33.0" -source = "git+https://github.com/rust-nostr/nostr#66d4b5905c8722952644d914ca480b2c33fa1395" +source = "git+https://github.com/rust-nostr/nostr#52b4f439c55033a871f2d00aa916160bcd35f6ff" dependencies = [ "async-trait", "nostr", @@ -2979,7 +2979,7 @@ dependencies = [ [[package]] name = "nostr-zapper" version = "0.33.0" -source = "git+https://github.com/rust-nostr/nostr#66d4b5905c8722952644d914ca480b2c33fa1395" +source = "git+https://github.com/rust-nostr/nostr#52b4f439c55033a871f2d00aa916160bcd35f6ff" dependencies = [ "async-trait", "nostr", @@ -3099,7 +3099,7 @@ dependencies = [ [[package]] name = "nwc" version = "0.33.0" -source = "git+https://github.com/rust-nostr/nostr#66d4b5905c8722952644d914ca480b2c33fa1395" +source = "git+https://github.com/rust-nostr/nostr#52b4f439c55033a871f2d00aa916160bcd35f6ff" dependencies = [ "async-utility", "nostr", @@ -4719,6 +4719,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.72", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5095,12 +5117,17 @@ dependencies = [ [[package]] name = "tauri-plugin-prevent-default" -version = "0.1.12" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38be0ac8fcc5fa03422409fc506015b01dc29cc8a3a72572c68d41e6f10f4491" +checksum = "c76ff4be3c049bac253390c06f26c805a74b58722f8be97f99550213036df949" dependencies = [ "bitflags 2.6.0", + "itertools 0.13.0", + "serde", + "strum", "tauri", + "tauri-plugin", + "thiserror", ] [[package]] @@ -5357,9 +5384,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.39.1" +version = "1.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d040ac2b29ab03b09d4129c2f5bbd012a3ac2f79d38ff506a4bf8dd34b0eac8a" +checksum = "daa4fb1bc778bd6f04cbfc4bb2d06a7396a8f299dc33ea1900cedaa316f467b1" dependencies = [ "backtrace", "bytes", @@ -5407,9 +5434,9 @@ dependencies = [ [[package]] name = "tokio-socks" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51165dfa029d2a65969413a6cc96f354b86b464498702f174a4efa13608fd8c0" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" dependencies = [ "either", "futures-util", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d9998bc..c2346df 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ tauri-specta = { git = "https://github.com/reyamir/tauri-specta", branch = "feat "typescript", ] } tauri-plugin-devtools = "2.0.0-beta" -tauri-plugin-prevent-default = "0.1" +tauri-plugin-prevent-default = "0.2" tauri-plugin-os = "2.0.0-beta" tauri-plugin-clipboard-manager = "2.0.0-beta" tauri-plugin-dialog = "2.0.0-beta" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 36bba74..056f9dc 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,29 +1,30 @@ { - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "Capability for the main window", - "windows": [ - "main" - ], - "permissions": [ - "path:default", - "event:default", - "window:default", - "app:default", - "image:default", - "resources:default", - "menu:default", - "tray:default", - "shell:allow-open", - "dialog:default", - "dialog:allow-open", - "window:allow-close", - "window:allow-center", - "window:allow-minimize", - "window:allow-maximize", - "window:allow-set-size", - "window:allow-set-focus", - "window:allow-start-dragging", - "decorum:allow-show-snap-overlay" - ] + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": [ + "main" + ], + "permissions": [ + "path:default", + "event:default", + "window:default", + "app:default", + "image:default", + "resources:default", + "menu:default", + "tray:default", + "shell:allow-open", + "dialog:default", + "dialog:allow-open", + "window:allow-close", + "window:allow-center", + "window:allow-minimize", + "window:allow-maximize", + "window:allow-set-size", + "window:allow-set-focus", + "window:allow-start-dragging", + "decorum:allow-show-snap-overlay", + "prevent-default:default" + ] } diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index ec86ec9..ded6433 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -32,7 +32,7 @@ pub async fn get_metadata(id: String, state: State<'_, Nostr>) -> Result { if let Some(event) = events.first() { Ok(Metadata::from_json(&event.content).unwrap_or(Metadata::new()).as_json()) @@ -149,7 +149,6 @@ pub async fn login( state: State<'_, Nostr>, handle: tauri::AppHandle, ) -> Result { - let app = handle.app_handle().clone(); let client = &state.client; let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?; let hex = public_key.to_hex(); @@ -187,62 +186,65 @@ pub async fn login( } let inbox = Filter::new().kind(Kind::Custom(10050)).author(public_key).limit(1); - let old = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); - let new = Filter::new().kind(Kind::GiftWrap).pubkey(public_key).limit(0); - - let mut relays = Vec::new(); if let Ok(events) = client.get_events_of(vec![inbox], None).await { if let Some(event) = events.into_iter().next() { - for tag in &event.tags { - if let Some(TagStandard::Relay(relay)) = tag.as_standardized() { - let url = relay.to_string(); - if client.add_relay(&url).await.is_ok() { - relays.push(url) + let urls = event + .tags() + .iter() + .filter_map(|tag| { + if let Some(TagStandard::Relay(relay)) = tag.as_standardized() { + Some(relay.to_string()) + } else { + None } - } + }) + .collect::>(); + + for url in urls.iter() { + let _ = client.add_relay(url).await; + let _ = client.connect_relay(url).await; } + + let mut inbox_relays = state.inbox_relays.lock().await; + inbox_relays.insert(public_key, urls); } } - if client.reconcile_with(relays.clone(), old, NegentropyOptions::default()).await.is_ok() { - handle.emit("synchronized", ()).unwrap(); - }; + let new_message = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + let subscription_id = SubscriptionId::new("personal_inbox"); + let _ = client.subscribe_with_id(subscription_id, vec![new_message], None).await; - if client.subscribe_to(relays, vec![new], None).await.is_ok() { - println!("Waiting for new message...") - }; + let handle_clone = handle.app_handle().clone(); tauri::async_runtime::spawn(async move { - let window = app.get_webview_window("main").expect("Window is terminated."); + let window = handle_clone.get_webview_window("main").expect("Window is terminated."); let state = window.state::(); let client = &state.client; - // Workaround for https://github.com/rust-nostr/nostr/issues/509 - // TODO: remove - let _ = client.get_events_of(vec![Filter::new().kind(Kind::TextNote).limit(0)], None).await; + let sync = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + let _ = client.reconcile(sync, NegentropyOptions::default()).await; + }); + + tauri::async_runtime::spawn(async move { + let window = handle.get_webview_window("main").expect("Window is terminated."); + let state = window.state::(); + let client = &state.client; client .handle_notifications(|notification| async { - if let RelayPoolNotification::Message { message, relay_url } = notification { - if let RelayMessage::Event { event, .. } = message { - if event.kind == Kind::GiftWrap { - match client.unwrap_gift_wrap(&event).await { - Ok(UnwrappedGift { rumor, sender }) => { - let payload = - Payload { event: rumor.as_json(), sender: sender.to_hex() }; - - if window.emit("event", payload).is_err() { - println!("Failed") - } - } - Err(e) => { - println!("Unwrapped Error: {} from {}", e, relay_url) - } + if let RelayPoolNotification::Event { event, .. } = notification { + if event.kind == Kind::GiftWrap { + if let Ok(UnwrappedGift { rumor, sender }) = + client.unwrap_gift_wrap(&event).await + { + if let Err(e) = window.emit( + "event", + Payload { event: rumor.as_json(), sender: sender.to_hex() }, + ) { + println!("emit failed: {}", e) } } - } else { - println!("message: {}", message.as_json()) } } Ok(false) diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs index f0bb197..48a19c9 100644 --- a/src-tauri/src/commands/chat.rs +++ b/src-tauri/src/commands/chat.rs @@ -1,47 +1,66 @@ -use std::{cmp::Reverse, time::Duration}; - use futures::stream::{self, StreamExt}; use itertools::Itertools; use nostr_sdk::prelude::*; +use std::{cmp::Reverse, time::Duration}; use tauri::State; -use crate::{common::is_target, Nostr}; +use crate::{common::is_member, Nostr}; #[tauri::command] #[specta::specta] -pub async fn get_chats(state: State<'_, Nostr>) -> Result, String> { +pub async fn get_chats(db_only: bool, state: State<'_, Nostr>) -> Result, String> { let client = &state.client; - let signer = client.signer().await.expect("Unexpected"); - let public_key = signer.public_key().await.expect("Unexpected"); + let signer = client.signer().await.map_err(|e| e.to_string())?; + let public_key = signer.public_key().await.map_err(|e| e.to_string())?; let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); - match client.get_events_of(vec![filter], None).await { - Ok(events) => { - let rumors = stream::iter(events) - .filter_map(|ev| async move { - if let Ok(UnwrappedGift { rumor, .. }) = client.unwrap_gift_wrap(&ev).await { - if rumor.kind == Kind::PrivateDirectMessage { - return Some(rumor); + let events = match db_only { + true => match client.database().query(vec![filter], Order::Desc).await { + Ok(events) => { + stream::iter(events) + .filter_map(|ev| async move { + if let Ok(UnwrappedGift { rumor, .. }) = client.unwrap_gift_wrap(&ev).await + { + if rumor.kind == Kind::PrivateDirectMessage { + return Some(rumor); + } } - } - None - }) - .collect::>() - .await; + None + }) + .collect::>() + .await + } + Err(err) => return Err(err.to_string()), + }, + false => match client.get_events_of(vec![filter], Some(Duration::from_secs(12))).await { + Ok(events) => { + stream::iter(events) + .filter_map(|ev| async move { + if let Ok(UnwrappedGift { rumor, .. }) = client.unwrap_gift_wrap(&ev).await + { + if rumor.kind == Kind::PrivateDirectMessage { + return Some(rumor); + } + } + None + }) + .collect::>() + .await + } + Err(err) => return Err(err.to_string()), + }, + }; - let uniqs = rumors - .into_iter() - .sorted_by_key(|ev| Reverse(ev.created_at)) - .filter(|ev| ev.pubkey != public_key) - .unique_by(|ev| ev.pubkey) - .map(|ev| ev.as_json()) - .collect::>(); + let uniqs = events + .into_iter() + .filter(|ev| ev.pubkey != public_key) + .unique_by(|ev| ev.pubkey) + .sorted_by_key(|ev| Reverse(ev.created_at)) + .map(|ev| ev.as_json()) + .collect::>(); - Ok(uniqs) - } - Err(err) => Err(err.to_string()), - } + Ok(uniqs) } #[tauri::command] @@ -49,20 +68,21 @@ pub async fn get_chats(state: State<'_, Nostr>) -> Result, String> { pub async fn get_chat_messages(id: String, state: State<'_, Nostr>) -> Result, String> { let client = &state.client; let signer = client.signer().await.map_err(|e| e.to_string())?; + let receiver_pk = signer.public_key().await.map_err(|e| e.to_string())?; let sender_pk = PublicKey::parse(id).map_err(|e| e.to_string())?; - let filter = Filter::new().kind(Kind::GiftWrap).pubkeys(vec![sender_pk, receiver_pk]); + let filter = Filter::new().kind(Kind::GiftWrap).pubkeys(vec![receiver_pk, sender_pk]); - match client.get_events_of(vec![filter], None).await { + let rumors = match client.get_events_of(vec![filter], Some(Duration::from_secs(10))).await { Ok(events) => { - let rumors = stream::iter(events) + stream::iter(events) .filter_map(|ev| async move { if let Ok(UnwrappedGift { rumor, sender }) = client.unwrap_gift_wrap(&ev).await { - let is_target = is_target(&sender_pk, &rumor.tags); - let is_member = sender == sender_pk; - if rumor.kind == Kind::PrivateDirectMessage && (is_member || is_target) { + let groups = vec![&receiver_pk, &sender_pk]; + + if groups.contains(&&sender) && is_member(groups, &rumor.tags) { Some(rumor.as_json()) } else { None @@ -72,44 +92,12 @@ pub async fn get_chat_messages(id: String, state: State<'_, Nostr>) -> Result>() - .await; - - Ok(rumors) + .await } - Err(e) => Err(e.to_string()), - } -} - -#[tauri::command] -#[specta::specta] -pub async fn subscribe_to( - id: String, - relays: Vec, - state: State<'_, Nostr>, -) -> Result<(), String> { - let client = &state.client; - let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?; - - let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key).limit(0); - let subscription_id = SubscriptionId::new(&id[..6]); - - if client.subscribe_with_id_to(relays, subscription_id, vec![filter], None).await.is_ok() { - println!("Watching ... {}", id) + Err(e) => return Err(e.to_string()), }; - Ok(()) -} - -#[tauri::command] -#[specta::specta] -pub async fn unsubscribe(id: String, state: State<'_, Nostr>) -> Result<(), ()> { - let client = &state.client; - let subscription_id = SubscriptionId::new(&id[..6]); - - client.unsubscribe(subscription_id).await; - println!("Unwatching ... {}", id); - - Ok(()) + Ok(rumors) } #[tauri::command] @@ -117,6 +105,7 @@ pub async fn unsubscribe(id: String, state: State<'_, Nostr>) -> Result<(), ()> pub async fn get_inboxes(id: String, state: State<'_, Nostr>) -> Result, String> { let client = &state.client; let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?; + let inbox = Filter::new().kind(Kind::Custom(10050)).author(public_key).limit(1); match client.get_events_of(vec![inbox], Some(Duration::from_secs(2))).await { @@ -125,12 +114,17 @@ pub async fn get_inboxes(id: String, state: State<'_, Nostr>) -> Result) -> Result, state: State<'_, Nostr>, ) -> Result<(), String> { let client = &state.client; + + let signer = client.signer().await.map_err(|e| e.to_string())?; + let public_key = signer.public_key().await.map_err(|e| e.to_string())?; let receiver = PublicKey::parse(&to).map_err(|e| e.to_string())?; - match client.send_private_msg_to(relays, receiver, message, None).await { - Ok(output) => { - println!("send to: {}", output.success.iter().join(", ")); + // TODO: Add support reply_to + let rumor = EventBuilder::private_msg_rumor(receiver, message, None); + + // Get inbox relays + let relays = state.inbox_relays.lock().await; + + let outbox = relays.get(&receiver); + let inbox = relays.get(&public_key); + + let outbox_urls = match outbox { + Some(relays) => relays, + None => return Err("User's didn't have inbox relays to receive message.".into()), + }; + + let inbox_urls = match inbox { + Some(relays) => relays, + None => return Err("User's didn't have inbox relays to receive message.".into()), + }; + + match client.gift_wrap_to(outbox_urls, receiver, rumor.clone(), None).await { + Ok(_) => { + if let Err(e) = client.gift_wrap_to(inbox_urls, public_key, rumor, None).await { + return Err(e.to_string()); + } + Ok(()) } Err(e) => Err(e.to_string()), diff --git a/src-tauri/src/common.rs b/src-tauri/src/common.rs index f952db6..1ff394e 100644 --- a/src-tauri/src/common.rs +++ b/src-tauri/src/common.rs @@ -1,9 +1,9 @@ use nostr_sdk::prelude::*; -pub fn is_target(target: &PublicKey, tags: &Vec) -> bool { +pub fn is_member(groups: Vec<&PublicKey>, tags: &Vec) -> bool { for tag in tags { if let Some(TagStandard::PublicKey { public_key, .. }) = tag.as_standardized() { - if public_key == target { + if groups.contains(&public_key) { return true; } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d0af5b2..22ff04d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,9 +3,8 @@ use border::WebviewWindowExt as WebviewWindowExtAlt; use nostr_sdk::prelude::*; -use serde::Serialize; -use std::{fs, sync::Mutex, time::Duration}; -use tauri::Manager; +use std::{collections::HashMap, fs, time::Duration}; +use tauri::{async_runtime::Mutex, Manager}; use tauri_plugin_decorum::WebviewWindowExt; use commands::{account::*, chat::*}; @@ -13,11 +12,9 @@ use commands::{account::*, chat::*}; mod commands; mod common; -#[derive(Serialize)] pub struct Nostr { - #[serde(skip_serializing)] client: Client, - contact_list: Mutex>, + inbox_relays: Mutex>>, } fn main() { @@ -29,12 +26,10 @@ fn main() { connect_account, get_accounts, get_metadata, - get_inboxes, get_chats, get_chat_messages, + get_inboxes, send_message, - subscribe_to, - unsubscribe, ]); #[cfg(debug_assertions)] @@ -50,6 +45,8 @@ fn main() { builder .setup(|app| { + let handle = app.handle(); + #[cfg(not(target_os = "linux"))] let main_window = app.get_webview_window("main").unwrap(); @@ -69,35 +66,35 @@ fn main() { #[cfg(target_os = "macos")] main_window.add_border(None); - tauri::async_runtime::block_on(async move { + let client = tauri::async_runtime::block_on(async move { // Create data folder if not exist - let dir = app.path().config_dir().expect("Config Directory not found."); + let dir = handle.path().config_dir().expect("Config Directory not found."); let _ = fs::create_dir_all(dir.join("Coop/")); // Setup database - let database = SQLiteDatabase::open(dir.join("Coop/coop.db")).await; - - // Config - let opts = Options::new() - .autoconnect(true) - .timeout(Duration::from_secs(5)) - .send_timeout(Some(Duration::from_secs(5))) - .connection_timeout(Some(Duration::from_secs(20))); + let database = + SQLiteDatabase::open(dir.join("Coop/coop.db")).await.expect("Error."); // Setup nostr client - let client = match database { - Ok(db) => ClientBuilder::default().opts(opts).database(db).build(), - Err(_) => ClientBuilder::default().opts(opts).build(), - }; + let opts = Options::new() + .timeout(Duration::from_secs(30)) + .send_timeout(Some(Duration::from_secs(5))) + .connection_timeout(Some(Duration::from_secs(5))); + + let client = ClientBuilder::default().opts(opts).database(database).build(); // Add bootstrap relay - let _ = - client.add_relays(["wss://relay.damus.io/", "wss://relay.nostr.net/"]).await; + let _ = client.add_relays(["wss://relay.damus.io", "wss://relay.nostr.net"]).await; - // Create global state - app.handle().manage(Nostr { client, contact_list: Mutex::new(vec![]) }) + // Connect + client.connect().await; + + client }); + // Create global state + app.manage(Nostr { client, inbox_relays: Mutex::new(HashMap::new()) }); + Ok(()) }) .enable_macos_default_menu(false) diff --git a/src/commands.ts b/src/commands.ts index b7ec936..38dd7c7 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -47,17 +47,9 @@ try { else return { status: "error", error: e as any }; } }, -async getInboxes(id: string) : Promise> { +async getChats(dbOnly: boolean) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("get_inboxes", { id }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async getChats() : Promise> { -try { - return { status: "ok", data: await TAURI_INVOKE("get_chats") }; + return { status: "ok", data: await TAURI_INVOKE("get_chats", { dbOnly }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -71,25 +63,17 @@ try { else return { status: "error", error: e as any }; } }, -async sendMessage(to: string, message: string, relays: string[]) : Promise> { +async getInboxes(id: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("send_message", { to, message, relays }) }; + return { status: "ok", data: await TAURI_INVOKE("get_inboxes", { id }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, -async subscribeTo(id: string, relays: string[]) : Promise> { +async sendMessage(to: string, message: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("subscribe_to", { id, relays }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async unsubscribe(id: string) : Promise> { -try { - return { status: "ok", data: await TAURI_INVOKE("unsubscribe", { id }) }; + return { status: "ok", data: await TAURI_INVOKE("send_message", { to, message }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; diff --git a/src/commons.ts b/src/commons.ts index dae9dcc..5bab285 100644 --- a/src/commons.ts +++ b/src/commons.ts @@ -75,19 +75,7 @@ export function getReceivers(tags: string[][]) { return p; } -export const useRelays = (id: string) => - useQuery({ - queryKey: ["relays", id], - queryFn: async () => { - const res = await commands.getInboxes(id); - - if (res.status === "ok") { - return res.data; - } else { - throw new Error(res.error); - } - }, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - }); +export function getChatId(pubkey: string, tags: string[][]) { + const id = [pubkey, tags.map((tag) => tag[0] === "p" && tag[1])].join("-"); + return id; +} diff --git a/src/components/user/provider.tsx b/src/components/user/provider.tsx index 06c391c..c655ecd 100644 --- a/src/components/user/provider.tsx +++ b/src/components/user/provider.tsx @@ -55,7 +55,6 @@ export function UserProvider({ refetchOnWindowFocus: false, refetchOnReconnect: false, staleTime: Number.POSITIVE_INFINITY, - retry: 2, }); return ( diff --git a/src/main.tsx b/src/main.tsx index 9033004..0cfdfbd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,16 +1,28 @@ +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; +import { QueryClient } from "@tanstack/react-query"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { RouterProvider, createRouter } from "@tanstack/react-router"; import { type } from "@tauri-apps/plugin-os"; import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import "./app.css"; - -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // Import the generated route tree import { routeTree } from "./routes.gen"; -// Create a new router instance -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1000 * 60 * 60 * 24, + }, + }, +}); + +const persister = createSyncStoragePersister({ + storage: window.localStorage, +}); + const platform = type(); + const router = createRouter({ routeTree, context: { @@ -32,9 +44,12 @@ if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( - + - + , ); } diff --git a/src/routes.gen.ts b/src/routes.gen.ts index 5a398ed..09dbf36 100644 --- a/src/routes.gen.ts +++ b/src/routes.gen.ts @@ -14,6 +14,7 @@ import { createFileRoute } from '@tanstack/react-router' import { Route as rootRoute } from './routes/__root' import { Route as IndexImport } from './routes/index' +import { Route as AccountChatsIdImport } from './routes/$account.chats.$id' // Create Virtual Routes @@ -23,7 +24,6 @@ const ImportKeyLazyImport = createFileRoute('/import-key')() const CreateAccountLazyImport = createFileRoute('/create-account')() const AccountChatsLazyImport = createFileRoute('/$account/chats')() const AccountChatsNewLazyImport = createFileRoute('/$account/chats/new')() -const AccountChatsIdLazyImport = createFileRoute('/$account/chats/$id')() // Create/Update Routes @@ -68,12 +68,10 @@ const AccountChatsNewLazyRoute = AccountChatsNewLazyImport.update({ import('./routes/$account.chats.new.lazy').then((d) => d.Route), ) -const AccountChatsIdLazyRoute = AccountChatsIdLazyImport.update({ +const AccountChatsIdRoute = AccountChatsIdImport.update({ path: '/$id', getParentRoute: () => AccountChatsLazyRoute, -} as any).lazy(() => - import('./routes/$account.chats.$id.lazy').then((d) => d.Route), -) +} as any) // Populate the FileRoutesByPath interface @@ -125,7 +123,7 @@ declare module '@tanstack/react-router' { id: '/$account/chats/$id' path: '/$id' fullPath: '/$account/chats/$id' - preLoaderRoute: typeof AccountChatsIdLazyImport + preLoaderRoute: typeof AccountChatsIdImport parentRoute: typeof AccountChatsLazyImport } '/$account/chats/new': { @@ -147,7 +145,7 @@ export const routeTree = rootRoute.addChildren({ NewLazyRoute, NostrConnectLazyRoute, AccountChatsLazyRoute: AccountChatsLazyRoute.addChildren({ - AccountChatsIdLazyRoute, + AccountChatsIdRoute, AccountChatsNewLazyRoute, }), }) @@ -191,7 +189,7 @@ export const routeTree = rootRoute.addChildren({ ] }, "/$account/chats/$id": { - "filePath": "$account.chats.$id.lazy.tsx", + "filePath": "$account.chats.$id.tsx", "parent": "/$account/chats" }, "/$account/chats/new": { diff --git a/src/routes/$account.chats.$id.lazy.tsx b/src/routes/$account.chats.$id.tsx similarity index 74% rename from src/routes/$account.chats.$id.lazy.tsx rename to src/routes/$account.chats.$id.tsx index cdd64a2..6bef332 100644 --- a/src/routes/$account.chats.$id.lazy.tsx +++ b/src/routes/$account.chats.$id.tsx @@ -1,10 +1,11 @@ import { commands } from "@/commands"; -import { cn, getReceivers, time, useRelays } from "@/commons"; +import { cn, getReceivers, time } from "@/commons"; import { Spinner } from "@/components/spinner"; -import { ArrowUp, CloudArrowUp, Paperclip } from "@phosphor-icons/react"; +import { ArrowUp, Paperclip } from "@phosphor-icons/react"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { createLazyFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; +import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { message } from "@tauri-apps/plugin-dialog"; import type { NostrEvent } from "nostr-tools"; @@ -17,24 +18,27 @@ type Payload = { sender: string; }; -export const Route = createLazyFileRoute("/$account/chats/$id")({ +export const Route = createFileRoute("/$account/chats/$id")({ + beforeLoad: async ({ params }) => { + const inboxRelays: string[] = await invoke("get_inboxes", { + id: params.id, + }); + + return { inboxRelays }; + }, component: Screen, + pendingComponent: Pending, }); +function Pending() { + return ( +
+ +
+ ); +} + function Screen() { - const { id } = Route.useParams(); - const { isLoading, data: relays } = useRelays(id); - - useEffect(() => { - if (!isLoading && relays?.length) - commands.subscribeTo(id, relays).then(() => console.log("sub: ", id)); - - return () => { - if (!isLoading && relays?.length) - commands.unsubscribe(id).then(() => console.log("unsub: ", id)); - }; - }, [isLoading, relays]); - return (
@@ -46,7 +50,6 @@ function Screen() { function List() { const { account, id } = Route.useParams(); - const { isLoading: rl, isError: rE } = useRelays(id); const { isLoading, isError, data } = useQuery({ queryKey: ["chats", id], queryFn: async () => { @@ -63,7 +66,6 @@ function List() { throw new Error(res.error); } }, - enabled: !rl && !rE, refetchOnWindowFocus: false, }); @@ -120,11 +122,8 @@ function List() { await queryClient.setQueryData( ["chats", id], (prevEvents: NostrEvent[]) => { - if (!prevEvents) { - return prevEvents; - } + if (!prevEvents) return prevEvents; return [...prevEvents, event]; - // queryClient.invalidateQueries(['chats', id]); }, ); }); @@ -144,14 +143,34 @@ function List() { ref={ref} className="relative h-full py-2 [&>div]:!flex [&>div]:flex-col [&>div]:justify-end [&>div]:min-h-full" > - + {isLoading || !data ? ( -
-
- - Loading message... + <> +
+
+
+ Loading... +
+
+
+ + {time(Math.floor(Date.now() / 1000))} + +
-
+
+
+
+ Loading... +
+
+
+ + {time(Math.floor(Date.now() / 1000))} + +
+
+ ) : isError ? (
@@ -176,34 +195,32 @@ function List() { function Form() { const { id } = Route.useParams(); - const { isLoading, isError, data: relays } = useRelays(id); + const { inboxRelays } = Route.useRouteContext(); const [newMessage, setNewMessage] = useState(""); const [isPending, startTransition] = useTransition(); + // const queryClient = useQueryClient(); + const submit = async () => { startTransition(async () => { - if (newMessage.length < 1) return; + if (!newMessage.length) return; + if (!inboxRelays?.length) return; - const res = await commands.sendMessage(id, newMessage, relays); + const res = await commands.sendMessage(id, newMessage); - if (res.status === "ok") { - setNewMessage(""); - } else { + if (res.status === "error") { await message(res.error, { title: "Coop", kind: "error" }); return; } + + setNewMessage(""); }); }; return (
- {isLoading ? ( -
- - Connecting to inbox relays -
- ) : isError || !relays.length ? ( + {!inboxRelays.length ? (
This user doesn't have inbox relays. You cannot send messages to them.
@@ -216,12 +233,14 @@ function Form() { >
+ {/*
+ */}
{ - const res = await commands.getChats(); + const res = await commands.getChats(true); if (res.status === "ok") { const raw = res.data; @@ -76,21 +77,10 @@ function ChatList() { throw new Error(res.error); } }, + select: (data) => data.sort((a, b) => b.created_at - a.created_at), refetchOnWindowFocus: false, }); - const queryClient = useQueryClient(); - - useEffect(() => { - const unlisten = listen("synchronized", async () => { - await queryClient.refetchQueries({ queryKey: ["chats"] }); - }); - - return () => { - unlisten.then((f) => f()); - }; - }, []); - useEffect(() => { const unlisten = listen("event", async (data) => { const event: NostrEvent = JSON.parse(data.payload.event); @@ -127,9 +117,9 @@ function ChatList() { {isLoading ? (
- {Array.from(Array(5)).map((_, index) => ( + {[...Array(5).keys()].map((i) => (
@@ -137,12 +127,10 @@ function ChatList() {
))}
- ) : isError ? ( -
Error
) : ( data.map((item) => ( @@ -188,7 +176,7 @@ function CurrentUser() { const { account } = Route.useParams(); return ( -
+