feat: Multi Accounts (#237)

* wip: new sync

* wip: restructure routes

* update

* feat: improve sync

* feat: repost with multi-account

* feat: improve sync

* feat: publish with multi account

* fix: settings screen

* feat: add zap for multi accounts
This commit is contained in:
雨宮蓮
2024-10-22 16:00:06 +07:00
committed by GitHub
parent ba9c81a10a
commit cc7de41bfd
89 changed files with 2695 additions and 2911 deletions

View File

@@ -21,6 +21,7 @@
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/query-persist-client-core": "^5.59.0", "@tanstack/query-persist-client-core": "^5.59.0",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",
"@tanstack/react-query-devtools": "^5.59.15",
"@tanstack/react-router": "^1.63.5", "@tanstack/react-router": "^1.63.5",
"@tanstack/react-store": "^0.5.5", "@tanstack/react-store": "^0.5.5",
"@tanstack/store": "^0.5.5", "@tanstack/store": "^0.5.5",

20
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.59.0 specifier: ^5.59.0
version: 5.59.0(react@19.0.0-rc-d025ddd3-20240722) version: 5.59.0(react@19.0.0-rc-d025ddd3-20240722)
'@tanstack/react-query-devtools':
specifier: ^5.59.15
version: 5.59.15(@tanstack/react-query@5.59.0(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)
'@tanstack/react-router': '@tanstack/react-router':
specifier: ^1.63.5 specifier: ^1.63.5
version: 1.63.5(@tanstack/router-generator@1.63.5)(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722) version: 1.63.5(@tanstack/router-generator@1.63.5)(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)
@@ -1230,9 +1233,18 @@ packages:
'@tanstack/query-core@5.59.0': '@tanstack/query-core@5.59.0':
resolution: {integrity: sha512-WGD8uIhX6/deH/tkZqPNcRyAhDUqs729bWKoByYHSogcshXfFbppOdTER5+qY7mFvu8KEFJwT0nxr8RfPTVh0Q==} resolution: {integrity: sha512-WGD8uIhX6/deH/tkZqPNcRyAhDUqs729bWKoByYHSogcshXfFbppOdTER5+qY7mFvu8KEFJwT0nxr8RfPTVh0Q==}
'@tanstack/query-devtools@5.58.0':
resolution: {integrity: sha512-iFdQEFXaYYxqgrv63ots+65FGI+tNp5ZS5PdMU1DWisxk3fez5HG3FyVlbUva+RdYS5hSLbxZ9aw3yEs97GNTw==}
'@tanstack/query-persist-client-core@5.59.0': '@tanstack/query-persist-client-core@5.59.0':
resolution: {integrity: sha512-uGXnTgck1AX2xXDVj417vtQD4Sz3J1D5iPxVhfUc7f/fkY9Qad2X7Id9mZUtll1/m9z55DfHoXMXlx5H1JK6fQ==} resolution: {integrity: sha512-uGXnTgck1AX2xXDVj417vtQD4Sz3J1D5iPxVhfUc7f/fkY9Qad2X7Id9mZUtll1/m9z55DfHoXMXlx5H1JK6fQ==}
'@tanstack/react-query-devtools@5.59.15':
resolution: {integrity: sha512-rX28KTivkA2XEn3Fj9ckDtnTPY8giWYgssySSAperpVol4+th+NCij/MhLylfB+Mfg2JfCxOcwnM/fwzS8iSog==}
peerDependencies:
'@tanstack/react-query': ^5.59.15
react: ^18 || ^19
'@tanstack/react-query@5.59.0': '@tanstack/react-query@5.59.0':
resolution: {integrity: sha512-YDXp3OORbYR+8HNQx+lf4F73NoiCmCcSvZvgxE29OifmQFk0sBlO26NWLHpcNERo92tVk3w+JQ53/vkcRUY1hA==} resolution: {integrity: sha512-YDXp3OORbYR+8HNQx+lf4F73NoiCmCcSvZvgxE29OifmQFk0sBlO26NWLHpcNERo92tVk3w+JQ53/vkcRUY1hA==}
peerDependencies: peerDependencies:
@@ -3259,10 +3271,18 @@ snapshots:
'@tanstack/query-core@5.59.0': {} '@tanstack/query-core@5.59.0': {}
'@tanstack/query-devtools@5.58.0': {}
'@tanstack/query-persist-client-core@5.59.0': '@tanstack/query-persist-client-core@5.59.0':
dependencies: dependencies:
'@tanstack/query-core': 5.59.0 '@tanstack/query-core': 5.59.0
'@tanstack/react-query-devtools@5.59.15(@tanstack/react-query@5.59.0(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)':
dependencies:
'@tanstack/query-devtools': 5.58.0
'@tanstack/react-query': 5.59.0(react@19.0.0-rc-d025ddd3-20240722)
react: 19.0.0-rc-d025ddd3-20240722
'@tanstack/react-query@5.59.0(react@19.0.0-rc-d025ddd3-20240722)': '@tanstack/react-query@5.59.0(react@19.0.0-rc-d025ddd3-20240722)':
dependencies: dependencies:
'@tanstack/query-core': 5.59.0 '@tanstack/query-core': 5.59.0

View File

@@ -1,74 +1,37 @@
[ [
{ {
"default": true, "default": true,
"official": true,
"label": "onboarding", "label": "onboarding",
"name": "Onboarding", "name": "Onboarding",
"description": "Tips for Mastering Lume.", "description": "Tips for Mastering Lume.",
"url": "/columns/onboarding", "url": "/columns/onboarding"
"picture": ""
}, },
{ {
"default": true, "default": true,
"official": true, "label": "launchpad",
"label": "Launchpad",
"name": "Launchpad", "name": "Launchpad",
"description": "Expand your experiences.", "description": "Expand your experiences.",
"url": "/columns/launchpad", "url": "/columns/launchpad"
"picture": ""
}, },
{ {
"default": false, "default": false,
"official": true,
"label": "newsfeed",
"name": "Newsfeed",
"description": "All notes from you're following.",
"url": "/columns/newsfeed",
"picture": ""
},
{
"default": false,
"official": true,
"label": "notification",
"name": "Notification",
"description": "All things around you.",
"url": "/columns/notification",
"picture": ""
},
{
"default": false,
"official": true,
"label": "search", "label": "search",
"name": "Search", "name": "Search",
"description": "Find anything.", "description": "Find anything.",
"url": "/columns/search", "url": "/columns/search"
"picture": ""
}, },
{ {
"default": false, "default": false,
"official": true,
"label": "stories",
"name": "Stories",
"description": "Keep up to date with your follows.",
"url": "/columns/stories",
"picture": ""
},
{
"default": false,
"official": true,
"label": "global_feeds", "label": "global_feeds",
"name": "Global Feeds", "name": "Global Feeds",
"description": "All global notes from all connected relays.", "description": "All global notes from all connected relays.",
"url": "/columns/global", "url": "/columns/global"
"picture": ""
}, },
{ {
"default": false, "default": false,
"official": true,
"label": "trending", "label": "trending",
"name": "Trending", "name": "Trending",
"description": "Discover all trending notes.", "description": "Discover all trending notes.",
"url": "/columns/trending", "url": "/columns/trending"
"picture": ""
} }
] ]

View File

@@ -1,17 +1,11 @@
use keyring::Entry; use keyring::Entry;
use keyring_search::{Limit, List, Search};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use std::{ use std::{str::FromStr, time::Duration};
collections::HashSet, use tauri::{Emitter, State};
fs::{self, File},
str::FromStr,
time::Duration,
};
use tauri::{Emitter, Manager, State};
use crate::{Nostr, NOTIFICATION_SUB_ID}; use crate::{common::get_all_accounts, Nostr};
#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Serialize, Deserialize, Type)]
struct Account { struct Account {
@@ -22,67 +16,31 @@ struct Account {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn get_accounts() -> Vec<String> { pub fn get_accounts() -> Vec<String> {
let search = Search::new().expect("Unexpected."); get_all_accounts()
let results = search.by_service("Lume Secret Storage");
let list = List::list_credentials(&results, Limit::All);
let accounts: HashSet<String> = list
.split_whitespace()
.filter(|v| v.starts_with("npub1"))
.map(String::from)
.collect();
accounts.into_iter().collect()
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn create_account( pub async fn watch_account(key: String, state: State<'_, Nostr>) -> Result<String, String> {
name: String, let public_key = PublicKey::from_str(&key).map_err(|e| e.to_string())?;
about: String, let bech32 = public_key.to_bech32().map_err(|e| e.to_string())?;
picture: String, let keyring = Entry::new("Lume Secret Storage", &bech32).map_err(|e| e.to_string())?;
keyring.set_password("").map_err(|e| e.to_string())?;
// Update state
state.accounts.lock().unwrap().push(bech32.clone());
Ok(bech32)
}
#[tauri::command]
#[specta::specta]
pub async fn import_account(
key: String,
password: String, password: String,
state: State<'_, Nostr>, state: State<'_, Nostr>,
) -> Result<String, String> { ) -> Result<String, String> {
let client = &state.client;
let keys = Keys::generate();
let npub = keys.public_key().to_bech32().map_err(|e| e.to_string())?;
let secret_key = keys.secret_key();
let enc = EncryptedSecretKey::new(secret_key, password, 16, KeySecurity::Medium)
.map_err(|err| err.to_string())?;
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
// Save account
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
let account = Account {
password: enc_bech32,
nostr_connect: None,
};
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
let _ = keyring.set_password(&j);
let signer = NostrSigner::Keys(keys);
// Update signer
client.set_signer(Some(signer)).await;
let mut metadata = Metadata::new()
.display_name(name.clone())
.name(name.to_lowercase())
.about(about);
if let Ok(url) = Url::parse(&picture) {
metadata = metadata.picture(url)
}
match client.set_metadata(&metadata).await {
Ok(_) => Ok(npub),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn import_account(key: String, password: String) -> Result<String, String> {
let (npub, enc_bech32) = match key.starts_with("ncryptsec") { let (npub, enc_bech32) = match key.starts_with("ncryptsec") {
true => { true => {
let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?; let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
@@ -117,6 +75,9 @@ pub async fn import_account(key: String, password: String) -> Result<String, Str
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?; let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
keyring.set_password(&pwd).map_err(|e| e.to_string())?; keyring.set_password(&pwd).map_err(|e| e.to_string())?;
// Update state
state.accounts.lock().unwrap().push(npub.clone());
Ok(npub) Ok(npub)
} }
@@ -157,6 +118,9 @@ pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<Str
// Update signer // Update signer
let _ = client.set_signer(Some(signer.into())).await; let _ = client.set_signer(Some(signer.into())).await;
// Update state
state.accounts.lock().unwrap().push(remote_npub.clone());
Ok(remote_npub) Ok(remote_npub)
} }
Err(err) => Err(err.to_string()), Err(err) => Err(err.to_string()),
@@ -208,34 +172,32 @@ pub fn delete_account(id: String) -> Result<(), String> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn is_account_sync(id: String, handle: tauri::AppHandle) -> bool { pub async fn has_signer(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let config_dir = handle let client = &state.client;
.path() let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
.app_config_dir()
.expect("Error: app config directory not found.");
fs::metadata(config_dir.join(id)).is_ok() match client.signer().await {
Ok(signer) => {
// Emit reload in front-end
// handle.emit("signer", ()).unwrap();
let signer_key = signer.public_key().await.map_err(|e| e.to_string())?;
let is_match = signer_key == public_key;
Ok(is_match)
}
Err(_) => Ok(false),
}
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn create_sync_file(id: String, handle: tauri::AppHandle) -> bool { pub async fn set_signer(
let config_dir = handle
.path()
.app_config_dir()
.expect("Error: app config directory not found.");
File::create(config_dir.join(id)).is_ok()
}
#[tauri::command]
#[specta::specta]
pub async fn login(
account: String, account: String,
password: String, password: String,
state: State<'_, Nostr>, state: State<'_, Nostr>,
handle: tauri::AppHandle, handle: tauri::AppHandle,
) -> Result<String, String> { ) -> Result<(), String> {
let client = &state.client; let client = &state.client;
let keyring = Entry::new("Lume Secret Storage", &account).map_err(|e| e.to_string())?; let keyring = Entry::new("Lume Secret Storage", &account).map_err(|e| e.to_string())?;
@@ -247,7 +209,7 @@ pub async fn login(
Err(e) => return Err(e.to_string()), Err(e) => return Err(e.to_string()),
}; };
let public_key = match account.nostr_connect { match account.nostr_connect {
None => { None => {
let ncryptsec = let ncryptsec =
EncryptedSecretKey::from_bech32(account.password).map_err(|e| e.to_string())?; EncryptedSecretKey::from_bech32(account.password).map_err(|e| e.to_string())?;
@@ -255,196 +217,30 @@ pub async fn login(
.to_secret_key(password) .to_secret_key(password)
.map_err(|_| "Wrong password.")?; .map_err(|_| "Wrong password.")?;
let keys = Keys::new(secret_key); let keys = Keys::new(secret_key);
let public_key = keys.public_key().to_bech32().unwrap();
let signer = NostrSigner::Keys(keys); let signer = NostrSigner::Keys(keys);
// Update signer // Update signer
client.set_signer(Some(signer)).await; client.set_signer(Some(signer)).await;
// Emit to front-end
handle.emit("signer-updated", ()).unwrap();
public_key Ok(())
} }
Some(bunker) => { Some(bunker) => {
let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?; let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?;
let public_key = uri.signer_public_key().unwrap().to_bech32().unwrap();
let app_keys = Keys::from_str(&account.password).map_err(|e| e.to_string())?; let app_keys = Keys::from_str(&account.password).map_err(|e| e.to_string())?;
match Nip46Signer::new(uri, app_keys, Duration::from_secs(120), None) { match Nip46Signer::new(uri, app_keys, Duration::from_secs(120), None) {
Ok(signer) => { Ok(signer) => {
// Update signer // Update signer
client.set_signer(Some(signer.into())).await; client.set_signer(Some(signer.into())).await;
public_key // Emit to front-end
handle.emit("signer-updated", ()).unwrap();
Ok(())
} }
Err(e) => return Err(e.to_string()), Err(e) => Err(e.to_string()),
} }
} }
};
// NIP-65: Connect to user's relay list
// init_nip65(client, &public_key).await;
// NIP-03: Get user's contact list
let contact_list = {
if let Ok(contacts) = client.get_contact_list(Some(Duration::from_secs(5))).await {
state.contact_list.lock().unwrap().clone_from(&contacts);
contacts
} else {
Vec::new()
} }
};
let public_key_clone = public_key.clone();
// Run seperate thread for sync
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
let author = PublicKey::from_str(&public_key).unwrap();
// Subscribe for new notification
if let Ok(e) = client
.subscribe_with_id(
SubscriptionId::new(NOTIFICATION_SUB_ID),
vec![Filter::new().pubkey(author).since(Timestamp::now())],
None,
)
.await
{
println!("Subscribed: {}", e.success.len())
}
// Get events from contact list
if !contact_list.is_empty() {
let authors: Vec<PublicKey> = contact_list.iter().map(|f| f.public_key).collect();
// Syncing all metadata events from contact list
if let Ok(report) = client
.reconcile(
Filter::new()
.authors(authors.clone())
.kinds(vec![Kind::Metadata, Kind::ContactList])
.limit(authors.len() * 10),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len());
}
// Syncing all events from contact list
if let Ok(report) = client
.reconcile(
Filter::new()
.authors(authors.clone())
.kinds(vec![Kind::TextNote, Kind::Repost, Kind::EventDeletion])
.limit(authors.len() * 40),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len());
}
// Create the trusted public key list from contact list
// TODO: create a cached file
if let Ok(events) = client
.database()
.query(vec![Filter::new().kind(Kind::ContactList)])
.await
{
let keys: Vec<&str> = events
.iter()
.flat_map(|event| {
event
.tags
.iter()
.filter(|t| t.kind() == TagKind::p())
.filter_map(|t| t.content())
.collect::<Vec<&str>>()
})
.collect();
let trusted_list: HashSet<PublicKey> = keys
.into_iter()
.filter_map(|item| {
if let Ok(pk) = PublicKey::from_str(item) {
Some(pk)
} else {
None
}
})
.collect();
// Update app's state
state.trusted_list.lock().unwrap().clone_from(&trusted_list);
let trusted_users: Vec<PublicKey> = trusted_list.into_iter().collect();
println!("Total trusted users: {}", trusted_users.len());
if let Ok(report) = client
.reconcile(
Filter::new()
.authors(trusted_users)
.kinds(vec![
Kind::Metadata,
Kind::TextNote,
Kind::Repost,
Kind::EventDeletion,
])
.limit(5000),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len())
}
};
};
// Syncing all user's events
if let Ok(report) = client
.reconcile(
Filter::new().author(author).kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::FollowSet,
Kind::InterestSet,
Kind::Interests,
Kind::EventDeletion,
Kind::MuteList,
Kind::BookmarkSet,
Kind::BlockedRelays,
Kind::EmojiSet,
Kind::RelaySet,
Kind::RelayList,
Kind::ApplicationSpecificData,
]),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len())
}
// Syncing all tagged events for current user
if let Ok(report) = client
.reconcile(
Filter::new().pubkey(author).kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
]),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len())
};
handle
.emit("neg_synchronized", ())
.expect("Something wrong!");
});
Ok(public_key_clone)
} }

View File

@@ -410,16 +410,58 @@ pub async fn repost(raw: String, state: State<'_, Nostr>) -> Result<String, Stri
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn delete(id: String, state: State<'_, Nostr>) -> Result<String, String> { pub async fn is_reposted(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client; let client = &state.client;
let accounts = state.accounts.lock().unwrap().clone();
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?; let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
match client.delete_event(event_id).await { let authors: Vec<PublicKey> = accounts
Ok(event_id) => Ok(event_id.to_string()), .iter()
.map(|acc| PublicKey::from_str(acc).unwrap())
.collect();
let filter = Filter::new()
.event(event_id)
.kind(Kind::Repost)
.authors(authors);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(err) => Err(err.to_string()), Err(err) => Err(err.to_string()),
} }
} }
#[tauri::command]
#[specta::specta]
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
match client.delete_event(event_id).await {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|err| err.to_string())?;
let public_key = signer.public_key().await.map_err(|err| err.to_string())?;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let filter = Filter::new()
.author(public_key)
.event(event_id)
.kind(Kind::EventDeletion);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<String, String> { pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<String, String> {
@@ -498,34 +540,3 @@ pub async fn search(query: String, state: State<'_, Nostr>) -> Result<Vec<RichEv
Err(e) => Err(e.to_string()), Err(e) => Err(e.to_string()),
} }
} }
#[tauri::command]
#[specta::specta]
pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|err| err.to_string())?;
let public_key = signer.public_key().await.map_err(|err| err.to_string())?;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let filter = Filter::new()
.author(public_key)
.event(event_id)
.kind(Kind::EventDeletion);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let builder = EventBuilder::delete(vec![event_id]);
match client.send_event_builder(builder).await {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}

View File

@@ -4,11 +4,10 @@ use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use std::{str::FromStr, time::Duration}; use std::{str::FromStr, time::Duration};
use tauri::{Emitter, Manager, State}; use tauri::{Emitter, Manager, State};
use tauri_specta::Event;
use crate::{ use crate::{
common::{get_latest_event, process_event}, common::{get_all_accounts, get_latest_event, get_tags_content, process_event},
NewSettings, Nostr, RichEvent, Settings, Nostr, RichEvent, Settings,
}; };
#[derive(Clone, Serialize, Deserialize, Type)] #[derive(Clone, Serialize, Deserialize, Type)]
@@ -104,14 +103,36 @@ pub async fn set_contact_list(
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, String> { pub async fn get_contact_list(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
let contact_list = state.contact_list.lock().unwrap().clone(); let client = &state.client;
let vec: Vec<String> = contact_list let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
.into_iter()
.map(|f| f.public_key.to_hex())
.collect();
Ok(vec) let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let mut contact_list: Vec<String> = Vec::new();
match client.database().query(vec![filter]).await {
Ok(events) => {
if let Some(event) = events.into_iter().next() {
for tag in event.tags.into_iter() {
if let Some(TagStandard::PublicKey {
public_key,
uppercase: false,
..
}) = tag.to_standardized()
{
contact_list.push(public_key.to_hex())
}
}
}
Ok(contact_list)
}
Err(e) => Err(e.to_string()),
}
} }
#[tauri::command] #[tauri::command]
@@ -149,13 +170,27 @@ pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<St
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn check_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> { pub async fn is_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let contact_list = &state.contact_list.lock().unwrap(); let client = &state.client;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?; let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
match contact_list.iter().position(|x| x.public_key == public_key) { let filter = Filter::new()
Some(_) => Ok(true), .author(public_key)
None => Ok(false), .kind(Kind::ContactList)
.limit(1);
match client.database().query(vec![filter]).await {
Ok(events) => {
if let Some(event) = events.into_iter().next() {
let hex = public_key.to_hex();
let pubkeys = get_tags_content(&event, TagKind::p());
Ok(pubkeys.iter().any(|i| i == &hex))
} else {
Ok(false)
}
}
Err(e) => Err(e.to_string()),
} }
} }
@@ -267,9 +302,18 @@ pub async fn get_group(id: String, state: State<'_, Nostr>) -> Result<String, St
#[specta::specta] #[specta::specta]
pub async fn get_all_groups(state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> { pub async fn get_all_groups(state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
let client = &state.client; let client = &state.client;
let signer = client.signer().await.map_err(|e| e.to_string())?; let accounts = get_all_accounts();
let public_key = signer.public_key().await.map_err(|e| e.to_string())?; let authors: Vec<PublicKey> = accounts
let filter = Filter::new().kind(Kind::FollowSet).author(public_key); .iter()
.filter_map(|acc| {
if let Ok(pk) = PublicKey::from_str(acc) {
Some(pk)
} else {
None
}
})
.collect();
let filter = Filter::new().kind(Kind::FollowSet).authors(authors);
match client.database().query(vec![filter]).await { match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events).await), Ok(events) => Ok(process_event(client, events).await),
@@ -347,11 +391,20 @@ pub async fn get_interest(id: String, state: State<'_, Nostr>) -> Result<String,
#[specta::specta] #[specta::specta]
pub async fn get_all_interests(state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> { pub async fn get_all_interests(state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
let client = &state.client; let client = &state.client;
let signer = client.signer().await.map_err(|e| e.to_string())?; let accounts = get_all_accounts();
let public_key = signer.public_key().await.map_err(|e| e.to_string())?; let authors: Vec<PublicKey> = accounts
.iter()
.filter_map(|acc| {
if let Ok(pk) = PublicKey::from_str(acc) {
Some(pk)
} else {
None
}
})
.collect();
let filter = Filter::new() let filter = Filter::new()
.kinds(vec![Kind::InterestSet, Kind::Interests]) .kinds(vec![Kind::InterestSet, Kind::Interests])
.author(public_key); .authors(authors);
match client.database().query(vec![filter]).await { match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events).await), Ok(events) => Ok(process_event(client, events).await),
@@ -361,7 +414,7 @@ pub async fn get_all_interests(state: State<'_, Nostr>) -> Result<Vec<RichEvent>
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_mention_list(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> { pub async fn get_all_profiles(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
let client = &state.client; let client = &state.client;
let filter = Filter::new().kind(Kind::Metadata); let filter = Filter::new().kind(Kind::Metadata);
@@ -396,7 +449,9 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(uri) { if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(uri) {
let nwc = NWC::new(nwc_uri); let nwc = NWC::new(nwc_uri);
let keyring = Entry::new("Lume Secret", "Bitcoin Connect").map_err(|e| e.to_string())?; let keyring =
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
keyring.set_password(uri).map_err(|e| e.to_string())?; keyring.set_password(uri).map_err(|e| e.to_string())?;
client.set_zapper(nwc).await; client.set_zapper(nwc).await;
@@ -408,8 +463,10 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn load_wallet(state: State<'_, Nostr>) -> Result<String, String> { pub async fn load_wallet(state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client; let client = &state.client;
if client.zapper().await.is_err() {
let keyring = let keyring =
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?; Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
@@ -418,19 +475,13 @@ pub async fn load_wallet(state: State<'_, Nostr>) -> Result<String, String> {
let uri = NostrWalletConnectURI::from_str(&val).unwrap(); let uri = NostrWalletConnectURI::from_str(&val).unwrap();
let nwc = NWC::new(uri); let nwc = NWC::new(uri);
// Get current balance
let balance = nwc.get_balance().await;
// Update zapper
client.set_zapper(nwc).await; client.set_zapper(nwc).await;
}
Err(_) => return Err("Wallet not found.".into()),
}
}
match balance { Ok(())
Ok(val) => Ok(val.to_string()),
Err(_) => Err("Get balance failed.".into()),
}
}
Err(_) => Err("NWC not found.".into()),
}
} }
#[tauri::command] #[tauri::command]
@@ -452,52 +503,40 @@ pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn zap_profile( pub async fn zap_profile(
id: &str, id: String,
amount: &str, amount: String,
message: &str, message: Option<String>,
state: State<'_, Nostr>, state: State<'_, Nostr>,
) -> Result<bool, String> { ) -> Result<(), String> {
let client = &state.client; let client = &state.client;
let public_key: PublicKey = PublicKey::parse(id).map_err(|e| e.to_string())?; let public_key: PublicKey = PublicKey::parse(id).map_err(|e| e.to_string())?;
let details = ZapDetails::new(ZapType::Private).message(message);
let num = amount.parse::<u64>().map_err(|e| e.to_string())?; let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
if client.zap(public_key, num, Some(details)).await.is_ok() { match client.zap(public_key, num, details).await {
Ok(true) Ok(()) => Ok(()),
} else { Err(e) => Err(e.to_string()),
Err("Zap profile failed".into())
} }
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn zap_event( pub async fn zap_event(
id: &str, id: String,
amount: &str, amount: String,
message: &str, message: Option<String>,
state: State<'_, Nostr>, state: State<'_, Nostr>,
) -> Result<bool, String> { ) -> Result<(), String> {
let client = &state.client; let client = &state.client;
let event_id = match Nip19::from_bech32(id) {
Ok(val) => match val {
Nip19::EventId(id) => id,
Nip19::Event(event) => event.event_id,
_ => return Err("Event ID is invalid.".into()),
},
Err(_) => match EventId::from_hex(id) {
Ok(val) => val,
Err(_) => return Err("Event ID is invalid.".into()),
},
};
let details = ZapDetails::new(ZapType::Private).message(message); let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
let num = amount.parse::<u64>().map_err(|e| e.to_string())?; let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
if client.zap(event_id, num, Some(details)).await.is_ok() { match client.zap(event_id, num, details).await {
Ok(true) Ok(()) => Ok(()),
} else { Err(e) => Err(e.to_string()),
Err("Zap event failed".into())
} }
} }
@@ -544,12 +583,10 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, String> { pub async fn get_notifications(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
let client = &state.client; let client = &state.client;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
match client.signer().await {
Ok(signer) => {
let public_key = signer.public_key().await.unwrap();
let filter = Filter::new() let filter = Filter::new()
.pubkey(public_key) .pubkey(public_key)
.kinds(vec![ .kinds(vec![
@@ -564,9 +601,6 @@ pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, S
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()), Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
Err(err) => Err(err.to_string()), Err(err) => Err(err.to_string()),
} }
}
Err(err) => Err(err.to_string()),
}
} }
#[tauri::command] #[tauri::command]
@@ -577,29 +611,11 @@ pub fn get_user_settings(state: State<'_, Nostr>) -> Result<Settings, String> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn set_user_settings( pub async fn set_user_settings(settings: String, state: State<'_, Nostr>) -> Result<(), String> {
settings: String,
state: State<'_, Nostr>,
handle: tauri::AppHandle,
) -> Result<(), String> {
let client = &state.client;
let tags = vec![Tag::identifier("lume_user_setting")];
let builder = EventBuilder::new(Kind::ApplicationSpecificData, &settings, tags);
match client.send_event_builder(builder).await {
Ok(_) => {
let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?; let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
// Update state
state.settings.lock().unwrap().clone_from(&parsed); state.settings.lock().unwrap().clone_from(&parsed);
// Emit new changes to frontend
NewSettings(parsed).emit(&handle).unwrap();
Ok(()) Ok(())
}
Err(err) => Err(err.to_string()),
}
} }
#[tauri::command] #[tauri::command]
@@ -613,12 +629,3 @@ pub async fn verify_nip05(id: String, nip05: &str) -> Result<bool, String> {
Err(e) => Err(e.to_string()), Err(e) => Err(e.to_string()),
} }
} }
#[tauri::command]
#[specta::specta]
pub fn is_trusted_user(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let trusted_list = &state.trusted_list.lock().unwrap();
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
Ok(trusted_list.contains(&public_key))
}

View File

@@ -2,4 +2,5 @@ pub mod account;
pub mod event; pub mod event;
pub mod metadata; pub mod metadata;
pub mod relay; pub mod relay;
pub mod sync;
pub mod window; pub mod window;

View File

@@ -5,6 +5,7 @@ use specta::Type;
use std::{ use std::{
fs::OpenOptions, fs::OpenOptions,
io::{self, BufRead, Write}, io::{self, BufRead, Write},
str::FromStr,
}; };
use tauri::{path::BaseDirectory, Manager, State}; use tauri::{path::BaseDirectory, Manager, State};
@@ -18,8 +19,9 @@ pub struct Relays {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> { pub async fn get_relays(id: String, state: State<'_, Nostr>) -> Result<Relays, String> {
let client = &state.client; let client = &state.client;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
let connected_relays = client let connected_relays = client
.relays() .relays()
@@ -28,9 +30,6 @@ pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
.map(|url| url.to_string()) .map(|url| url.to_string())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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() let filter = Filter::new()
.author(public_key) .author(public_key)
.kind(Kind::RelayList) .kind(Kind::RelayList)
@@ -98,13 +97,14 @@ pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
pub async fn connect_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> { pub async fn connect_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client; let client = &state.client;
let status = client.add_relay(relay).await.map_err(|e| e.to_string())?; let status = client.add_relay(relay).await.map_err(|e| e.to_string())?;
if status { if status {
println!("Connecting to relay: {}", relay);
client client
.connect_relay(relay) .connect_relay(relay)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
Ok(status) Ok(status)
} }
@@ -112,14 +112,12 @@ pub async fn connect_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool,
#[specta::specta] #[specta::specta]
pub async fn remove_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> { pub async fn remove_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client; let client = &state.client;
client client
.remove_relay(relay) .force_remove_relay(relay)
.await
.map_err(|e| e.to_string())?;
client
.disconnect_relay(relay)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(true) Ok(true)
} }

View File

@@ -0,0 +1,204 @@
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::str::FromStr;
use tauri::{AppHandle, Manager};
use tauri_specta::Event as TauriEvent;
use crate::{common::get_tags_content, Nostr};
#[derive(Clone, Serialize, Type, TauriEvent)]
pub struct NegentropyEvent {
kind: NegentropyKind,
total_event: i32,
}
#[derive(Clone, Serialize, Deserialize, Type)]
pub enum NegentropyKind {
Profile,
Metadata,
Events,
EventIds,
Global,
Notification,
Others,
}
pub fn run_fast_sync(accounts: Vec<String>, app_handle: AppHandle) {
if accounts.is_empty() {
return;
};
let public_keys: Vec<PublicKey> = accounts
.iter()
.filter_map(|acc| {
if let Ok(pk) = PublicKey::from_str(acc) {
Some(pk)
} else {
None
}
})
.collect();
tauri::async_runtime::spawn(async move {
let state = app_handle.state::<Nostr>();
let client = &state.client;
let bootstrap_relays = state.bootstrap_relays.lock().unwrap().clone();
// NEG: Sync profile
//
let profile = Filter::new()
.authors(public_keys.clone())
.kind(Kind::Metadata)
.limit(4);
if let Ok(report) = client
.reconcile_with(&bootstrap_relays, profile, NegentropyOptions::default())
.await
{
NegentropyEvent {
kind: NegentropyKind::Profile,
total_event: report.received.len() as i32,
}
.emit(&app_handle)
.unwrap();
}
// NEG: Sync contact list
//
let contact_list = Filter::new()
.authors(public_keys.clone())
.kind(Kind::ContactList)
.limit(4);
if let Ok(report) = client
.reconcile_with(
&bootstrap_relays,
contact_list.clone(),
NegentropyOptions::default(),
)
.await
{
NegentropyEvent {
kind: NegentropyKind::Metadata,
total_event: report.received.len() as i32,
}
.emit(&app_handle)
.unwrap();
}
// NEG: Sync events from contact list
//
if let Ok(events) = client.database().query(vec![contact_list]).await {
let pubkeys: Vec<PublicKey> = events
.iter()
.flat_map(|ev| {
let tags = get_tags_content(ev, TagKind::p());
tags.into_iter().filter_map(|p| {
if let Ok(pk) = PublicKey::from_hex(p) {
Some(pk)
} else {
None
}
})
})
.collect();
for chunk in pubkeys.chunks(500) {
if chunk.is_empty() {
break;
}
let authors = chunk.to_owned();
// NEG: Sync event
//
let events = Filter::new()
.authors(authors.clone())
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(1000);
if let Ok(report) = client
.reconcile_with(&bootstrap_relays, events, NegentropyOptions::default())
.await
{
NegentropyEvent {
kind: NegentropyKind::Events,
total_event: report.received.len() as i32,
}
.emit(&app_handle)
.unwrap();
}
// NEG: Sync metadata
//
let metadata = Filter::new()
.authors(authors)
.kind(Kind::Metadata)
.limit(1000);
if let Ok(report) = client
.reconcile_with(&bootstrap_relays, metadata, NegentropyOptions::default())
.await
{
NegentropyEvent {
kind: NegentropyKind::Metadata,
total_event: report.received.len() as i32,
}
.emit(&app_handle)
.unwrap();
}
}
}
// NEG: Sync other metadata
//
let others = Filter::new().authors(public_keys.clone()).kinds(vec![
Kind::Interests,
Kind::InterestSet,
Kind::FollowSet,
Kind::EventDeletion,
Kind::Custom(30315),
]);
if let Ok(report) = client
.reconcile_with(&bootstrap_relays, others, NegentropyOptions::default())
.await
{
NegentropyEvent {
kind: NegentropyKind::Others,
total_event: report.received.len() as i32,
}
.emit(&app_handle)
.unwrap();
}
// NEG: Sync notification
//
let notification = Filter::new()
.pubkeys(public_keys)
.kinds(vec![
Kind::Reaction,
Kind::TextNote,
Kind::Repost,
Kind::ZapReceipt,
])
.limit(10000);
if let Ok(report) = client
.reconcile_with(
&bootstrap_relays,
notification,
NegentropyOptions::default(),
)
.await
{
NegentropyEvent {
kind: NegentropyKind::Notification,
total_event: report.received.len() as i32,
}
.emit(&app_handle)
.unwrap();
}
});
}

View File

@@ -22,6 +22,7 @@ pub struct Window {
maximizable: bool, maximizable: bool,
minimizable: bool, minimizable: bool,
hidden_title: bool, hidden_title: bool,
closable: bool,
} }
#[derive(Serialize, Deserialize, Type)] #[derive(Serialize, Deserialize, Type)]
@@ -109,7 +110,7 @@ pub fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<bool
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> { pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<String, String> {
if let Some(current_window) = app_handle.get_window(&window.label) { if let Some(current_window) = app_handle.get_window(&window.label) {
if current_window.is_visible().unwrap_or_default() { if current_window.is_visible().unwrap_or_default() {
let _ = current_window.set_focus(); let _ = current_window.set_focus();
@@ -117,6 +118,8 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
let _ = current_window.show(); let _ = current_window.show();
let _ = current_window.set_focus(); let _ = current_window.set_focus();
}; };
Ok(current_window.label().to_string())
} else { } else {
let new_window = WebviewWindowBuilder::new( let new_window = WebviewWindowBuilder::new(
&app_handle, &app_handle,
@@ -131,6 +134,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.minimizable(window.minimizable) .minimizable(window.minimizable)
.maximizable(window.maximizable) .maximizable(window.maximizable)
.transparent(true) .transparent(true)
.closable(window.closable)
.effects(WindowEffectsConfig { .effects(WindowEffectsConfig {
state: None, state: None,
effects: vec![Effect::UnderWindowBackground], effects: vec![Effect::UnderWindowBackground],
@@ -142,24 +146,26 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
// Restore native border // Restore native border
new_window.add_border(None); new_window.add_border(None);
}
Ok(()) Ok(new_window.label().to_string())
}
} }
#[tauri::command(async)] #[tauri::command(async)]
#[specta::specta] #[specta::specta]
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> { pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<String, String> {
if let Some(window) = app_handle.get_window(&window.label) { if let Some(current_window) = app_handle.get_window(&window.label) {
if window.is_visible().unwrap_or_default() { if current_window.is_visible().unwrap_or_default() {
let _ = window.set_focus(); let _ = current_window.set_focus();
} else { } else {
let _ = window.show(); let _ = current_window.show();
let _ = window.set_focus(); let _ = current_window.set_focus();
}; };
Ok(current_window.label().to_string())
} else { } else {
let window = WebviewWindowBuilder::new( let new_window = WebviewWindowBuilder::new(
&app_handle, &app_handle,
&window.label, &window.label,
WebviewUrl::App(PathBuf::from(window.url)), WebviewUrl::App(PathBuf::from(window.url)),
@@ -171,6 +177,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.maximizable(window.maximizable) .maximizable(window.maximizable)
.transparent(true) .transparent(true)
.decorations(false) .decorations(false)
.closable(window.closable)
.effects(WindowEffectsConfig { .effects(WindowEffectsConfig {
state: None, state: None,
effects: vec![Effect::Mica], effects: vec![Effect::Mica],
@@ -181,7 +188,9 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.unwrap(); .unwrap();
// Set decoration // Set decoration
window.create_overlay_titlebar().unwrap(); new_window.create_overlay_titlebar().unwrap();
Ok(new_window.label().to_string())
} }
Ok(()) Ok(())

View File

@@ -1,10 +1,11 @@
use futures::future::join_all; use futures::future::join_all;
use keyring_search::{Limit, List, Search};
use linkify::LinkFinder; use linkify::LinkFinder;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use reqwest::Client as ReqClient; use reqwest::Client as ReqClient;
use serde::Serialize; use serde::Serialize;
use specta::Type; use specta::Type;
use std::{collections::HashSet, str::FromStr, time::Duration}; use std::{collections::HashSet, str::FromStr};
use crate::RichEvent; use crate::RichEvent;
@@ -18,6 +19,8 @@ pub struct Meta {
} }
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"]; const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
// const VIDEOS: [&str; 6] = ["mp4", "avi", "mov", "mkv", "wmv", "webm"];
const NOSTR_EVENTS: [&str; 10] = [ const NOSTR_EVENTS: [&str; 10] = [
"@nevent1", "@nevent1",
"@note1", "@note1",
@@ -30,6 +33,7 @@ const NOSTR_EVENTS: [&str; 10] = [
"Nostr:note1", "Nostr:note1",
"Nostr:nevent1", "Nostr:nevent1",
]; ];
const NOSTR_MENTIONS: [&str; 10] = [ const NOSTR_MENTIONS: [&str; 10] = [
"@npub1", "@npub1",
"nostr:npub1", "nostr:npub1",
@@ -47,6 +51,15 @@ pub fn get_latest_event(events: &Events) -> Option<&Event> {
events.iter().next() events.iter().next()
} }
pub fn get_tags_content(event: &Event, kind: TagKind) -> Vec<String> {
event
.tags
.iter()
.filter(|t| t.kind() == kind)
.filter_map(|t| t.content().map(|content| content.to_string()))
.collect()
}
pub fn create_tags(content: &str) -> Vec<Tag> { pub fn create_tags(content: &str) -> Vec<Tag> {
let mut tags: Vec<Tag> = vec![]; let mut tags: Vec<Tag> = vec![];
let mut tag_set: HashSet<String> = HashSet::new(); let mut tag_set: HashSet<String> = HashSet::new();
@@ -65,7 +78,7 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
let hashtags = words let hashtags = words
.iter() .iter()
.filter(|&&word| word.starts_with('#')) .filter(|&&word| word.starts_with('#'))
.map(|&s| s.to_string()) .map(|&s| s.to_string().replace("#", "").to_lowercase())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for mention in mentions { for mention in mentions {
@@ -128,6 +141,19 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
tags tags
} }
pub fn get_all_accounts() -> Vec<String> {
let search = Search::new().expect("Unexpected.");
let results = search.by_service("Lume Secret Storage");
let list = List::list_credentials(&results, Limit::All);
let accounts: HashSet<String> = list
.split_whitespace()
.filter(|v| v.starts_with("npub1") && !v.ends_with("Lume"))
.map(String::from)
.collect();
accounts.into_iter().collect()
}
pub async fn process_event(client: &Client, events: Events) -> Vec<RichEvent> { pub async fn process_event(client: &Client, events: Events) -> Vec<RichEvent> {
// Remove event thread if event is TextNote // Remove event thread if event is TextNote
let events: Vec<Event> = events let events: Vec<Event> = events
@@ -201,38 +227,6 @@ pub async fn process_event(client: &Client, events: Events) -> Vec<RichEvent> {
join_all(futures).await join_all(futures).await
} }
pub async fn init_nip65(client: &Client, public_key: &str) {
let author = PublicKey::from_str(public_key).unwrap();
let filter = Filter::new().author(author).kind(Kind::RelayList).limit(1);
// client.add_relay("ws://127.0.0.1:1984").await.unwrap();
// client.connect_relay("ws://127.0.0.1:1984").await.unwrap();
if let Ok(events) = client
.fetch_events(vec![filter], Some(Duration::from_secs(5)))
.await
{
if let Some(event) = events.first() {
let relay_list = nip65::extract_relay_list(event);
for (url, metadata) in relay_list {
let opts = match metadata {
Some(RelayMetadata::Read) => RelayOptions::new().read(true).write(false),
Some(_) => RelayOptions::new().write(true).read(false),
None => RelayOptions::default(),
};
if let Err(e) = client.pool().add_relay(&url.to_string(), opts).await {
eprintln!("Failed to add relay {}: {:?}", url, e);
}
if let Err(e) = client.connect_relay(url.to_string()).await {
eprintln!("Failed to connect to relay {}: {:?}", url, e);
} else {
println!("Connecting to relay: {} - {:?}", url, metadata);
}
}
}
}
}
pub async fn parse_event(content: &str) -> Meta { pub async fn parse_event(content: &str) -> Meta {
let mut finder = LinkFinder::new(); let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false); finder.url_must_have_scheme(false);

View File

@@ -5,14 +5,20 @@
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use border::WebviewWindowExt as BorderWebviewWindowExt; use border::WebviewWindowExt as BorderWebviewWindowExt;
use commands::{account::*, event::*, metadata::*, relay::*, window::*}; use commands::{
use common::parse_event; account::*,
event::*,
metadata::*,
relay::*,
sync::{run_fast_sync, NegentropyEvent},
window::*,
};
use common::{get_all_accounts, get_tags_content, parse_event};
use nostr_sdk::prelude::{Profile as DatabaseProfile, *}; use nostr_sdk::prelude::{Profile as DatabaseProfile, *};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use specta_typescript::Typescript; use specta_typescript::Typescript;
use std::{ use std::{
collections::HashSet,
fs, fs,
io::{self, BufRead}, io::{self, BufRead},
str::FromStr, str::FromStr,
@@ -30,8 +36,9 @@ pub mod common;
pub struct Nostr { pub struct Nostr {
client: Client, client: Client,
settings: Mutex<Settings>, settings: Mutex<Settings>,
contact_list: Mutex<Vec<Contact>>, accounts: Mutex<Vec<String>>,
trusted_list: Mutex<HashSet<PublicKey>>, subscriptions: Mutex<Vec<SubscriptionId>>,
bootstrap_relays: Mutex<Vec<Url>>,
} }
#[derive(Clone, Serialize, Deserialize, Type)] #[derive(Clone, Serialize, Deserialize, Type)]
@@ -76,14 +83,14 @@ struct Subscription {
label: String, label: String,
kind: SubKind, kind: SubKind,
event_id: Option<String>, event_id: Option<String>,
contacts: Option<Vec<String>>,
} }
#[derive(Serialize, Deserialize, Type, Clone, TauriEvent)] #[derive(Serialize, Deserialize, Type, Clone, TauriEvent)]
struct NewSettings(Settings); struct NewSettings(Settings);
pub const DEFAULT_DIFFICULTY: u8 = 21; pub const DEFAULT_DIFFICULTY: u8 = 21;
pub const FETCH_LIMIT: usize = 100; pub const FETCH_LIMIT: usize = 50;
pub const NOTIFICATION_NEG_LIMIT: usize = 64;
pub const NOTIFICATION_SUB_ID: &str = "lume_notification"; pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
fn main() { fn main() {
@@ -99,22 +106,21 @@ fn main() {
get_bootstrap_relays, get_bootstrap_relays,
save_bootstrap_relays, save_bootstrap_relays,
get_accounts, get_accounts,
create_account, watch_account,
import_account, import_account,
connect_account, connect_account,
get_private_key, get_private_key,
delete_account, delete_account,
reset_password, reset_password,
is_account_sync, has_signer,
create_sync_file, set_signer,
login,
get_profile, get_profile,
set_profile, set_profile,
get_contact_list, get_contact_list,
set_contact_list, set_contact_list,
check_contact, is_contact,
toggle_contact, toggle_contact,
get_mention_list, get_all_profiles,
set_group, set_group,
get_group, get_group,
get_all_groups, get_all_groups,
@@ -131,7 +137,6 @@ fn main() {
get_user_settings, get_user_settings,
set_user_settings, set_user_settings,
verify_nip05, verify_nip05,
is_trusted_user,
get_event_meta, get_event_meta,
get_event, get_event,
get_event_from, get_event_from,
@@ -142,12 +147,13 @@ fn main() {
get_all_events_by_hashtags, get_all_events_by_hashtags,
get_local_events, get_local_events,
get_global_events, get_global_events,
is_deleted_event,
request_delete,
search, search,
publish, publish,
reply, reply,
repost, repost,
is_reposted,
request_delete,
is_deleted_event,
event_to_bech32, event_to_bech32,
user_to_bech32, user_to_bech32,
create_column, create_column,
@@ -158,7 +164,7 @@ fn main() {
reopen_lume, reopen_lume,
quit quit
]) ])
.events(collect_events![Subscription, NewSettings]); .events(collect_events![Subscription, NewSettings, NegentropyEvent]);
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
builder builder
@@ -179,6 +185,7 @@ fn main() {
let handle = app.handle(); let handle = app.handle();
let handle_clone = handle.clone(); let handle_clone = handle.clone();
let handle_clone_child = handle_clone.clone(); let handle_clone_child = handle_clone.clone();
let handle_clone_sync = handle_clone_child.clone();
let main_window = app.get_webview_window("main").unwrap(); let main_window = app.get_webview_window("main").unwrap();
let config_dir = handle let config_dir = handle
@@ -216,7 +223,7 @@ fn main() {
} }
}); });
let client = tauri::async_runtime::block_on(async move { let (client, bootstrap_relays) = tauri::async_runtime::block_on(async move {
// Setup database // Setup database
let database = NostrLMDB::open(config_dir.join("nostr-lmdb")) let database = NostrLMDB::open(config_dir.join("nostr-lmdb"))
.expect("Error: cannot create database."); .expect("Error: cannot create database.");
@@ -224,10 +231,10 @@ fn main() {
// Config // Config
let opts = Options::new() let opts = Options::new()
.gossip(true) .gossip(true)
.max_avg_latency(Duration::from_millis(500)) .max_avg_latency(Duration::from_millis(800))
.automatic_authentication(false) .automatic_authentication(false)
.connection_timeout(Some(Duration::from_secs(20))) .connection_timeout(Some(Duration::from_secs(20)))
.send_timeout(Some(Duration::from_secs(10))) .send_timeout(Some(Duration::from_secs(20)))
.timeout(Duration::from_secs(20)); .timeout(Duration::from_secs(20));
// Setup nostr client // Setup nostr client
@@ -279,17 +286,27 @@ fn main() {
// Connect // Connect
client.connect_with_timeout(Duration::from_secs(10)).await; client.connect_with_timeout(Duration::from_secs(10)).await;
client // Get all bootstrap relays
let bootstrap_relays: Vec<Url> =
client.pool().all_relays().await.into_keys().collect();
(client, bootstrap_relays)
}); });
let accounts = get_all_accounts();
// Run fast sync for all accounts
run_fast_sync(accounts.clone(), handle_clone_sync);
// Create global state // Create global state
app.manage(Nostr { app.manage(Nostr {
client, client,
accounts: Mutex::new(accounts),
settings: Mutex::new(Settings::default()), settings: Mutex::new(Settings::default()),
contact_list: Mutex::new(Vec::new()), subscriptions: Mutex::new(Vec::new()),
trusted_list: Mutex::new(HashSet::new()), bootstrap_relays: Mutex::new(bootstrap_relays),
}); });
// Handle subscription
Subscription::listen_any(app, move |event| { Subscription::listen_any(app, move |event| {
let handle = handle_clone_child.to_owned(); let handle = handle_clone_child.to_owned();
let payload = event.payload; let payload = event.payload;
@@ -302,42 +319,84 @@ fn main() {
SubKind::Subscribe => { SubKind::Subscribe => {
let subscription_id = SubscriptionId::new(payload.label); let subscription_id = SubscriptionId::new(payload.label);
match payload.event_id { if !client
Some(id) => { .pool()
.subscriptions()
.await
.contains_key(&subscription_id)
{
// Update state
state
.subscriptions
.lock()
.unwrap()
.push(subscription_id.clone());
println!(
"Total subscriptions: {}",
state.subscriptions.lock().unwrap().len()
);
if let Some(id) = payload.event_id {
let event_id = EventId::from_str(&id).unwrap(); let event_id = EventId::from_str(&id).unwrap();
let filter = let filter =
Filter::new().event(event_id).since(Timestamp::now()); Filter::new().event(event_id).since(Timestamp::now());
if let Err(e) = client if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None) .subscribe_with_id(
subscription_id.clone(),
vec![filter],
None,
)
.await .await
{ {
println!("Subscription error: {}", e) println!("Subscription error: {}", e)
} }
} }
None => {
let contact_list = state.contact_list.lock().unwrap().clone();
if !contact_list.is_empty() {
let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
let filter = Filter::new() if let Some(ids) = payload.contacts {
.kinds(vec![Kind::TextNote, Kind::Repost]) let authors: Vec<PublicKey> = ids
.authors(authors) .iter()
.since(Timestamp::now()); .filter_map(|item| {
if let Ok(pk) = PublicKey::from_str(item) {
Some(pk)
} else {
None
}
})
.collect();
if let Err(e) = client if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None) .subscribe_with_id(
subscription_id,
vec![Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.authors(authors)
.since(Timestamp::now())],
None,
)
.await .await
{ {
println!("Subscription error: {}", e) println!("Subscription error: {}", e)
} }
} }
} }
};
} }
SubKind::Unsubscribe => { SubKind::Unsubscribe => {
let subscription_id = SubscriptionId::new(payload.label); let subscription_id = SubscriptionId::new(payload.label);
let mut sub_state = state.subscriptions.lock().unwrap().clone();
if let Some(pos) = sub_state.iter().position(|x| *x == subscription_id)
{
sub_state.remove(pos);
state.subscriptions.lock().unwrap().clone_from(&sub_state)
}
println!(
"Total subscriptions: {}",
state.subscriptions.lock().unwrap().len()
);
client.unsubscribe(subscription_id).await client.unsubscribe(subscription_id).await
} }
} }
@@ -363,6 +422,30 @@ fn main() {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = handle_clone.state::<Nostr>(); let state = handle_clone.state::<Nostr>();
let client = &state.client; let client = &state.client;
let accounts = state.accounts.lock().unwrap().clone();
let public_keys: Vec<PublicKey> = accounts
.iter()
.filter_map(|acc| {
if let Ok(pk) = PublicKey::from_str(acc) {
Some(pk)
} else {
None
}
})
.collect();
// Subscribe for new notification
if let Ok(e) = client
.subscribe_with_id(
SubscriptionId::new(NOTIFICATION_SUB_ID),
vec![Filter::new().pubkeys(public_keys).since(Timestamp::now())],
None,
)
.await
{
println!("Subscribed for notification on {} relays", e.success.len())
}
let allow_notification = match handle_clone.notification().request_permission() { let allow_notification = match handle_clone.notification().request_permission() {
Ok(_) => { Ok(_) => {
@@ -377,6 +460,7 @@ fn main() {
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID); let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
let mut notifications = client.pool().notifications(); let mut notifications = client.pool().notifications();
let mut new_events: Vec<EventId> = Vec::new();
while let Ok(notification) = notifications.recv().await { while let Ok(notification) = notifications.recv().await {
match notification { match notification {
@@ -394,6 +478,17 @@ fn main() {
println!("Error: {}", e); println!("Error: {}", e);
} }
// Workaround for https://github.com/rust-nostr/nostr/issues/509
// TODO: remove
let _ = client
.fetch_events(
vec![Filter::new()
.kind(Kind::TextNote)
.limit(0)],
Some(Duration::from_secs(5)),
)
.await;
if allow_notification { if allow_notification {
if let Err(e) = &handle_clone if let Err(e) = &handle_clone
.notification() .notification()
@@ -426,8 +521,12 @@ fn main() {
event, event,
} = message } = message
{ {
let tags = get_tags_content(&event, TagKind::p());
// Handle events from notification subscription // Handle events from notification subscription
if subscription_id == notification_id { if subscription_id == notification_id
&& tags.iter().any(|item| accounts.iter().any(|i| i == item))
{
// Send native notification // Send native notification
if allow_notification { if allow_notification {
let author = client let author = client
@@ -437,27 +536,46 @@ fn main() {
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
DatabaseProfile::new(event.pubkey, Metadata::new()) DatabaseProfile::new(event.pubkey, Metadata::new())
}); });
let metadata = author.metadata();
send_event_notification(&event, metadata, &handle_clone); send_event_notification(
&event,
author.metadata(),
&handle_clone,
);
} }
} }
let label = subscription_id.to_string(); let payload = RichEvent {
let raw = event.as_json(); raw: event.as_json(),
let parsed = if event.kind == Kind::TextNote { parsed: if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await) Some(parse_event(&event.content).await)
} else { } else {
None None
},
}; };
handle_clone handle_clone
.emit_to( .emit_to(
EventTarget::labeled(label), EventTarget::labeled(subscription_id.to_string()),
"event", "event",
RichEvent { raw, parsed }, payload,
) )
.unwrap(); .unwrap();
if state
.subscriptions
.lock()
.unwrap()
.iter()
.any(|i| i == &subscription_id)
{
new_events.push(event.id);
if new_events.len() > 5 {
handle_clone.emit("synchronized", ()).unwrap();
new_events.clear();
}
}
}; };
} }
RelayPoolNotification::Shutdown => break, RelayPoolNotification::Shutdown => break,
@@ -468,47 +586,6 @@ fn main() {
Ok(()) Ok(())
}) })
.on_window_event(|window, event| {
if let tauri::WindowEvent::Focused(focused) = event {
if !focused {
let handle = window.app_handle().to_owned();
let config_dir = handle.path().app_config_dir().unwrap();
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
if let Ok(signer) = client.signer().await {
let public_key = signer.public_key().await.unwrap();
let bech32 = public_key.to_bech32().unwrap();
if fs::metadata(config_dir.join(bech32)).is_ok() {
if let Ok(contact_list) =
client.get_contact_list(Some(Duration::from_secs(5))).await
{
let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
if client
.reconcile(
Filter::new()
.authors(authors)
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(1000),
NegentropyOptions::default(),
)
.await
.is_ok()
{
handle.emit("synchronized", ()).unwrap();
}
}
}
}
});
}
}
})
.plugin(prevent_default()) .plugin(prevent_default())
.plugin(tauri_plugin_decorum::init()) .plugin(tauri_plugin_decorum::init())
.plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_store::Builder::default().build())

View File

@@ -5,9 +5,9 @@
export const commands = { export const commands = {
async getRelays() : Promise<Result<Relays, string>> { async getRelays(id: string) : Promise<Result<Relays, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_relays") }; return { status: "ok", data: await TAURI_INVOKE("get_relays", { id }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -48,9 +48,9 @@ async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
async getAccounts() : Promise<string[]> { async getAccounts() : Promise<string[]> {
return await TAURI_INVOKE("get_accounts"); return await TAURI_INVOKE("get_accounts");
}, },
async createAccount(name: string, about: string, picture: string, password: string) : Promise<Result<string, string>> { async watchAccount(key: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("create_account", { name, about, picture, password }) }; return { status: "ok", data: await TAURI_INVOKE("watch_account", { key }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -96,15 +96,17 @@ async resetPassword(key: string, password: string) : Promise<Result<null, string
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async isAccountSync(id: string) : Promise<boolean> { async hasSigner(id: string) : Promise<Result<boolean, string>> {
return await TAURI_INVOKE("is_account_sync", { id });
},
async createSyncFile(id: string) : Promise<boolean> {
return await TAURI_INVOKE("create_sync_file", { id });
},
async login(account: string, password: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("login", { account, password }) }; return { status: "ok", data: await TAURI_INVOKE("has_signer", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setSigner(account: string, password: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_signer", { account, password }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -126,9 +128,9 @@ async setProfile(profile: Profile) : Promise<Result<string, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getContactList() : Promise<Result<string[], string>> { async getContactList(id: string) : Promise<Result<string[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_contact_list") }; return { status: "ok", data: await TAURI_INVOKE("get_contact_list", { id }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -142,9 +144,9 @@ async setContactList(publicKeys: string[]) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async checkContact(id: string) : Promise<Result<boolean, string>> { async isContact(id: string) : Promise<Result<boolean, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("check_contact", { id }) }; return { status: "ok", data: await TAURI_INVOKE("is_contact", { id }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -158,9 +160,9 @@ async toggleContact(id: string, alias: string | null) : Promise<Result<string, s
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getMentionList() : Promise<Result<Mention[], string>> { async getAllProfiles() : Promise<Result<Mention[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_mention_list") }; return { status: "ok", data: await TAURI_INVOKE("get_all_profiles") };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -222,7 +224,7 @@ async setWallet(uri: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async loadWallet() : Promise<Result<string, string>> { async loadWallet() : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("load_wallet") }; return { status: "ok", data: await TAURI_INVOKE("load_wallet") };
} catch (e) { } catch (e) {
@@ -238,7 +240,7 @@ async removeWallet() : Promise<Result<null, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async zapProfile(id: string, amount: string, message: string) : Promise<Result<boolean, string>> { async zapProfile(id: string, amount: string, message: string | null) : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) }; return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) };
} catch (e) { } catch (e) {
@@ -246,7 +248,7 @@ async zapProfile(id: string, amount: string, message: string) : Promise<Result<b
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async zapEvent(id: string, amount: string, message: string) : Promise<Result<boolean, string>> { async zapEvent(id: string, amount: string, message: string | null) : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) }; return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) };
} catch (e) { } catch (e) {
@@ -262,9 +264,9 @@ async copyFriend(npub: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getNotifications() : Promise<Result<string[], string>> { async getNotifications(id: string) : Promise<Result<string[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_notifications") }; return { status: "ok", data: await TAURI_INVOKE("get_notifications", { id }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -294,14 +296,6 @@ async verifyNip05(id: string, nip05: string) : Promise<Result<boolean, string>>
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async isTrustedUser(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_trusted_user", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEventMeta(content: string) : Promise<Result<Meta, null>> { async getEventMeta(content: string) : Promise<Result<Meta, null>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) }; return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) };
@@ -382,22 +376,6 @@ async getGlobalEvents(until: string | null) : Promise<Result<RichEvent[], string
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_deleted_event", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async requestDelete(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("request_delete", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async search(query: string) : Promise<Result<RichEvent[], string>> { async search(query: string) : Promise<Result<RichEvent[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("search", { query }) }; return { status: "ok", data: await TAURI_INVOKE("search", { query }) };
@@ -430,6 +408,30 @@ async repost(raw: string) : Promise<Result<string, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async isReposted(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_reposted", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async requestDelete(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("request_delete", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_deleted_event", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async eventToBech32(id: string) : Promise<Result<string, string>> { async eventToBech32(id: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) }; return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) };
@@ -478,7 +480,7 @@ async closeColumn(label: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async openWindow(window: Window) : Promise<Result<null, string>> { async openWindow(window: Window) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) }; return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) };
} catch (e) { } catch (e) {
@@ -498,9 +500,11 @@ async quit() : Promise<void> {
export const events = __makeEvents__<{ export const events = __makeEvents__<{
negentropyEvent: NegentropyEvent,
newSettings: NewSettings, newSettings: NewSettings,
subscription: Subscription subscription: Subscription
}>({ }>({
negentropyEvent: "negentropy-event",
newSettings: "new-settings", newSettings: "new-settings",
subscription: "subscription" subscription: "subscription"
}) })
@@ -514,14 +518,16 @@ subscription: "subscription"
export type Column = { label: string; url: string; x: number; y: number; width: number; height: number } export type Column = { label: string; url: string; x: number; y: number; width: number; height: number }
export type Mention = { pubkey: string; avatar: string; display_name: string; name: string } export type Mention = { pubkey: string; avatar: string; display_name: string; name: string }
export type Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] } export type Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] }
export type NegentropyEvent = { kind: NegentropyKind; total_event: number }
export type NegentropyKind = "Profile" | "Metadata" | "Events" | "EventIds" | "Global" | "Notification" | "Others"
export type NewSettings = Settings export type NewSettings = Settings
export type Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null } export type Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null }
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null } export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
export type RichEvent = { raw: string; parsed: Meta | null } export type RichEvent = { raw: string; parsed: Meta | null }
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; trusted_only: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean; transparent: boolean } export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; trusted_only: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean; transparent: boolean }
export type SubKind = "Subscribe" | "Unsubscribe" export type SubKind = "Subscribe" | "Unsubscribe"
export type Subscription = { label: string; kind: SubKind; event_id: string | null } export type Subscription = { label: string; kind: SubKind; event_id: string | null; contacts: string[] | null }
export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean } export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean; closable: boolean }
/** tauri-specta globals **/ /** tauri-specta globals **/

View File

@@ -155,7 +155,7 @@ export function decodeZapInvoice(tags?: string[][]) {
); );
// @ts-ignore, its fine. // @ts-ignore, its fine.
const amount = Number.parseInt(amountSection.value); const amount = Number.parseInt(amountSection.value) / 1000;
const displayValue = getBitcoinDisplayValues(amount); const displayValue = getBitcoinDisplayValues(amount);
return displayValue; return displayValue;

View File

@@ -1,23 +1,17 @@
import { commands } from "@/commands.gen"; import { commands } from "@/commands.gen";
import { appColumns } from "@/commons";
import { useRect } from "@/system"; import { useRect } from "@/system";
import type { LumeColumn } from "@/types"; import type { LumeColumn } from "@/types";
import { CaretDown, Check } from "@phosphor-icons/react"; import { CaretDown, Check } from "@phosphor-icons/react";
import { useParams } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { User } from "./user";
export function Column({ column }: { column: LumeColumn }) { export function Column({ column }: { column: LumeColumn }) {
const params = useParams({ strict: false }); const webviewLabel = useMemo(() => `column-${column.label}`, [column.label]);
const webviewLabel = useMemo(
() => `column-${params.account}_${column.label}`,
[params.account, column.label],
);
const [rect, ref] = useRect(); const [rect, ref] = useRect();
const [error, setError] = useState<string>(null); const [_error, setError] = useState<string>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -52,7 +46,7 @@ export function Column({ column }: { column: LumeColumn }) {
y: initialRect.y, y: initialRect.y,
width: initialRect.width, width: initialRect.width,
height: initialRect.height, height: initialRect.height,
url: `${column.url}?account=${params.account}&label=${column.label}&name=${column.name}`, url: `${column.url}?label=${column.label}&name=${column.name}`,
}) })
.then((res) => { .then((res) => {
if (res.status === "ok") { if (res.status === "ok") {
@@ -73,42 +67,35 @@ export function Column({ column }: { column: LumeColumn }) {
}); });
}; };
} }
}, [params.account]); }, []);
return ( return (
<div className="h-full w-[440px] shrink-0 border-r border-black/5 dark:border-white/5"> <div className="h-full w-[440px] shrink-0 border-r border-black/5 dark:border-white/5">
<div className="flex flex-col gap-px size-full"> <div className="flex flex-col gap-px size-full">
<Header label={column.label} /> <Header
label={column.label}
name={column.name}
account={column.account}
/>
<div ref={ref} className="flex-1 size-full" /> <div ref={ref} className="flex-1 size-full" />
</div> </div>
</div> </div>
); );
} }
function Header({ label }: { label: string }) { function Header({
label,
name,
account,
}: { label: string; name: string; account?: string }) {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [isChanged, setIsChanged] = useState(false); const [isChanged, setIsChanged] = useState(false);
const column = useStore(appColumns, (state) =>
state.find((col) => col.label === label),
);
const saveNewTitle = async () => {
const mainWindow = getCurrentWindow();
await mainWindow.emit("columns", { type: "set_title", label, title });
// update search params
// @ts-ignore, hahaha
search.name = title;
// reset state
setIsChanged(false);
};
const showContextMenu = useCallback(async (e: React.MouseEvent) => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
const window = getCurrentWindow(); const window = getCurrentWindow();
const menuItems = await Promise.all([ const menuItems = await Promise.all([
MenuItem.new({ MenuItem.new({
text: "Reload", text: "Reload",
@@ -116,10 +103,6 @@ function Header({ label }: { label: string }) {
await commands.reloadColumn(label); await commands.reloadColumn(label);
}, },
}), }),
MenuItem.new({
text: "Open in new window",
action: () => console.log("not implemented."),
}),
PredefinedMenuItem.new({ item: "Separator" }), PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({ MenuItem.new({
text: "Move left", text: "Move left",
@@ -160,6 +143,15 @@ function Header({ label }: { label: string }) {
await menu.popup().catch((e) => console.error(e)); await menu.popup().catch((e) => console.error(e));
}, []); }, []);
const saveNewTitle = async () => {
await getCurrentWindow().emit("columns", {
type: "set_title",
label,
title,
});
setIsChanged(false);
};
useEffect(() => { useEffect(() => {
if (title.length > 0) setIsChanged(true); if (title.length > 0) setIsChanged(true);
}, [title.length]); }, [title.length]);
@@ -168,13 +160,20 @@ function Header({ label }: { label: string }) {
<div className="group flex items-center justify-center gap-2 w-full h-9 shrink-0"> <div className="group flex items-center justify-center gap-2 w-full h-9 shrink-0">
<div className="flex items-center justify-center shrink-0 h-7"> <div className="flex items-center justify-center shrink-0 h-7">
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2">
{account?.length ? (
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="size-6 rounded-full" />
</User.Root>
</User.Provider>
) : null}
<div <div
contentEditable contentEditable
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
onBlur={(e) => setTitle(e.currentTarget.textContent)} onBlur={(e) => setTitle(e.currentTarget.textContent)}
className="text-[12px] font-semibold focus:outline-none" className="text-[12px] font-semibold focus:outline-none"
> >
{column.name} {name}
</div> </div>
{isChanged ? ( {isChanged ? (
<button <button

View File

@@ -1,47 +0,0 @@
import { cn } from "@/commons";
import { Note } from "@/components/note";
import type { LumeEvent } from "@/system";
import { ChatsTeardrop } from "@phosphor-icons/react";
import { memo, useMemo } from "react";
export const Conversation = memo(function Conversation({
event,
className,
}: {
event: LumeEvent;
className?: string;
}) {
const thread = useMemo(() => event.thread, [event]);
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div className="flex flex-col gap-3">
{thread?.root?.id ? <Note.Child event={thread?.root} isRoot /> : null}
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<ChatsTeardrop className="size-4" />
Thread
</div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
{thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
<div>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
</div>
<Note.Content className="px-3" />
</div>
</div>
<div className="flex items-center px-3 h-14">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
});

View File

@@ -1,5 +1,4 @@
import { cn } from "@/commons"; import { cn } from "@/commons";
import { useRouteContext } from "@tanstack/react-router";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export function Frame({ export function Frame({
@@ -7,17 +6,13 @@ export function Frame({
shadow, shadow,
className, className,
}: { children: ReactNode; shadow?: boolean; className?: string }) { }: { children: ReactNode; shadow?: boolean; className?: string }) {
const { platform } = useRouteContext({ strict: false });
return ( return (
<div <div
className={cn( className={cn(
className, className,
platform === "linux" "bg-white dark:bg-neutral-800",
? "bg-white dark:bg-neutral-950"
: "bg-white dark:bg-white/10",
shadow shadow
? "shadow-lg shadow-neutral-500/10 dark:shadow-none dark:ring-1 dark:ring-white/20" ? "shadow-primary dark:shadow-none dark:ring-1 dark:ring-neutral-700/50"
: "", : "",
)} )}
> >

View File

@@ -0,0 +1,19 @@
import type { SVGProps } from "react";
export const PublishIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M19.432 2.738c.505.54.728 1.327.443 2.133-.606 1.713-1.798 3.124-2.797 4.087a15.74 15.74 0 01-1.045.921l.137.1c.93.684 1.416 1.975.757 3.118-1.221 2.12-4.356 5.803-11.192 5.803a.753.753 0 01-.15-.015A32.702 32.702 0 005.5 21.25a.75.75 0 01-1.5 0c0-4.43.821-8.93 2.909-12.485 2.106-3.587 5.49-6.182 10.492-6.749a2.404 2.404 0 012.031.722z"
clipRule="evenodd"
/>
</svg>
);

View File

@@ -0,0 +1,23 @@
import type { SVGProps } from "react";
export const QuoteIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M2.75 5.75a2 2 0 0 1 2-2h14.5a2 2 0 0 1 2 2v10.5a2 2 0 0 1-2 2h-3.874a1 1 0 0 0-.638.23l-2.098 1.738a1 1 0 0 1-1.28-.003l-2.066-1.731a1 1 0 0 0-.642-.234H4.75a2 2 0 0 1-2-2z"
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
/>
<path
d="M9.523 8C8.406 8 7.5 8.91 7.5 10.033a2.028 2.028 0 0 0 2.81 1.874q-.072.132-.157.251c-.353.502-.875.885-1.554 1.34a.453.453 0 0 0-.125.626.45.45 0 0 0 .624.125c.67-.449 1.328-.913 1.79-1.569.474-.674.716-1.51.658-2.66A2.03 2.03 0 0 0 9.523 8m4.945 0c-1.117 0-2.023.91-2.023 2.033a2.028 2.028 0 0 0 2.81 1.874q-.072.132-.156.251c-.353.502-.876.885-1.554 1.34a.453.453 0 0 0-.125.626.45.45 0 0 0 .623.125c.67-.449 1.328-.913 1.79-1.569.474-.674.717-1.51.658-2.66A2.03 2.03 0 0 0 14.468 8"
fill="currentColor"
/>
</svg>
);

View File

@@ -4,10 +4,8 @@ export * from "./spinner";
export * from "./column"; export * from "./column";
// Newsfeed // Newsfeed
export * from "./repost";
export * from "./conversation";
export * from "./quote";
export * from "./text"; export * from "./text";
export * from "./repost";
export * from "./reply"; export * from "./reply";
// Global components // Global components
@@ -18,3 +16,5 @@ export * from "./user";
export * from "./icons/reply"; export * from "./icons/reply";
export * from "./icons/repost"; export * from "./icons/repost";
export * from "./icons/zap"; export * from "./icons/zap";
export * from "./icons/quote";
export * from "./icons/publish";

View File

@@ -8,7 +8,7 @@ export function NoteOpenThread() {
return ( return (
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root delayDuration={150}> <Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="button" type="button"

View File

@@ -0,0 +1,40 @@
import { cn } from "@/commons";
import { QuoteIcon } from "@/components";
import { LumeWindow } from "@/system";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useNoteContext } from "../provider";
export function NoteQuote({
label = false,
smol = false,
}: { label?: boolean; smol?: boolean }) {
const event = useNoteContext();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => LumeWindow.openEditor(null, event.id)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
label
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)}
>
<QuoteIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
{label ? "Quote" : null}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Quote
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -18,10 +18,8 @@ export function NoteReply({
type="button" type="button"
onClick={() => LumeWindow.openEditor(event.id)} onClick={() => LumeWindow.openEditor(event.id)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200", "h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
label label ? "w-24 gap-1.5" : "w-14",
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)} )}
> >
<ReplyIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} /> <ReplyIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />

View File

@@ -1,88 +1,174 @@
import { appSettings, cn } from "@/commons"; import { commands } from "@/commands.gen";
import { appSettings, cn, displayNpub } from "@/commons";
import { RepostIcon, Spinner } from "@/components"; import { RepostIcon, Spinner } from "@/components";
import { LumeWindow } from "@/system"; import { LumeWindow } from "@/system";
import type { Metadata } from "@/types";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useStore } from "@tanstack/react-store"; import { useStore } from "@tanstack/react-store";
import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog"; import type { Window } from "@tauri-apps/api/window";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState, useTransition } from "react";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
export function NoteRepost({ export function NoteRepost({
label = false, label = false,
smol = false, smol = false,
}: { label?: boolean; smol?: boolean }) { }: { label?: boolean; smol?: boolean }) {
const visible = useStore(appSettings, (state) => state.display_repost_button);
const event = useNoteContext(); const event = useNoteContext();
const visible = useStore(appSettings, (state) => state.display_repost_button);
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false); const { isLoading, data: status } = useQuery({
const [isRepost, setIsRepost] = useState(false); queryKey: ["is-reposted", event.id],
queryFn: async () => {
const repost = async () => { const res = await commands.isReposted(event.id);
if (isRepost) return; if (res.status === "ok") {
return res.data;
try { } else {
setLoading(true); return false;
// repost
await event.repost();
// update state
setLoading(false);
setIsRepost(true);
} catch {
setLoading(false);
await message("Repost failed, try again later", {
title: "Lume",
kind: "info",
});
} }
}; },
enabled: visible,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: false,
});
const [isPending, startTransition] = useTransition();
const [popup, setPopup] = useState<Window>(null);
const showContextMenu = useCallback(async (e: React.MouseEvent) => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
const menuItems = await Promise.all([ const accounts = await commands.getAccounts();
MenuItem.new({ const list = [];
text: "Repost",
action: async () => repost(),
}),
MenuItem.new({
text: "Quote",
action: () => LumeWindow.openEditor(null, event.id),
}),
]);
const menu = await Menu.new({ for (const account of accounts) {
items: menuItems, const res = await commands.getProfile(account);
}); let name = "unknown";
if (res.status === "ok") {
const profile: Metadata = JSON.parse(res.data);
name = profile.display_name ?? profile.name;
}
list.push(
MenuItem.new({
text: `Repost as ${name} (${displayNpub(account, 16)})`,
action: async () => submit(account),
}),
);
}
const items = await Promise.all(list);
const menu = await Menu.new({ items });
await menu.popup().catch((e) => console.error(e)); await menu.popup().catch((e) => console.error(e));
}, []); }, []);
const repost = useMutation({
mutationFn: async () => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ["is-reposted", event.id] });
// Optimistically update to the new value
queryClient.setQueryData(["is-reposted", event.id], true);
const res = await commands.repost(JSON.stringify(event.raw));
if (res.status === "ok") {
return;
} else {
throw new Error(res.error);
}
},
onError: () => {
queryClient.setQueryData(["is-reposted", event.id], false);
},
onSettled: async () => {
return await queryClient.invalidateQueries({
queryKey: ["is-reposted", event.id],
});
},
});
const submit = (account: string) => {
startTransition(async () => {
if (!status) {
const signer = await commands.hasSigner(account);
if (signer.status === "ok") {
if (!signer.data) {
const newPopup = await LumeWindow.openPopup(
`/set-signer/${account}`,
undefined,
false,
);
setPopup(newPopup);
return;
}
repost.mutate();
} else {
return;
}
} else {
return;
}
});
};
useEffect(() => {
if (!visible) return;
if (!popup) return;
const unlisten = popup.listen("signer-updated", async () => {
repost.mutate();
});
return () => {
unlisten.then((f) => f());
};
}, [popup]);
if (!visible) return null; if (!visible) return null;
return ( return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={(e) => showContextMenu(e)} onClick={(e) => showContextMenu(e)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200", "h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
label label ? "w-24 gap-1.5" : "w-14",
? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)} )}
> >
{loading ? ( {isPending || isLoading ? (
<Spinner className="size-4" /> <Spinner className="size-4" />
) : ( ) : (
<RepostIcon <RepostIcon
className={cn( className={cn(
smol ? "size-4" : "size-5", smol ? "size-4" : "size-5",
isRepost ? "text-blue-500" : "", status ? "text-blue-500" : "",
)} )}
/> />
)} )}
{label ? "Repost" : null} {label ? "Repost" : null}
</button> </button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Repost
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
); );
} }

View File

@@ -20,10 +20,8 @@ export function NoteZap({
type="button" type="button"
onClick={() => LumeWindow.openZap(event.id, search.account)} onClick={() => LumeWindow.openZap(event.id, search.account)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200", "h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
label label ? "w-24 gap-1.5" : "w-14",
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)} )}
> >
<ZapIcon className={smol ? "size-4" : "size-5"} /> <ZapIcon className={smol ? "size-4" : "size-5"} />

View File

@@ -1,4 +1,5 @@
import { NoteOpenThread } from "./buttons/open"; import { NoteOpenThread } from "./buttons/open";
import { NoteQuote } from "./buttons/quote";
import { NoteReply } from "./buttons/reply"; import { NoteReply } from "./buttons/reply";
import { NoteRepost } from "./buttons/repost"; import { NoteRepost } from "./buttons/repost";
import { NoteZap } from "./buttons/zap"; import { NoteZap } from "./buttons/zap";
@@ -16,6 +17,7 @@ export const Note = {
User: NoteUser, User: NoteUser,
Menu: NoteMenu, Menu: NoteMenu,
Reply: NoteReply, Reply: NoteReply,
Quote: NoteQuote,
Repost: NoteRepost, Repost: NoteRepost,
Content: NoteContent, Content: NoteContent,
ContentLarge: NoteContentLarge, ContentLarge: NoteContentLarge,

View File

@@ -51,11 +51,11 @@ export const MentionNote = memo(function MentionNote({
<span className="text-sm text-neutral-500"> <span className="text-sm text-neutral-500">
{replyTime(event.created_at)} {replyTime(event.created_at)}
</span> </span>
<div className="invisible group-hover:visible flex items-center justify-end gap-3"> <div className="invisible group-hover:visible flex items-center justify-end">
<button <button
type="button" type="button"
onClick={() => LumeWindow.openEvent(event)} onClick={() => LumeWindow.openEvent(event)}
className="text-sm font-medium text-blue-500 hover:text-blue-600" className="mr-3 text-sm font-medium text-blue-500 hover:text-blue-600"
> >
Show all Show all
</button> </button>

View File

@@ -1,39 +0,0 @@
import { cn } from "@/commons";
import { Note } from "@/components/note";
import type { LumeEvent } from "@/system";
import { Quotes } from "@phosphor-icons/react";
import { memo } from "react";
export const Quote = memo(function Quote({
event,
className,
}: {
event: LumeEvent;
className?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root className={cn("", className)}>
<div className="flex flex-col gap-3">
<Note.Child event={event.quote} isRoot />
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<Quotes className="size-4" />
Quote
</div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
<div>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
</div>
<Note.Content className="px-3" quote={false} clean />
</div>
</div>
<div className="flex items-center px-3 h-14">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
});

View File

@@ -1,21 +1,12 @@
import { commands } from "@/commands.gen"; import { cn, replyTime } from "@/commons";
import { appSettings, cn, replyTime } from "@/commons";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { type LumeEvent, LumeWindow } from "@/system"; import { type LumeEvent, LumeWindow } from "@/system";
import { CaretDown } from "@phosphor-icons/react"; import { CaretDown } from "@phosphor-icons/react";
import { Link, useSearch } from "@tanstack/react-router"; import { Link, useSearch } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { import { type ReactNode, memo, useCallback, useMemo } from "react";
type ReactNode,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { Hashtag } from "./note/mentions/hashtag"; import { Hashtag } from "./note/mentions/hashtag";
import { MentionUser } from "./note/mentions/user"; import { MentionUser } from "./note/mentions/user";
@@ -28,11 +19,7 @@ export const ReplyNote = memo(function ReplyNote({
event: LumeEvent; event: LumeEvent;
className?: string; className?: string;
}) { }) {
const trustedOnly = useStore(appSettings, (state) => state.trusted_only);
const search = useSearch({ strict: false }); const search = useSearch({ strict: false });
const [isTrusted, setIsTrusted] = useState<boolean>(null);
const showContextMenu = useCallback(async (e: React.MouseEvent) => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@@ -57,24 +44,6 @@ export const ReplyNote = memo(function ReplyNote({
await menu.popup().catch((e) => console.error(e)); await menu.popup().catch((e) => console.error(e));
}, []); }, []);
useEffect(() => {
async function check() {
const res = await commands.isTrustedUser(event.pubkey);
if (res.status === "ok") {
setIsTrusted(res.data);
}
}
if (trustedOnly) {
check();
}
}, []);
if (isTrusted !== null && isTrusted === false) {
return null;
}
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}> <User.Provider pubkey={event.pubkey}>
@@ -99,7 +68,7 @@ export const ReplyNote = memo(function ReplyNote({
<span className="text-sm text-neutral-500"> <span className="text-sm text-neutral-500">
{replyTime(event.created_at)} {replyTime(event.created_at)}
</span> </span>
<div className="flex items-center justify-end gap-5"> <div className="flex items-center justify-end">
<Note.Reply smol /> <Note.Reply smol />
<Note.Repost smol /> <Note.Repost smol />
<Note.Zap smol /> <Note.Zap smol />
@@ -180,7 +149,7 @@ function ChildReply({ event }: { event: LumeEvent }) {
<span className="text-sm text-neutral-500"> <span className="text-sm text-neutral-500">
{replyTime(event.created_at)} {replyTime(event.created_at)}
</span> </span>
<div className="invisible group-hover:visible flex items-center justify-end gap-5"> <div className="invisible group-hover:visible flex items-center justify-end">
<Note.Reply smol /> <Note.Reply smol />
<Note.Repost smol /> <Note.Repost smol />
<Note.Zap smol /> <Note.Zap smol />

View File

@@ -36,7 +36,7 @@ export const RepostNote = memo(function RepostNote({
</div> </div>
<Note.Content className="px-3" /> <Note.Content className="px-3" />
<div className="flex items-center justify-between px-3 mt-3 h-14"> <div className="flex items-center justify-between px-3 mt-3 h-14">
<div className="inline-flex items-center gap-6"> <div className="inline-flex items-center gap-2">
<Note.Open /> <Note.Open />
<Note.Reply /> <Note.Reply />
<Note.Repost /> <Note.Repost />

View File

@@ -18,7 +18,7 @@ export const TextNote = memo(function TextNote({
<Note.Menu /> <Note.Menu />
</div> </div>
<Note.Content className="px-3" /> <Note.Content className="px-3" />
<div className="flex items-center gap-6 px-3 mt-3 h-14"> <div className="flex items-center gap-2 px-3 mt-3 h-14">
<Note.Open /> <Note.Open />
<Note.Reply /> <Note.Reply />
<Note.Repost /> <Note.Repost />

View File

@@ -7,7 +7,7 @@ import { message } from "@tauri-apps/plugin-dialog";
import { useTransition } from "react"; import { useTransition } from "react";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
export function UserFollowButton({ className }: { className?: string }) { export function UserButton({ className }: { className?: string }) {
const user = useUserContext(); const user = useUserContext();
const { queryClient } = useRouteContext({ strict: false }); const { queryClient } = useRouteContext({ strict: false });
@@ -18,7 +18,7 @@ export function UserFollowButton({ className }: { className?: string }) {
} = useQuery({ } = useQuery({
queryKey: ["status", user.pubkey], queryKey: ["status", user.pubkey],
queryFn: async () => { queryFn: async () => {
const res = await commands.checkContact(user.pubkey); const res = await commands.isContact(user.pubkey);
if (res.status === "ok") { if (res.status === "ok") {
return res.data; return res.data;

View File

@@ -1,7 +1,7 @@
import { UserAbout } from "./about"; import { UserAbout } from "./about";
import { UserAvatar } from "./avatar"; import { UserAvatar } from "./avatar";
import { UserButton } from "./button";
import { UserCover } from "./cover"; import { UserCover } from "./cover";
import { UserFollowButton } from "./followButton";
import { UserName } from "./name"; import { UserName } from "./name";
import { UserNip05 } from "./nip05"; import { UserNip05 } from "./nip05";
import { UserProvider } from "./provider"; import { UserProvider } from "./provider";
@@ -17,5 +17,5 @@ export const User = {
NIP05: UserNip05, NIP05: UserNip05,
Time: UserTime, Time: UserTime,
About: UserAbout, About: UserAbout,
Button: UserFollowButton, Button: UserButton,
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -1,117 +0,0 @@
import { cn } from "@/commons";
import { User } from "@/components/user";
import { LumeWindow } from "@/system";
import { CaretDown, Feather, MagnifyingGlass } from "@phosphor-icons/react";
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { memo, useCallback } from "react";
export const Route = createLazyFileRoute("/$account/_app")({
component: Screen,
});
function Screen() {
const context = Route.useRouteContext();
return (
<div className="flex flex-col w-screen h-screen">
<div
data-tauri-drag-region
className={cn(
"flex h-10 shrink-0 items-center justify-between",
context.platform === "macos" ? "pl-[72px] pr-3" : "pr-[156px] pl-3",
)}
>
<div
data-tauri-drag-region
className="relative z-[200] flex-1 flex items-center gap-4"
>
<Account />
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => LumeWindow.openSearch()}
className="inline-flex items-center justify-center size-7 bg-black/5 dark:bg-white/5 rounded-full hover:bg-blue-500 hover:text-white"
>
<MagnifyingGlass className="size-4" />
</button>
<button
type="button"
onClick={() => LumeWindow.openEditor()}
className="inline-flex items-center justify-center h-7 gap-1.5 px-2 text-sm font-medium bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
>
<Feather className="size-4" weight="fill" />
New Post
</button>
</div>
</div>
<div
id="toolbar"
data-tauri-drag-region
className="relative z-[200] flex-1 flex items-center justify-end gap-1"
/>
</div>
<div className="flex-1 bg-neutral-100 dark:bg-neutral-900 border-t-[.5px] border-black/20 dark:border-white/20">
<Outlet />
</div>
</div>
);
}
const Account = memo(function Account() {
const params = Route.useParams();
const navigate = Route.useNavigate();
const showContextMenu = useCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "New Post",
action: () => LumeWindow.openEditor(),
}),
MenuItem.new({
text: "Profile",
action: () => LumeWindow.openProfile(params.account),
}),
MenuItem.new({
text: "Settings",
action: () => LumeWindow.openSettings(params.account),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Copy Public Key",
action: async () => await writeText(params.account),
}),
MenuItem.new({
text: "Logout",
action: () => navigate({ to: "/" }),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
},
[params.account],
);
return (
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center gap-1.5"
>
<User.Provider pubkey={params.account}>
<User.Root className="shrink-0 rounded-full">
<User.Avatar className="rounded-full size-7" />
</User.Root>
</User.Provider>
<CaretDown className="size-3" />
</button>
);
});

View File

@@ -1,3 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_app")();

View File

@@ -1,40 +0,0 @@
import { commands } from "@/commands.gen";
import { NostrAccount } from "@/system";
import { Button } from "@getalby/bitcoin-connect-react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
export const Route = createLazyFileRoute("/$account/_settings/bitcoin-connect")(
{
component: Screen,
},
);
function Screen() {
const setNwcUri = async (uri: string) => {
const res = await commands.setWallet(uri);
if (res.status === "ok") {
await getCurrentWebviewWindow().close();
} else {
throw new Error(res.error);
}
};
return (
<div className="flex items-center justify-center size-full">
<div className="flex flex-col items-center justify-center gap-3 text-center">
<div>
<p className="text-sm text-black/70 dark:text-white/70">
Click to the button below to connect with your Bitcoin wallet.
</p>
</div>
<Button
onConnected={(provider) =>
setNwcUri(provider.client.nostrWalletConnectUrl)
}
/>
</div>
</div>
);
}

View File

@@ -1,185 +0,0 @@
import { commands } from "@/commands.gen";
import { appSettings } from "@/commons";
import { Spinner } from "@/components";
import * as Switch from "@radix-ui/react-switch";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { invoke } from "@tauri-apps/api/core";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useState, useTransition } from "react";
type Theme = "auto" | "light" | "dark";
export const Route = createLazyFileRoute("/$account/_settings/general")({
component: Screen,
});
function Screen() {
const [theme, setTheme] = useState<Theme>(null);
const [isPending, startTransition] = useTransition();
const changeTheme = useCallback(async (theme: string) => {
if (theme === "auto" || theme === "light" || theme === "dark") {
invoke("plugin:theme|set_theme", {
theme: theme,
}).then(() => setTheme(theme));
}
}, []);
const updateSettings = () => {
startTransition(async () => {
const newSettings = JSON.stringify(appSettings.state);
const res = await commands.setUserSettings(newSettings);
if (res.status === "error") {
await message(res.error, { kind: "error" });
}
return;
});
};
useEffect(() => {
invoke("plugin:theme|get_theme").then((data) => setTheme(data as Theme));
}, []);
return (
<div className="relative w-full">
<div className="flex flex-col gap-6 px-3 pb-3">
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
General
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<Setting
name="Relay Hint"
description="Use the relay hint if necessary."
label="use_relay_hint"
/>
<Setting
name="Content Warning"
description="Shows a warning for notes that have a content warning."
label="content_warning"
/>
<Setting
name="Trusted Only"
description="Only shows note's replies from your inner circle."
label="trusted_only"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Appearance
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Appearance</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Change app theme
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<select
name="theme"
className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
defaultValue={theme}
onChange={(e) => changeTheme(e.target.value)}
>
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
<Setting
name="Transparent Effect"
description="Use native window transparent effect."
label="transparent"
/>
<Setting
name="Show Zap Button"
description="Shows the Zap button when viewing a note."
label="display_zap_button"
/>
<Setting
name="Show Repost Button"
description="Shows the Repost button when viewing a note."
label="display_repost_button"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Privacy & Performance
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<Setting
name="Proxy"
description="Set proxy address."
label="proxy"
/>
<Setting
name="Image Resize Service"
description="Use weserv/images for resize image on-the-fly."
label="image_resize_service"
/>
<Setting
name="Load Remote Media"
description="View the remote media directly."
label="display_media"
/>
</div>
</div>
</div>
<div className="sticky bottom-0 left-0 w-full h-16 flex items-center justify-end px-3">
<div className="absolute left-0 bottom-0 w-full h-11 gradient-mask-t-0 bg-neutral-100 dark:bg-neutral-900" />
<button
type="button"
onClick={() => updateSettings()}
className="relative z-10 inline-flex items-center justify-center w-20 rounded-md shadow h-8 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium"
>
{isPending ? <Spinner className="size-4" /> : "Update"}
</button>
</div>
</div>
);
}
function Setting({
label,
name,
description,
}: { label: string; name: string; description: string }) {
const state = useStore(appSettings, (state) => state[label]);
const toggle = useCallback(() => {
appSettings.setState((state) => {
return {
...state,
[label]: !state[label],
};
});
}, []);
return (
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">{name}</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{description}
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={state}
onClick={() => toggle()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
);
}

View File

@@ -1,17 +0,0 @@
import { commands } from "@/commands.gen";
import { appSettings } from "@/commons";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_settings/general")({
beforeLoad: async () => {
const res = await commands.getUserSettings();
if (res.status === "ok") {
appSettings.setState((state) => {
return { ...state, ...res.data };
});
} else {
throw new Error(res.error);
}
},
});

View File

@@ -1,15 +0,0 @@
import { commands } from "@/commands.gen";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_settings/relay")({
beforeLoad: async () => {
const res = await commands.getRelays();
if (res.status === "ok") {
const relayList = res.data;
return { relayList };
} else {
throw new Error(res.error);
}
},
});

View File

@@ -1,55 +0,0 @@
import { commands } from "@/commands.gen";
import { createLazyFileRoute, redirect } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/$account/_settings/wallet")({
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
const { balance } = Route.useRouteContext();
const disconnect = async () => {
const res = await commands.removeWallet();
if (res.status === "ok") {
window.localStorage.removeItem("bc:config");
return redirect({ to: "/$account/bitcoin-connect", params: { account } });
} else {
throw new Error(res.error);
}
};
return (
<div className="w-full px-3 pb-3">
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col w-full px-3 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Connection</h3>
</div>
<div className="flex justify-end w-36 shrink-0">
<button
type="button"
onClick={() => disconnect()}
className="h-8 w-max px-2.5 text-sm rounded-lg inline-flex items-center justify-center bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
>
Disconnect
</button>
</div>
</div>
</div>
<div className="flex flex-col w-full px-3 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Current Balance</h3>
</div>
<div className="flex justify-end w-36 shrink-0">
{balance.bitcoinFormatted}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { commands } from "@/commands.gen";
import { getBitcoinDisplayValues } from "@/commons";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_settings/wallet")({
beforeLoad: async ({ params }) => {
const query = await commands.loadWallet();
if (query.status === "ok") {
const wallet = Number.parseInt(query.data);
const balance = getBitcoinDisplayValues(wallet);
return { balance };
} else {
throw redirect({
to: "/$account/bitcoin-connect",
params: { account: params.account },
});
}
},
});

View File

@@ -1,177 +0,0 @@
import { displayNsec } from "@/commons";
import { Spinner } from "@/components";
import { Check } from "@phosphor-icons/react";
import * as Checkbox from "@radix-ui/react-checkbox";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
export const Route = createFileRoute("/$account/backup")({
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
const navigate = useNavigate();
const [key, setKey] = useState(null);
const [passphase, setPassphase] = useState("");
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false });
const submit = async () => {
try {
if (key) {
if (!confirm.c1 || !confirm.c2) {
return await message("You need to confirm before continue", {
title: "Backup",
kind: "info",
});
}
navigate({ to: "/", replace: true });
}
// start loading
setLoading(true);
invoke("get_encrypted_key", {
npub: account,
password: passphase,
}).then((encrypted: string) => {
// update state
setKey(encrypted);
setLoading(false);
});
} catch (e) {
setLoading(false);
await message(String(e), {
title: "Backup",
kind: "error",
});
}
};
const copyKey = async () => {
try {
await writeText(key);
setCopied(true);
} catch (e) {
await message(String(e), {
title: "Backup",
kind: "error",
});
}
};
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="flex flex-col text-center">
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
<p className="text-neutral-700 dark:text-neutral-300">
It's use for login to Lume or other Nostr clients. You will lost
access to your account if you lose this key.
</p>
</div>
<div className="flex flex-col w-full gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="passphase" className="font-medium">
Set a passphase to secure your key
</label>
<div className="relative">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
</div>
{key ? (
<>
<div className="flex flex-col gap-2">
<label htmlFor="nsec" className="font-medium">
Copy this key and keep it in safe place
</label>
<div className="flex items-center gap-2">
<input
name="nsec"
type="text"
value={key}
readOnly
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => copyKey()}
className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="font-medium">Before you continue:</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c1}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c1: !state.c1 }))
}
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm1"
>
<Checkbox.Indicator className="text-blue-500">
<Check className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm1"
>
I will make sure keep it safe and not sharing with anyone.
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c2}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c2: !state.c2 }))
}
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm2"
>
<Checkbox.Indicator className="text-blue-500">
<Check className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm2"
>
I understand I cannot recover private key.
</label>
</div>
</div>
</div>
</>
) : null}
<div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Continue"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -3,13 +3,13 @@ import { appSettings } from "@/commons";
import { Spinner } from "@/components"; import { Spinner } from "@/components";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import type { OsType } from "@tauri-apps/plugin-os"; import type { OsType } from "@tauri-apps/plugin-os";
import { useEffect } from "react"; import { useEffect } from "react";
interface RouterContext { interface RouterContext {
queryClient: QueryClient; queryClient: QueryClient;
platform: OsType; platform: OsType;
accounts: string[];
} }
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
@@ -33,8 +33,9 @@ function Screen() {
}, []); }, []);
useEffect(() => { useEffect(() => {
const unlisten = listen("synchronized", async () => { const unlisten = events.negentropyEvent.listen(async (data) => {
await queryClient.invalidateQueries(); const queryKey = [data.payload.kind.toLowerCase()];
await queryClient.invalidateQueries({ queryKey });
}); });
return () => { return () => {

195
src/routes/_layout.lazy.tsx Normal file
View File

@@ -0,0 +1,195 @@
import { commands } from "@/commands.gen";
import { cn } from "@/commons";
import { PublishIcon } from "@/components";
import { User } from "@/components/user";
import { LumeWindow } from "@/system";
import { MagnifyingGlass, Plus } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { Link, Outlet, createLazyFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { memo, useCallback, useEffect, useState } from "react";
export const Route = createLazyFileRoute("/_layout")({
component: Layout,
});
function Layout() {
return (
<div className="flex flex-col w-screen h-screen">
<Topbar />
<div className="flex-1 bg-neutral-100 dark:bg-neutral-900 border-t-[.5px] border-black/20 dark:border-white/20">
<Outlet />
</div>
</div>
);
}
function Topbar() {
const { platform, accounts } = Route.useRouteContext();
return (
<div
data-tauri-drag-region
className={cn(
"flex h-10 shrink-0 items-center justify-between",
platform === "macos" ? "pl-[72px] pr-3" : "pr-[156px] pl-3",
)}
>
<div
data-tauri-drag-region
className="relative z-[200] h-10 flex-1 flex items-center gap-2"
>
{accounts?.map((account) => (
<Account key={account} pubkey={account} />
))}
<Link
to="/new"
className="inline-flex items-center justify-center size-7 bg-black/5 dark:bg-white/5 rounded-full hover:bg-blue-500 hover:text-white"
>
<Plus className="size-4" weight="bold" />
</Link>
</div>
<div
data-tauri-drag-region
className="relative z-[200] flex-1 flex items-center justify-end gap-4"
>
{accounts?.length ? (
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={() => LumeWindow.openEditor()}
className="inline-flex items-center justify-center h-7 gap-1 px-2 text-sm font-medium bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
>
<PublishIcon className="size-4" />
New Post
</button>
<button
type="button"
onClick={() => LumeWindow.openSearch()}
className="inline-flex items-center justify-center size-7 bg-black/5 dark:bg-white/5 rounded-full hover:bg-blue-500 hover:text-white"
>
<MagnifyingGlass className="size-4" />
</button>
</div>
) : null}
<div id="toolbar" className="inline-flex items-center gap-2" />
</div>
</div>
);
}
const NegentropyBadge = memo(function NegentropyBadge() {
const [process, setProcess] = useState(null);
useEffect(() => {
const unlisten = listen("negentropy", async (data) => {
if (data.payload === "Ok") {
setProcess(null);
} else {
setProcess(data.payload);
}
});
return () => {
unlisten.then((f) => f());
};
}, []);
if (!process) {
return null;
}
return (
<div className="h-7 w-max px-3 inline-flex items-center justify-center text-[9px] font-medium rounded-full bg-black/5 dark:bg-white/5">
{process ? (
<span>
{process.message}
{process.total_event > 0 ? ` / ${process.total_event}` : null}
</span>
) : (
"Syncing"
)}
</div>
);
});
function Account({ pubkey }: { pubkey: string }) {
const navigate = Route.useNavigate();
const context = Route.useRouteContext();
const { data: isActive } = useQuery({
queryKey: ["signer", pubkey],
queryFn: async () => {
const res = await commands.hasSigner(pubkey);
if (res.status === "ok") {
return res.data;
} else {
return false;
}
},
});
const showContextMenu = useCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
const items = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(pubkey),
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => await writeText(pubkey),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Settings",
action: () => LumeWindow.openSettings(pubkey),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Logout",
action: async () => {
const res = await commands.deleteAccount(pubkey);
if (res.status === "ok") {
const newAccounts = context.accounts.filter(
(account) => account !== pubkey,
);
if (newAccounts.length < 1) {
navigate({ to: "/", replace: true });
}
}
},
}),
]);
const menu = await Menu.new({ items });
await menu.popup().catch((e) => console.error(e));
},
[pubkey],
);
return (
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="h-10 relative"
>
<User.Provider pubkey={pubkey}>
<User.Root className="shrink-0 rounded-full">
<User.Avatar className="rounded-full size-7" />
</User.Root>
</User.Provider>
{isActive ? (
<div className="h-px w-full absolute bottom-0 left-0 bg-green-500 rounded-full" />
) : null}
</button>
);
}

14
src/routes/_layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { commands } from "@/commands.gen";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/_layout")({
beforeLoad: async () => {
const accounts = await commands.getAccounts();
if (!accounts.length) {
throw redirect({ to: "/new", replace: true });
}
return { accounts };
},
});

View File

@@ -1,13 +1,11 @@
import { appColumns } from "@/commons"; import { appColumns } from "@/commons";
import { Spinner } from "@/components"; import { Column, Spinner } from "@/components";
import { Column } from "@/components/column";
import { LumeWindow } from "@/system"; import { LumeWindow } from "@/system";
import type { ColumnEvent, LumeColumn } from "@/types"; import type { ColumnEvent, LumeColumn } from "@/types";
import { ArrowLeft, ArrowRight, Plus, StackPlus } from "@phosphor-icons/react"; import { ArrowLeft, ArrowRight, Plus, StackPlus } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store"; import { useStore } from "@tanstack/react-store";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { resolveResource } from "@tauri-apps/api/path"; import { resolveResource } from "@tauri-apps/api/path";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs"; import { readTextFile } from "@tauri-apps/plugin-fs";
@@ -23,12 +21,11 @@ import {
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
export const Route = createLazyFileRoute("/$account/_app/home")({ export const Route = createLazyFileRoute("/_layout/")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const params = Route.useParams();
const columns = useStore(appColumns, (state) => state); const columns = useStore(appColumns, (state) => state);
const [emblaRef, emblaApi] = useEmblaCarousel({ const [emblaRef, emblaApi] = useEmblaCarousel({
@@ -158,9 +155,7 @@ function Screen() {
} }
if (!columns.length) { if (!columns.length) {
const prevColumns = window.localStorage.getItem( const prevColumns = window.localStorage.getItem("columns");
`${params.account}_columns`,
);
if (!prevColumns) { if (!prevColumns) {
getSystemColumns(); getSystemColumns();
@@ -169,10 +164,7 @@ function Screen() {
appColumns.setState(() => parsed); appColumns.setState(() => parsed);
} }
} else { } else {
window.localStorage.setItem( window.localStorage.setItem("columns", JSON.stringify(columns));
`${params.account}_columns`,
JSON.stringify(columns),
);
} }
}, [columns.length]); }, [columns.length]);
@@ -193,7 +185,7 @@ function Screen() {
<div className="size-full flex items-center justify-center"> <div className="size-full flex items-center justify-center">
<button <button
type="button" type="button"
onClick={() => LumeWindow.openColumnsGallery()} onClick={() => LumeWindow.openLaunchpad()}
className="inline-flex items-center justify-center gap-1 rounded-full text-sm font-medium h-8 w-max pl-2 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10" className="inline-flex items-center justify-center gap-1 rounded-full text-sm font-medium h-8 w-max pl-2 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
> >
<Plus className="size-4" /> <Plus className="size-4" />
@@ -204,7 +196,13 @@ function Screen() {
</div> </div>
</div> </div>
<Toolbar> <Toolbar>
<ManageButton /> <button
type="button"
onClick={() => LumeWindow.openLaunchpad()}
className="inline-flex items-center justify-center rounded-full size-7 hover:bg-black/5 dark:hover:bg-white/5"
>
<StackPlus className="size-4" />
</button>
<button <button
type="button" type="button"
onClick={() => scrollPrev()} onClick={() => scrollPrev()}
@@ -224,44 +222,6 @@ function Screen() {
); );
} }
function ManageButton() {
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Open Launchpad",
action: () => LumeWindow.openColumnsGallery(),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Open Newsfeed",
action: () => LumeWindow.openLocalFeeds(),
}),
MenuItem.new({
text: "Open Notification",
action: () => LumeWindow.openNotification(),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
return (
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center rounded-full size-7 hover:bg-black/5 dark:hover:bg-white/5"
>
<StackPlus className="size-4" />
</button>
);
}
function Toolbar({ children }: { children: ReactNode[] }) { function Toolbar({ children }: { children: ReactNode[] }) {
const [domReady, setDomReady] = useState(false); const [domReady, setDomReady] = useState(false);

View File

@@ -0,0 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_layout/')()

View File

@@ -44,20 +44,22 @@ function Screen() {
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className="size-full flex items-center justify-center" className="bg-white/50 dark:bg-black/50 size-full flex items-center justify-center"
> >
<div className="w-[320px] flex flex-col gap-8"> <div className="w-[340px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center"> <div className="flex flex-col gap-1 text-center">
<h1 className="leading-tight text-xl font-semibold">Nostr Connect</h1> <h1 className="leading-tight text-xl font-semibold">
Continue with Nostr Connect
</h1>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-5">
<Frame <Frame
className="flex flex-col gap-1 p-3 rounded-xl overflow-hidden" className="flex flex-col gap-3 p-4 rounded-xl overflow-hidden"
shadow shadow
> >
<label <label
htmlFor="uri" htmlFor="uri"
className="font-medium text-neutral-900 dark:text-neutral-100" className="text-sm font-semibold text-neutral-800 dark:text-neutral-200"
> >
Connection String Connection String
</label> </label>
@@ -68,7 +70,7 @@ function Screen() {
placeholder="bunker://..." placeholder="bunker://..."
value={uri} value={uri}
onChange={(e) => setUri(e.target.value)} onChange={(e) => setUri(e.target.value)}
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400" className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-700 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/> />
<button <button
type="button" type="button"

View File

@@ -46,7 +46,6 @@ function Screen() {
navigate({ to: "/", replace: true }); navigate({ to: "/", replace: true });
} else { } else {
await message(res.error, { await message(res.error, {
title: "Import Private Ket",
kind: "error", kind: "error",
}); });
return; return;
@@ -57,23 +56,23 @@ function Screen() {
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className="size-full flex items-center justify-center" className="bg-white/50 dark:bg-black/50 size-full flex items-center justify-center"
> >
<div className="w-[320px] flex flex-col gap-8"> <div className="w-[340px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center"> <div className="flex flex-col gap-1 text-center">
<h1 className="leading-tight text-xl font-semibold"> <h1 className="leading-tight text-xl font-semibold">
Import Private Key Continue with Secret Key
</h1> </h1>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-5">
<Frame <Frame
className="flex flex-col gap-3 p-3 rounded-xl overflow-hidden" className="flex flex-col gap-3 p-4 rounded-xl overflow-hidden"
shadow shadow
> >
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2.5">
<label <label
htmlFor="key" htmlFor="key"
className="text-sm font-medium text-neutral-800 dark:text-neutral-200" className="text-sm font-semibold text-neutral-800 dark:text-neutral-200"
> >
Private Key Private Key
</label> </label>
@@ -84,7 +83,7 @@ function Screen() {
placeholder="nsec or ncryptsec..." placeholder="nsec or ncryptsec..."
value={key} value={key}
onChange={(e) => setKey(e.target.value)} onChange={(e) => setKey(e.target.value)}
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400" className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-700 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/> />
<button <button
type="button" type="button"
@@ -110,7 +109,7 @@ function Screen() {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none" className="px-3 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-700 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/> />
</div> </div>
) : null} ) : null}

View File

@@ -1,166 +0,0 @@
import { commands } from "@/commands.gen";
import { upload } from "@/commons";
import { Frame, GoBack, Spinner } from "@/components";
import { Plus } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react";
export const Route = createLazyFileRoute("/auth/new")({
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const [password, setPassword] = useState("");
const [picture, setPicture] = useState<string>("");
const [name, setName] = useState("");
const [about, setAbout] = useState("");
const [isPending, startTransition] = useTransition();
const uploadAvatar = async () => {
const file = await upload();
if (file) {
setPicture(file);
} else {
return;
}
};
const submit = () => {
startTransition(async () => {
if (!name.length) {
await message("Please add your name", {
title: "New Identity",
kind: "info",
});
return;
}
if (!password.length) {
await message("You must set password to secure your account", {
title: "New Identity",
kind: "info",
});
return;
}
const res = await commands.createAccount(name, picture, about, password);
if (res.status === "ok") {
navigate({
to: "/",
replace: true,
});
} else {
await message(res.error, {
title: "New Identity",
kind: "error",
});
return;
}
});
};
return (
<div
data-tauri-drag-region
className="size-full flex items-center justify-center"
>
<div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h1 className="leading-tight text-xl font-semibold">New Identity</h1>
</div>
<div className="flex flex-col gap-3">
<Frame
className="flex flex-col gap-3 p-3 rounded-xl overflow-hidden"
shadow
>
<div className="self-center relative rounded-full size-20 bg-neutral-100 dark:bg-white/10 my-3">
{picture.length ? (
<img
src={picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
/>
) : null}
<button
type="button"
onClick={() => uploadAvatar()}
className="absolute inset-0 z-20 flex items-center justify-center w-full h-full rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<Plus className="size-5" />
</button>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
>
Name *
</label>
<input
name="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Alice"
spellCheck={false}
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
>
About
</label>
<textarea
name="about"
value={about}
onChange={(e) => setAbout(e.target.value)}
placeholder="e.g. Artist, anime-lover, and k-pop fan"
spellCheck={false}
className="px-3 py-1.5 rounded-lg min-h-16 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
/>
</div>
<div className="h-px w-full mt-2 bg-neutral-100 dark:bg-neutral-900" />
<div className="flex flex-col gap-1">
<label
htmlFor="password"
className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
>
Set password to secure your account *
</label>
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
/>
</div>
</Frame>
<div className="flex flex-col items-center gap-1">
<button
type="button"
onClick={() => submit()}
disabled={isPending}
className="inline-flex items-center justify-center w-full h-9 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? <Spinner /> : "Continue"}
</button>
<GoBack className="mt-2 w-full text-sm text-neutral-600 dark:text-neutral-400 inline-flex items-center justify-center">
Go back to previous screen
</GoBack>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { commands } from "@/commands.gen";
import { Frame, GoBack } from "@/components";
import { Spinner } from "@/components/spinner";
import { createLazyFileRoute } from "@tanstack/react-router";
import { readText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react";
export const Route = createLazyFileRoute("/auth/watch")({
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const [key, setKey] = useState("");
const [isPending, startTransition] = useTransition();
const pasteFromClipboard = async () => {
const val = await readText();
setKey(val);
};
const submit = () => {
startTransition(async () => {
if (!key.startsWith("npub") && !key.startsWith("nprofile")) {
await message(
"You need to enter a valid public key starts with npub or nprofile",
{ title: "Login", kind: "info" },
);
return;
}
const res = await commands.watchAccount(key);
if (res.status === "ok") {
navigate({ to: "/", replace: true });
} else {
await message(res.error, {
kind: "error",
});
return;
}
});
};
return (
<div
data-tauri-drag-region
className="bg-white/50 dark:bg-black/50 size-full flex items-center justify-center"
>
<div className="w-[340px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h1 className="leading-tight text-xl font-semibold">
Continue with Public Key (Watch Mode)
</h1>
</div>
<div className="flex flex-col gap-5">
<Frame
className="flex flex-col gap-3 p-4 rounded-xl overflow-hidden"
shadow
>
<div className="flex flex-col gap-2.5">
<label
htmlFor="key"
className="text-sm font-semibold text-neutral-800 dark:text-neutral-200"
>
Public Key
</label>
<div className="relative">
<input
name="key"
type="password"
placeholder="npub or nprofile..."
value={key}
onChange={(e) => setKey(e.target.value)}
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-700 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => pasteFromClipboard()}
className="absolute top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500 dark:text-blue-300"
>
Paste
</button>
</div>
</div>
</Frame>
<div className="flex flex-col items-center gap-1">
<button
type="button"
onClick={() => submit()}
disabled={isPending}
className="inline-flex items-center justify-center w-full h-9 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? <Spinner /> : "Continue"}
</button>
<GoBack className="mt-2 w-full text-sm text-neutral-600 dark:text-neutral-400 inline-flex items-center justify-center">
Go back to previous screen
</GoBack>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,12 +1,16 @@
import { commands } from "@/commands.gen"; import { commands } from "@/commands.gen";
import { appSettings } from "@/commons"; import { appSettings } from "@/commons";
import type { ColumnRouteSearch } from "@/types";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
export interface RouteSearch {
label?: string;
name?: string;
redirect?: string;
}
export const Route = createFileRoute("/columns/_layout")({ export const Route = createFileRoute("/columns/_layout")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): RouteSearch => {
return { return {
account: search.account,
label: search.label, label: search.label,
name: search.name, name: search.name,
}; };

View File

@@ -15,7 +15,7 @@ export const Route = createFileRoute("/columns/_layout/global")({
}); });
export function Screen() { export function Screen() {
const { label, account } = Route.useSearch(); const { label } = Route.useSearch();
const { const {
data, data,
isLoading, isLoading,
@@ -24,7 +24,7 @@ export function Screen() {
hasNextPage, hasNextPage,
fetchNextPage, fetchNextPage,
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: [label, account], queryKey: ["events", "global", label],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const until = pageParam > 0 ? pageParam.toString() : undefined; const until = pageParam > 0 ? pageParam.toString() : undefined;

View File

@@ -26,7 +26,7 @@ export function Screen() {
hasNextPage, hasNextPage,
fetchNextPage, fetchNextPage,
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: ["groups", params.id], queryKey: ["events", "groups", params.id],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const until = pageParam > 0 ? pageParam.toString() : undefined; const until = pageParam > 0 ? pageParam.toString() : undefined;

View File

@@ -26,7 +26,7 @@ export function Screen() {
hasNextPage, hasNextPage,
fetchNextPage, fetchNextPage,
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: ["hashtags", params.id], queryKey: ["events", "hashtags", params.id],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const tags = hashtags.map((tag) => tag.toLowerCase().replace("#", "")); const tags = hashtags.map((tag) => tag.toLowerCase().replace("#", ""));

View File

@@ -26,6 +26,7 @@ function Screen() {
<ScrollArea.Viewport className="relative h-full px-3 pb-3"> <ScrollArea.Viewport className="relative h-full px-3 pb-3">
<Groups /> <Groups />
<Interests /> <Interests />
<Accounts />
<Core /> <Core />
</ScrollArea.Viewport> </ScrollArea.Viewport>
<ScrollArea.Scrollbar <ScrollArea.Scrollbar
@@ -39,66 +40,9 @@ function Screen() {
); );
} }
function Core() {
const { isLoading, data } = useQuery({
queryKey: ["core"],
queryFn: async () => {
const systemPath = "resources/columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
const columns = systemColumns.filter((col) => !col.default);
return columns;
},
refetchOnWindowFocus: false,
});
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between px-2">
<h3 className="font-semibold">Core</h3>
</div>
<div className="flex flex-col gap-3">
{isLoading ? (
<div className="inline-flex items-center gap-1.5">
<Spinner className="size-4" />
Loading...
</div>
) : (
data.map((column) => (
<div
key={column.label}
className="group flex px-4 items-center justify-between h-16 rounded-xl bg-white dark:bg-black border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<div className="text-sm">
<div className="mb-px leading-tight font-semibold">
{column.name}
</div>
<div className="leading-tight text-neutral-500 dark:text-neutral-400">
{column.description}
</div>
</div>
<button
type="button"
onClick={() => LumeWindow.openColumn(column)}
className="text-xs uppercase font-semibold w-16 h-7 hidden group-hover:inline-flex items-center justify-center rounded-full bg-neutral-200 hover:bg-blue-500 hover:text-white dark:bg-black/10"
>
Open
</button>
</div>
))
)}
</div>
</div>
);
}
function Groups() { function Groups() {
const { account } = Route.useSearch();
const { isLoading, data, refetch, isRefetching } = useQuery({ const { isLoading, data, refetch, isRefetching } = useQuery({
queryKey: ["groups", account], queryKey: ["others", "groups"],
queryFn: async () => { queryFn: async () => {
const res = await commands.getAllGroups(); const res = await commands.getAllGroups();
@@ -125,9 +69,10 @@ function Groups() {
return ( return (
<div <div
key={item.id} key={item.id}
className="group flex flex-col rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50 border-[.5px] border-neutral-300 dark:border-neutral-700" className="group flex flex-col rounded-xl overflow-hidden bg-white dark:bg-neutral-800/50 shadow-lg shadow-primary dark:ring-1 dark:ring-neutral-800"
> >
<div className="p-3 h-16 flex flex-wrap items-center justify-center gap-2 overflow-y-auto"> <div className="px-2 pt-2">
<div className="p-3 h-16 bg-neutral-100 rounded-lg flex flex-wrap items-center justify-center gap-2 overflow-y-auto">
{item.tags {item.tags
.filter((tag) => tag[0] === "p") .filter((tag) => tag[0] === "p")
.map((tag) => ( .map((tag) => (
@@ -140,8 +85,16 @@ function Groups() {
</div> </div>
))} ))}
</div> </div>
<div className="p-3 flex items-center justify-between"> </div>
<div className="text-sm font-medium">{name}</div> <div className="p-2 flex items-center justify-between">
<div className="inline-flex items-center gap-2">
<User.Provider pubkey={item.pubkey}>
<User.Root>
<User.Avatar className="size-7 rounded-full" />
</User.Root>
</User.Provider>
<h5 className="text-xs font-medium">{name}</h5>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
@@ -181,9 +134,7 @@ function Groups() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => onClick={() => LumeWindow.openPopup("/set-group", "New group")}
LumeWindow.openPopup("New group", `/set-group?account=${account}`)
}
className="h-7 w-max px-2 inline-flex items-center justify-center gap-1 text-sm font-medium rounded-full bg-neutral-300 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white" className="h-7 w-max px-2 inline-flex items-center justify-center gap-1 text-sm font-medium rounded-full bg-neutral-300 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
> >
<Plus className="size-3" weight="bold" /> <Plus className="size-3" weight="bold" />
@@ -210,9 +161,8 @@ function Groups() {
} }
function Interests() { function Interests() {
const { account } = Route.useSearch();
const { isLoading, data, refetch, isRefetching } = useQuery({ const { isLoading, data, refetch, isRefetching } = useQuery({
queryKey: ["interests", account], queryKey: ["others", "interests"],
queryFn: async () => { queryFn: async () => {
const res = await commands.getAllInterests(); const res = await commands.getAllInterests();
@@ -240,9 +190,10 @@ function Interests() {
return ( return (
<div <div
key={item.id} key={item.id}
className="group flex flex-col rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50 border-[.5px] border-neutral-300 dark:border-neutral-700" className="group flex flex-col rounded-xl overflow-hidden bg-white dark:bg-neutral-800/50 shadow-lg shadow-primary dark:ring-1 dark:ring-neutral-800"
> >
<div className="p-3 h-16 flex flex-wrap items-center justify-center gap-2 overflow-y-auto"> <div className="px-2 pt-2">
<div className="p-3 h-16 bg-neutral-100 rounded-lg flex flex-wrap items-center justify-center gap-4 overflow-y-auto">
{item.tags {item.tags
.filter((tag) => tag[0] === "t") .filter((tag) => tag[0] === "t")
.map((tag) => ( .map((tag) => (
@@ -251,8 +202,16 @@ function Interests() {
</div> </div>
))} ))}
</div> </div>
</div>
<div className="p-3 flex items-center justify-between"> <div className="p-3 flex items-center justify-between">
<div className="text-sm font-medium">{name}</div> <div className="inline-flex items-center gap-2">
<User.Provider pubkey={item.pubkey}>
<User.Root>
<User.Avatar className="size-7 rounded-full" />
</User.Root>
</User.Provider>
<h5 className="text-xs font-medium">{name}</h5>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
@@ -293,10 +252,7 @@ function Interests() {
<button <button
type="button" type="button"
onClick={() => onClick={() =>
LumeWindow.openPopup( LumeWindow.openPopup("/set-interest", "New interest")
"New interest",
`/set-interest?account=${account}`,
)
} }
className="h-7 w-max px-2 inline-flex items-center justify-center gap-1 text-sm font-medium rounded-full bg-neutral-300 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white" className="h-7 w-max px-2 inline-flex items-center justify-center gap-1 text-sm font-medium rounded-full bg-neutral-300 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
> >
@@ -322,3 +278,134 @@ function Interests() {
</div> </div>
); );
} }
function Accounts() {
const { isLoading, data: accounts } = useQuery({
queryKey: ["accounts"],
queryFn: async () => {
const res = await commands.getAccounts();
return res;
},
refetchOnWindowFocus: false,
});
return (
<div className="mb-12 flex flex-col gap-3">
<div className="flex items-center justify-between px-2">
<h3 className="font-semibold">Accounts</h3>
</div>
<div className="flex flex-col gap-3">
{isLoading ? (
<div className="inline-flex items-center gap-1.5 text-sm">
<Spinner className="size-4" />
Loading...
</div>
) : (
accounts.map((account) => (
<div
key={account}
className="group flex flex-col rounded-xl overflow-hidden bg-white dark:bg-neutral-800/50 shadow-lg shadow-primary dark:ring-1 dark:ring-neutral-800"
>
<div className="px-2 pt-2">
<User.Provider pubkey={account}>
<User.Root className="inline-flex items-center gap-2">
<User.Avatar className="size-7 rounded-full" />
<User.Name className="text-xs font-medium" />
</User.Root>
</User.Provider>
</div>
<div className="flex flex-col gap-2 p-2">
<div className="px-3 flex items-center justify-between h-11 rounded-lg bg-neutral-100 dark:bg-neutral-800">
<div className="text-sm font-medium">Newsfeed</div>
<button
type="button"
onClick={() => LumeWindow.openNewsfeed(account)}
className="h-6 w-16 inline-flex items-center justify-center gap-1 text-xs font-semibold rounded-full bg-neutral-200 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
>
Add
</button>
</div>
<div className="px-3 flex items-center justify-between h-11 rounded-lg bg-neutral-100 dark:bg-neutral-800">
<div className="text-sm font-medium">Stories</div>
<button
type="button"
onClick={() => LumeWindow.openStory(account)}
className="h-6 w-16 inline-flex items-center justify-center gap-1 text-xs font-semibold rounded-full bg-neutral-200 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
>
Add
</button>
</div>
<div className="px-3 flex items-center justify-between h-11 rounded-lg bg-neutral-100 dark:bg-neutral-800">
<div className="text-sm font-medium">Notification</div>
<button
type="button"
onClick={() => LumeWindow.openNotification(account)}
className="h-6 w-16 inline-flex items-center justify-center gap-1 text-xs font-semibold rounded-full bg-neutral-200 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
>
Add
</button>
</div>
</div>
</div>
))
)}
</div>
</div>
);
}
function Core() {
const { isLoading, data } = useQuery({
queryKey: ["other-columns"],
queryFn: async () => {
const systemPath = "resources/columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
const columns = systemColumns.filter((col) => !col.default);
return columns;
},
refetchOnWindowFocus: false,
});
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between px-2">
<h3 className="font-semibold">Others</h3>
</div>
<div className="flex flex-col gap-3">
{isLoading ? (
<div className="inline-flex items-center gap-1.5 text-sm">
<Spinner className="size-4" />
Loading...
</div>
) : (
data.map((column) => (
<div
key={column.label}
className="group flex px-4 items-center justify-between h-16 rounded-xl bg-white dark:bg-black border-[.5px] border-neutral-300 dark:border-neutral-700"
>
<div className="text-sm">
<div className="mb-px leading-tight font-semibold">
{column.name}
</div>
<div className="leading-tight text-neutral-500 dark:text-neutral-400">
{column.description}
</div>
</div>
<button
type="button"
onClick={() => LumeWindow.openColumn(column)}
className="text-xs font-semibold w-16 h-7 hidden group-hover:inline-flex items-center justify-center rounded-full bg-neutral-200 hover:bg-blue-500 hover:text-white dark:bg-black/10"
>
Add
</button>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -1,35 +1,23 @@
import { events, commands } from "@/commands.gen"; import { events, commands } from "@/commands.gen";
import { toLumeEvents } from "@/commons"; import { toLumeEvents } from "@/commons";
import { RepostNote, Spinner, TextNote } from "@/components"; import { RepostNote, Spinner, TextNote } from "@/components";
import { LumeEvent } from "@/system"; import type { LumeEvent } from "@/system";
import { Kind, type Meta } from "@/types"; import { Kind } from "@/types";
import { ArrowDown, ArrowUp } from "@phosphor-icons/react"; import { ArrowDown } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { useCallback, useEffect, useRef } from "react";
import {
memo,
useCallback,
useEffect,
useRef,
useState,
useTransition,
} from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
type Payload = { export const Route = createLazyFileRoute("/columns/_layout/newsfeed/$id")({
raw: string;
parsed: Meta;
};
export const Route = createLazyFileRoute("/columns/_layout/newsfeed")({
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const contacts = Route.useLoaderData(); const contacts = Route.useLoaderData();
const { label, account } = Route.useSearch(); const search = Route.useSearch();
const { const {
data, data,
isLoading, isLoading,
@@ -38,7 +26,7 @@ export function Screen() {
hasNextPage, hasNextPage,
fetchNextPage, fetchNextPage,
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: [label, account], queryKey: ["events", "newsfeed", search.label],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const until = pageParam > 0 ? pageParam.toString() : undefined; const until = pageParam > 0 ? pageParam.toString() : undefined;
@@ -59,7 +47,10 @@ export function Screen() {
const renderItem = useCallback( const renderItem = useCallback(
(event: LumeEvent) => { (event: LumeEvent) => {
if (!event) return; if (!event) {
return;
}
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
return ( return (
@@ -82,6 +73,28 @@ export function Screen() {
[data], [data],
); );
useEffect(() => {
events.subscription
.emit({
label: search.label,
kind: "Subscribe",
event_id: undefined,
contacts,
})
.then(() => console.log("Subscribe: ", search.label));
return () => {
events.subscription
.emit({
label: search.label,
kind: "Unsubscribe",
event_id: undefined,
contacts,
})
.then(() => console.log("Unsubscribe: ", search.label));
};
}, []);
return ( return (
<ScrollArea.Root <ScrollArea.Root
type={"scroll"} type={"scroll"}
@@ -92,7 +105,6 @@ export function Screen() {
ref={ref} ref={ref}
className="relative h-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700" className="relative h-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700"
> >
<Listener />
<Virtualizer scrollRef={ref}> <Virtualizer scrollRef={ref}>
{isFetching && !isLoading && !isFetchingNextPage ? ( {isFetching && !isLoading && !isFetchingNextPage ? (
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3"> <div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
@@ -145,85 +157,3 @@ export function Screen() {
</ScrollArea.Root> </ScrollArea.Root>
); );
} }
const Listener = memo(function Listerner() {
const { queryClient } = Route.useRouteContext();
const { label, account } = Route.useSearch();
const [lumeEvents, setLumeEvents] = useState<LumeEvent[]>([]);
const [isPending, startTransition] = useTransition();
const queryStatus = queryClient.getQueryState([label, account]);
const pushNewEvents = () => {
startTransition(() => {
queryClient.setQueryData(
[label, account],
(oldData: InfiniteData<LumeEvent[], number> | undefined) => {
if (oldData) {
const firstPage = oldData.pages[0];
const newPage = [...lumeEvents, ...firstPage];
return {
...oldData,
pages: [newPage, ...oldData.pages.slice(1)],
};
}
},
);
// Reset array
setLumeEvents([]);
return;
});
};
useEffect(() => {
events.subscription
.emit({ label, kind: "Subscribe", event_id: undefined })
.then(() => console.log("Subscribe: ", label));
return () => {
events.subscription
.emit({
label,
kind: "Unsubscribe",
event_id: undefined,
})
.then(() => console.log("Unsubscribe: ", label));
};
}, []);
useEffect(() => {
const unlisten = getCurrentWindow().listen<Payload>("event", (data) => {
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
setLumeEvents((prev) => [event, ...prev]);
});
return () => {
unlisten.then((f) => f());
};
}, []);
if (lumeEvents.length && queryStatus.fetchStatus !== "fetching") {
return (
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
<button
type="button"
onClick={() => pushNewEvents()}
className="w-max h-8 pl-2 pr-3 inline-flex items-center justify-center gap-1.5 rounded-full shadow-lg text-sm font-medium text-white bg-black dark:text-black dark:bg-white"
>
{isPending ? (
<Spinner className="size-4" />
) : (
<ArrowUp className="size-4" />
)}
{lumeEvents.length} new notes
</button>
</div>
);
}
return null;
});

View File

@@ -1,9 +1,9 @@
import { commands } from "@/commands.gen"; import { commands } from "@/commands.gen";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/columns/_layout/stories")({ export const Route = createFileRoute("/columns/_layout/newsfeed/$id")({
loader: async () => { loader: async ({ params }) => {
const res = await commands.getContactList(); const res = await commands.getContactList(params.id);
if (res.status === "ok") { if (res.status === "ok") {
return res.data; return res.data;

View File

@@ -13,17 +13,17 @@ import { nip19 } from "nostr-tools";
import { type ReactNode, useEffect, useMemo, useRef } from "react"; import { type ReactNode, useEffect, useMemo, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/columns/_layout/notification")({ export const Route = createLazyFileRoute("/columns/_layout/notification/$id")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { account } = Route.useSearch(); const { id } = Route.useParams();
const { queryClient } = Route.useRouteContext(); const { queryClient } = Route.useRouteContext();
const { isLoading, data } = useQuery({ const { isLoading, data } = useQuery({
queryKey: ["notification", account], queryKey: ["notification", id],
queryFn: async () => { queryFn: async () => {
const res = await commands.getNotifications(); const res = await commands.getNotifications(id);
if (res.status === "error") { if (res.status === "error") {
throw new Error(res.error); throw new Error(res.error);
@@ -37,7 +37,7 @@ function Screen() {
select: (events) => { select: (events) => {
const zaps = new Map<string, LumeEvent[]>(); const zaps = new Map<string, LumeEvent[]>();
const reactions = new Map<string, LumeEvent[]>(); const reactions = new Map<string, LumeEvent[]>();
const hex = nip19.decode(account).data; const hex = nip19.decode(id).data;
const texts = events.filter( const texts = events.filter(
(ev) => ev.kind === Kind.Text && ev.pubkey !== hex, (ev) => ev.kind === Kind.Text && ev.pubkey !== hex,
@@ -80,7 +80,7 @@ function Screen() {
const unlisten = getCurrentWindow().listen("event", async (data) => { const unlisten = getCurrentWindow().listen("event", async (data) => {
const event: LumeEvent = JSON.parse(data.payload as string); const event: LumeEvent = JSON.parse(data.payload as string);
await queryClient.setQueryData( await queryClient.setQueryData(
["notification", account], ["notification", id],
(data: LumeEvent[]) => [event, ...data], (data: LumeEvent[]) => [event, ...data],
); );
}); });
@@ -88,7 +88,7 @@ function Screen() {
return () => { return () => {
unlisten.then((f) => f()); unlisten.then((f) => f());
}; };
}, [account]); }, [id]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -130,8 +130,8 @@ function Screen() {
className="min-h-0 flex-1 overflow-x-hidden" className="min-h-0 flex-1 overflow-x-hidden"
> >
<Tab value="replies"> <Tab value="replies">
{data.texts.map((event, index) => ( {data.texts.map((event) => (
<TextNote key={event.id + index} event={event} /> <TextNote key={event.id} event={event} />
))} ))}
</Tab> </Tab>
<Tab value="reactions"> <Tab value="reactions">

View File

@@ -14,7 +14,7 @@ import { type ReactNode, memo, useMemo, useRef } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/columns/_layout/stories")({ export const Route = createLazyFileRoute("/columns/_layout/stories/$id")({
component: Screen, component: Screen,
}); });
@@ -59,7 +59,7 @@ function StoryItem({ contact }: { contact: string }) {
error, error,
data: events, data: events,
} = useQuery({ } = useQuery({
queryKey: ["stories", contact], queryKey: ["events", "story", contact],
queryFn: async () => { queryFn: async () => {
const res = await commands.getAllEventsByAuthor(contact, 10); const res = await commands.getAllEventsByAuthor(contact, 10);
@@ -113,7 +113,7 @@ function StoryItem({ contact }: { contact: string }) {
</div> </div>
) : !events.length ? ( ) : !events.length ? (
<div className="w-full h-[calc(300px-48px)] flex items-center justify-center text-sm"> <div className="w-full h-[calc(300px-48px)] flex items-center justify-center text-sm">
This user didn't have any new notes. This user didn't have any new notes in the last few days.
</div> </div>
) : ( ) : (
events.map((event) => <StoryEvent key={event.id} event={event} />) events.map((event) => <StoryEvent key={event.id} event={event} />)

View File

@@ -1,9 +1,9 @@
import { commands } from "@/commands.gen"; import { commands } from "@/commands.gen";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/columns/_layout/newsfeed")({ export const Route = createFileRoute("/columns/_layout/stories/$id")({
loader: async () => { loader: async ({ params }) => {
const res = await commands.getContactList(); const res = await commands.getContactList(params.id);
if (res.status === "ok") { if (res.status === "ok") {
return res.data; return res.data;

View File

@@ -13,7 +13,7 @@ export const Route = createLazyFileRoute("/columns/_layout/trending")({
function Screen() { function Screen() {
const { isLoading, data } = useQuery({ const { isLoading, data } = useQuery({
queryKey: ["trending-notes"], queryKey: ["trending"],
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const res = await fetch("https://api.nostr.band/v0/trending/notes", { const res = await fetch("https://api.nostr.band/v0/trending/notes", {
signal, signal,

View File

@@ -16,7 +16,7 @@ export const Route = createLazyFileRoute("/columns/_layout/users/$id")({
function Screen() { function Screen() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { isLoading, data: events } = useQuery({ const { isLoading, data: events } = useQuery({
queryKey: ["stories", id], queryKey: ["events", "story", id],
queryFn: async () => { queryFn: async () => {
const res = await commands.getAllEventsByAuthor(id, 100); const res = await commands.getAllEventsByAuthor(id, 100);

View File

@@ -1,236 +0,0 @@
import { commands } from "@/commands.gen";
import { appSettings, displayNpub } from "@/commons";
import { Frame, Spinner, User } from "@/components";
import { ArrowRight, DotsThree, GearSix, Plus } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
export const Route = createLazyFileRoute("/")({
component: Screen,
});
function Screen() {
const context = Route.useRouteContext();
const navigate = Route.useNavigate();
const currentDate = useMemo(
() =>
new Date().toLocaleString("default", {
weekday: "long",
month: "long",
day: "numeric",
}),
[],
);
const [accounts, setAccounts] = useState([]);
const [value, setValue] = useState("");
const [autoLogin, setAutoLogin] = useState(false);
const [password, setPassword] = useState("");
const [isPending, startTransition] = useTransition();
const showContextMenu = useCallback(
async (e: React.MouseEvent, account: string) => {
e.stopPropagation();
const menuItems = await Promise.all([
MenuItem.new({
text: "Reset password",
enabled: !account.includes("_nostrconnect"),
// @ts-ignore, this is tanstack router bug
action: () => navigate({ to: "/reset", search: { account } }),
}),
MenuItem.new({
text: "Delete account",
action: async () => await deleteAccount(account),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
},
[],
);
const deleteAccount = async (account: string) => {
const res = await commands.deleteAccount(account);
if (res.status === "ok") {
setAccounts((prev) => prev.filter((item) => item !== account));
}
};
const selectAccount = (account: string) => {
setValue(account);
if (account.includes("_nostrconnect")) {
setAutoLogin(true);
}
};
const loginWith = () => {
startTransition(async () => {
const res = await commands.login(value, password);
if (res.status === "ok") {
const settings = await commands.getUserSettings();
if (settings.status === "ok") {
appSettings.setState(() => settings.data);
}
const status = await commands.isAccountSync(res.data);
if (status) {
navigate({
to: "/$account/home",
// @ts-ignore, this is tanstack router bug
params: { account: res.data },
replace: true,
});
} else {
navigate({
to: "/loading",
// @ts-ignore, this is tanstack router bug
search: { account: res.data },
replace: true,
});
}
} else {
await message(res.error, { title: "Login", kind: "error" });
return;
}
});
};
useEffect(() => {
if (autoLogin) {
loginWith();
}
}, [autoLogin, value]);
useEffect(() => {
setAccounts(context.accounts);
}, [context.accounts]);
return (
<div
data-tauri-drag-region
className="relative size-full flex items-center justify-center"
>
<div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h3 className="leading-tight text-neutral-700 dark:text-neutral-300">
{currentDate}
</h3>
<h1 className="leading-tight text-xl font-semibold">Welcome back!</h1>
</div>
<Frame
className="flex flex-col w-full divide-y divide-neutral-100 dark:divide-white/5 rounded-xl overflow-hidden"
shadow
>
{accounts.map((account) => (
<div
key={account}
onClick={() => selectAccount(account)}
onKeyDown={() => selectAccount(account)}
className="group flex items-center gap-2 hover:bg-black/5 dark:hover:bg-white/5 p-3"
>
<User.Provider pubkey={account.replace("_nostrconnect", "")}>
<User.Root className="flex-1 flex items-center gap-2.5">
<User.Avatar className="rounded-full size-10" />
{value === account && !value.includes("_nostrconnect") ? (
<div className="flex-1 flex items-center gap-2">
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") loginWith();
}}
disabled={isPending}
placeholder="Password"
className="px-3 rounded-full w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/>
</div>
) : (
<div className="inline-flex flex-col items-start">
<div className="inline-flex items-center gap-1.5">
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
{account.includes("_nostrconnect") ? (
<div className="text-[8px] border border-blue-500 text-blue-500 px-1.5 rounded-full">
Nostr Connect
</div>
) : null}
</div>
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{displayNpub(account.replace("_nostrconnect", ""), 16)}
</span>
</div>
)}
</User.Root>
</User.Provider>
<div className="inline-flex items-center justify-center size-8 shrink-0">
{value === account ? (
isPending ? (
<Spinner />
) : (
<button
type="button"
onClick={() => loginWith()}
className="rounded-full size-10 inline-flex items-center justify-center"
>
<ArrowRight className="size-5" />
</button>
)
) : (
<button
type="button"
onClick={(e) => showContextMenu(e, account)}
className="rounded-full size-10 hidden group-hover:inline-flex items-center justify-center"
>
<DotsThree className="size-5" />
</button>
)}
</div>
</div>
))}
<a
href="/new"
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2.5 p-3">
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
<Plus className="size-5" />
</div>
<span className="truncate text-sm font-medium leading-tight">
New account
</span>
</div>
</a>
</Frame>
</div>
<div className="absolute bottom-2 right-2">
<a
href="/bootstrap-relays"
className="h-8 w-max text-xs px-3 inline-flex items-center justify-center gap-1.5 bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 rounded-full"
>
<GearSix className="size-4" />
Manage Relays
</a>
</div>
</div>
);
}

View File

@@ -1,24 +0,0 @@
import { commands } from "@/commands.gen";
import { checkForAppUpdates } from "@/commons";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
beforeLoad: async () => {
// Check for app updates
await checkForAppUpdates(true);
// Get all accounts
const accounts = await commands.getAccounts();
if (accounts.length < 1) {
throw redirect({
to: "/new",
replace: true,
});
}
return {
accounts: accounts.filter((account) => !account.endsWith("Lume")),
};
},
});

View File

@@ -1,58 +0,0 @@
import { commands } from "@/commands.gen";
import { Frame, Spinner } from "@/components";
import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { useEffect } from "react";
type RouteSearch = {
account: string;
};
export const Route = createFileRoute("/loading")({
validateSearch: (search: Record<string, string>): RouteSearch => {
return {
account: search.account,
};
},
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const search = Route.useSearch();
useEffect(() => {
const unlisten = listen("neg_synchronized", async () => {
const status = await commands.createSyncFile(search.account);
if (status) {
navigate({
to: "/$account/home",
// @ts-ignore, this is tanstack router bug
params: { account: search.account },
replace: true,
});
} else {
throw new Error("System error.");
}
});
return () => {
unlisten.then((f) => f());
};
}, []);
return (
<div className="size-full flex items-center justify-center">
<Frame
className="p-6 h-36 flex flex-col gap-2 items-center justify-center text-center rounded-xl overflow-hidden"
shadow
>
<Spinner />
<p className="text-sm text-neutral-600 dark:text-neutral-40">
Syncing all necessary data for the first time login...
</p>
</Frame>
</div>
);
}

View File

@@ -12,10 +12,8 @@ import {
export function MediaButton({ export function MediaButton({
setText, setText,
setAttaches,
}: { }: {
setText: Dispatch<SetStateAction<string>>; setText: Dispatch<SetStateAction<string>>;
setAttaches: Dispatch<SetStateAction<string[]>>;
}) { }) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -24,8 +22,6 @@ export function MediaButton({
try { try {
const image = await upload(); const image = await upload();
setText((prev) => `${prev}\n${image}`); setText((prev) => `${prev}\n${image}`);
setAttaches((prev) => [...prev, image]);
return;
} catch (e) { } catch (e) {
await message(String(e), { title: "Upload", kind: "error" }); await message(String(e), { title: "Upload", kind: "error" });
return; return;
@@ -44,7 +40,6 @@ export function MediaButton({
if (isImagePath(item)) { if (isImagePath(item)) {
const image = await upload(item); const image = await upload(item);
setText((prev) => `${prev}\n${image}`); setText((prev) => `${prev}\n${image}`);
setAttaches((prev) => [...prev, image]);
} }
} }

View File

@@ -1,15 +1,23 @@
// @ts-nocheck import { type Mention, type Result, commands } from "@/commands.gen";
import { type Mention, commands } from "@/commands.gen"; import { cn, displayNpub } from "@/commons";
import { cn } from "@/commons"; import { PublishIcon, Spinner } from "@/components";
import { Spinner } from "@/components";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { LumeEvent, useEvent } from "@/system"; import { LumeWindow, useEvent } from "@/system";
import { Feather } from "@phosphor-icons/react"; import type { Metadata } from "@/types";
import { CaretDown } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import type { Window } from "@tauri-apps/api/window";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { useEffect, useMemo, useRef, useState, useTransition } from "react"; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useTransition,
} from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
RichTextarea, RichTextarea,
@@ -45,7 +53,15 @@ const renderer = createRegexRenderer([
], ],
[ [
/(?:^|\W)nostr:(\w+)(?!\w)/g, /(?:^|\W)nostr:(\w+)(?!\w)/g,
({ children, key, value }) => ( ({ children, key }) => (
<span key={key} className="text-blue-500">
{children}
</span>
),
],
[
/(?:^|\W)#(\w+)(?!\w)/g,
({ children, key }) => (
<span key={key} className="text-blue-500"> <span key={key} className="text-blue-500">
{children} {children}
</span> </span>
@@ -53,7 +69,7 @@ const renderer = createRegexRenderer([
], ],
]); ]);
export const Route = createFileRoute("/editor/")({ export const Route = createFileRoute("/new-post/")({
validateSearch: (search: Record<string, string>): EditorSearch => { validateSearch: (search: Record<string, string>): EditorSearch => {
return { return {
reply_to: search.reply_to, reply_to: search.reply_to,
@@ -70,25 +86,28 @@ export const Route = createFileRoute("/editor/")({
initialValue = ""; initialValue = "";
} }
const res = await commands.getMentionList(); const res = await commands.getAllProfiles();
const accounts = await commands.getAccounts();
if (res.status === "ok") { if (res.status === "ok") {
users = res.data; users = res.data;
} }
return { users, initialValue }; return { accounts, users, initialValue };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { reply_to } = Route.useSearch(); const { reply_to } = Route.useSearch();
const { users, initialValue } = Route.useRouteContext(); const { accounts, users, initialValue } = Route.useRouteContext();
const [text, setText] = useState(""); const [text, setText] = useState("");
const [currentUser, setCurrentUser] = useState<string>(null);
const [popup, setPopup] = useState<Window>(null);
const [isPublish, setIsPublish] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [attaches, setAttaches] = useState<string[]>(null);
const [warning, setWarning] = useState({ enable: false, reason: "" }); const [warning, setWarning] = useState({ enable: false, reason: "" });
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 }); const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
const [index, setIndex] = useState<number>(0); const [index, setIndex] = useState<number>(0);
@@ -110,6 +129,34 @@ function Screen() {
[name], [name],
); );
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const list = [];
for (const account of accounts) {
const res = await commands.getProfile(account);
let name = "unknown";
if (res.status === "ok") {
const profile: Metadata = JSON.parse(res.data);
name = profile.display_name ?? profile.name;
}
list.push(
MenuItem.new({
text: `Publish as ${name} (${displayNpub(account, 16)})`,
action: async () => setCurrentUser(account),
}),
);
}
const items = await Promise.all(list);
const menu = await Menu.new({ items });
await menu.popup().catch((e) => console.error(e));
}, []);
const insert = (i: number) => { const insert = (i: number) => {
if (!ref.current || !pos) return; if (!ref.current || !pos) return;
@@ -126,41 +173,84 @@ function Screen() {
setIndex(0); setIndex(0);
}; };
const publish = async () => { const publish = () => {
startTransition(async () => { startTransition(async () => {
try { const content = text.trim();
// Temporary hide window const warn = warning.enable ? warning.reason : undefined;
await getCurrentWindow().hide(); const diff = difficulty.enable ? difficulty.num : undefined;
let res: Result<string, string>; let res: Result<string, string>;
if (reply_to) { if (reply_to?.length) {
res = await commands.reply(content, reply_to, root_to); res = await commands.reply(content, reply_to, undefined);
} else { } else {
res = await commands.publish(content, warning, difficulty); res = await commands.publish(content, warn, diff);
} }
if (res.status === "ok") { if (res.status === "ok") {
setText(""); setText("");
// Close window setIsPublish(true);
await getCurrentWindow().close();
} else { } else {
setError(res.error); setError(res.error);
// Focus window
await getCurrentWindow().setFocus();
}
} catch {
return;
} }
}); });
}; };
const submit = async () => {
if (currentUser) {
const signer = await commands.hasSigner(currentUser);
if (signer.status === "ok") {
if (!signer.data) {
const newPopup = await LumeWindow.openPopup(
`/set-signer/${currentUser}`,
undefined,
false,
);
setPopup(newPopup);
return;
}
publish();
}
}
};
useEffect(() => {
if (!popup) return;
const unlisten = popup.listen("signer-updated", () => {
publish();
});
return () => {
unlisten.then((f) => f());
};
}, [popup]);
useEffect(() => {
if (isPublish) {
const timer = setTimeout(() => setIsPublish((prev) => !prev), 5000);
return () => {
clearTimeout(timer);
};
}
}, [isPublish]);
useEffect(() => { useEffect(() => {
if (initialValue?.length) { if (initialValue?.length) {
setText(initialValue); setText(initialValue);
} }
}, [initialValue]); }, [initialValue]);
useEffect(() => {
if (accounts?.length) {
setCurrentUser(accounts[0]);
}
}, [accounts]);
return ( return (
<div className="flex flex-col w-full h-full"> <div className="flex flex-col w-full h-full">
<div data-tauri-drag-region className="h-11 shrink-0" /> <div data-tauri-drag-region className="h-11 shrink-0" />
@@ -229,12 +319,13 @@ function Screen() {
setIndex(0); setIndex(0);
} }
}} }}
disabled={isPending}
> >
{renderer} {renderer}
</RichTextarea> </RichTextarea>
{pos {pos ? (
? createPortal( createPortal(
<Menu <MentionPopup
top={pos.top} top={pos.top}
left={pos.left} left={pos.left}
users={filtered} users={filtered}
@@ -243,7 +334,9 @@ function Screen() {
/>, />,
document.body, document.body,
) )
: null} ) : (
<></>
)}
</div> </div>
</div> </div>
{warning.enable ? ( {warning.enable ? (
@@ -289,20 +382,44 @@ function Screen() {
data-tauri-drag-region data-tauri-drag-region
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5" className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
> >
<div className="inline-flex items-center gap-3">
<button <button
type="button" type="button"
onClick={() => publish()} onClick={() => submit()}
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" className={cn(
"inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg w-max",
isPublish
? "bg-green-500 hover:bg-green-600 dark:hover:bg-green-400 text-white"
: "bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20",
)}
> >
{isPending ? ( {isPending ? (
<Spinner className="size-4" /> <Spinner className="size-4" />
) : ( ) : (
<Feather className="size-4" weight="fill" /> <PublishIcon className="size-4" />
)} )}
Publish {isPublish ? "Published" : "Publish"}
</button> </button>
<div className="inline-flex items-center flex-1 gap-2 pl-4"> {currentUser ? (
<MediaButton setText={setText} setAttaches={setAttaches} /> <button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center gap-1.5"
>
<User.Provider pubkey={currentUser}>
<User.Root>
<User.Avatar className="size-6 rounded-full" />
</User.Root>
</User.Provider>
<CaretDown
className="mt-px size-3 text-neutral-500"
weight="bold"
/>
</button>
) : null}
</div>
<div className="inline-flex items-center flex-1 gap-2 pl-2">
<MediaButton setText={setText} />
<WarningButton setWarning={setWarning} /> <WarningButton setWarning={setWarning} />
<PowButton setDifficulty={setDifficulty} /> <PowButton setDifficulty={setDifficulty} />
</div> </div>
@@ -311,7 +428,7 @@ function Screen() {
); );
} }
function Menu({ function MentionPopup({
users, users,
index, index,
top, top,

View File

@@ -19,24 +19,36 @@ function Screen() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<a <a
href="/auth/connect" href="/auth/connect"
className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-900 ring-1 ring-black/5 dark:ring-white/5" className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-800 ring-1 ring-black/5 dark:ring-white/5"
> >
<h3 className="mb-1 font-medium">Continue with Nostr Connect</h3> <h3 className="mb-1 font-medium">Continue with Nostr Connect</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-600"> <p className="text-xs text-neutral-500 dark:text-neutral-500">
Your account will be handled by a remote signer. Lume will not Your account will be handled by a remote signer. Lume will not
store your account keys. store your account keys.
</p> </p>
</a> </a>
<a <a
href="/auth/import" href="/auth/import"
className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-900 ring-1 ring-black/5 dark:ring-white/5" className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-800 ring-1 ring-black/5 dark:ring-white/5"
> >
<h3 className="mb-1 font-medium">Continue with Secret Key</h3> <h3 className="mb-1 font-medium">Continue with Secret Key</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-600"> <p className="text-xs text-neutral-500 dark:text-neutral-500">
Lume will store your keys in secure storage. You can provide a Lume will store your keys in secure storage. You can provide a
password to add extra security. password to add extra security.
</p> </p>
</a> </a>
<a
href="/auth/watch"
className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-800 ring-1 ring-black/5 dark:ring-white/5"
>
<h3 className="mb-1 font-medium">
Continue with Public Key (Watch Mode)
</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-500">
Use for experience without provide your private key, you can add
it later to publish new note.
</p>
</a>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex-1 h-px bg-black/5 dark:bg-white/5" /> <div className="flex-1 h-px bg-black/5 dark:bg-white/5" />
<div className="shrink-0 text-sm text-neutral-500 dark:text-neutral-400"> <div className="shrink-0 text-sm text-neutral-500 dark:text-neutral-400">

View File

@@ -0,0 +1,92 @@
import { commands } from "@/commands.gen";
import { Spinner, User } from "@/components";
import { Lock } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react";
export const Route = createLazyFileRoute("/set-signer/$id")({
component: Screen,
});
function Screen() {
const { id } = Route.useParams();
const [password, setPassword] = useState("");
const [isPending, startTransition] = useTransition();
const unlock = () => {
startTransition(async () => {
if (!password.length) {
await message("Password is required", { kind: "info" });
return;
}
const window = getCurrentWindow();
const res = await commands.setSigner(id, password);
if (res.status === "ok") {
await window.close();
} else {
await message(res.error, { kind: "error" });
return;
}
});
};
return (
<div
data-tauri-drag-region
className="size-full flex flex-col items-center justify-between gap-6 p-3"
>
<div
data-tauri-drag-region
className="flex-1 w-full px-10 flex flex-col gap-6 items-center justify-center"
>
<User.Provider pubkey={id}>
<User.Root className="flex flex-col text-center gap-2">
<User.Avatar className="size-12 rounded-full" />
<User.Name className="font-semibold" />
</User.Root>
</User.Provider>
<div className="w-full flex flex-col gap-2 items-center justify-center">
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") unlock();
}}
disabled={isPending}
placeholder="Enter password to unlock"
className="px-3 w-full rounded-lg h-10 text-center bg-transparent border border-black/10 dark:border-white/10 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => unlock()}
disabled={isPending}
className="shrink-0 h-9 w-full rounded-lg inline-flex items-center justify-center gap-2 bg-blue-500 hover:bg-blue-600 dark:hover:bg-blue-400 text-white text-sm font-medium"
>
{isPending ? (
<Spinner className="size-4" />
) : (
<Lock className="size-4" weight="bold" />
)}
Unlock
</button>
</div>
</div>
<div className="mt-auto">
<button
type="button"
onClick={() => getCurrentWindow().close()}
className="text-sm font-medium text-red-500"
>
Cancel
</button>
</div>
</div>
);
}

View File

@@ -4,12 +4,12 @@ import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Outlet, createLazyFileRoute } from "@tanstack/react-router"; import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/$account/_settings")({ export const Route = createLazyFileRoute("/settings/$id")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { account } = Route.useParams(); const { id } = Route.useParams();
const { platform } = Route.useRouteContext(); const { platform } = Route.useRouteContext();
return ( return (
@@ -17,11 +17,14 @@ function Screen() {
<div <div
data-tauri-drag-region data-tauri-drag-region
className={cn( className={cn(
"w-[250px] shrink-0 flex flex-col gap-1 border-r border-black/10 dark:border-white/10 p-2", "w-[200px] shrink-0 flex flex-col gap-1 border-r border-black/10 dark:border-white/10 p-2",
platform === "macos" ? "pt-11" : "", platform === "macos" ? "pt-11" : "",
)} )}
> >
<Link to="/$account/general" params={{ account }}> <div className="h-8 px-1.5">
<h1 className="text-lg font-semibold">Settings</h1>
</div>
<Link to="/settings/$id/general" params={{ id }}>
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div
@@ -38,7 +41,7 @@ function Screen() {
); );
}} }}
</Link> </Link>
<Link to="/$account/profile" params={{ account }}> <Link to="/settings/$id/profile" params={{ id }}>
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div
@@ -55,7 +58,7 @@ function Screen() {
); );
}} }}
</Link> </Link>
<Link to="/$account/relay" params={{ account }}> <Link to="/settings/$id/relay" params={{ id }}>
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div
@@ -72,7 +75,7 @@ function Screen() {
); );
}} }}
</Link> </Link>
<Link to="/$account/wallet" params={{ account }}> <Link to="/settings/$id/wallet" params={{ id }}>
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div

View File

@@ -0,0 +1,189 @@
import { commands } from '@/commands.gen'
import { appSettings } from '@/commons'
import { Spinner } from '@/components'
import * as Switch from '@radix-ui/react-switch'
import { createLazyFileRoute } from '@tanstack/react-router'
import { useStore } from '@tanstack/react-store'
import { invoke } from '@tauri-apps/api/core'
import { message } from '@tauri-apps/plugin-dialog'
import { useCallback, useEffect, useState, useTransition } from 'react'
type Theme = 'auto' | 'light' | 'dark'
export const Route = createLazyFileRoute('/settings/$id/general')({
component: Screen,
})
function Screen() {
const [theme, setTheme] = useState<Theme>(null)
const [isPending, startTransition] = useTransition()
const changeTheme = useCallback(async (theme: string) => {
if (theme === 'auto' || theme === 'light' || theme === 'dark') {
invoke('plugin:theme|set_theme', {
theme: theme,
}).then(() => setTheme(theme))
}
}, [])
const updateSettings = () => {
startTransition(async () => {
const newSettings = JSON.stringify(appSettings.state)
const res = await commands.setUserSettings(newSettings)
if (res.status === 'error') {
await message(res.error, { kind: 'error' })
}
return
})
}
useEffect(() => {
invoke('plugin:theme|get_theme').then((data) => setTheme(data as Theme))
}, [])
return (
<div className="relative w-full">
<div className="flex flex-col gap-6 px-3 pb-3">
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
General
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<Setting
name="Relay Hint"
description="Use the relay hint if necessary."
label="use_relay_hint"
/>
<Setting
name="Content Warning"
description="Shows a warning for notes that have a content warning."
label="content_warning"
/>
<Setting
name="Trusted Only"
description="Only shows note's replies from your inner circle."
label="trusted_only"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Appearance
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Appearance</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Change app theme
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<select
name="theme"
className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
defaultValue={theme}
onChange={(e) => changeTheme(e.target.value)}
>
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
<Setting
name="Transparent Effect"
description="Use native window transparent effect."
label="transparent"
/>
<Setting
name="Show Zap Button"
description="Shows the Zap button when viewing a note."
label="display_zap_button"
/>
<Setting
name="Show Repost Button"
description="Shows the Repost button when viewing a note."
label="display_repost_button"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Privacy & Performance
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<Setting
name="Proxy"
description="Set proxy address."
label="proxy"
/>
<Setting
name="Image Resize Service"
description="Use weserv/images for resize image on-the-fly."
label="image_resize_service"
/>
<Setting
name="Load Remote Media"
description="View the remote media directly."
label="display_media"
/>
</div>
</div>
</div>
<div className="sticky bottom-0 left-0 w-full h-16 flex items-center justify-end px-3">
<div className="absolute left-0 bottom-0 w-full h-11 gradient-mask-t-0 bg-neutral-100 dark:bg-neutral-900" />
<button
type="button"
onClick={() => updateSettings()}
className="relative z-10 inline-flex items-center justify-center w-20 rounded-md shadow h-8 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium"
>
{isPending ? <Spinner className="size-4" /> : 'Update'}
</button>
</div>
</div>
)
}
function Setting({
label,
name,
description,
}: {
label: string
name: string
description: string
}) {
const state = useStore(appSettings, (state) => state[label])
const toggle = useCallback(() => {
appSettings.setState((state) => {
return {
...state,
[label]: !state[label],
}
})
}, [])
return (
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">{name}</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{description}
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={state}
onClick={() => toggle()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { commands } from '@/commands.gen'
import { appSettings } from '@/commons'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/settings/$id/general')({
beforeLoad: async () => {
const res = await commands.getUserSettings()
if (res.status === 'ok') {
appSettings.setState((state) => {
return { ...state, ...res.data }
})
} else {
throw new Error(res.error)
}
},
})

View File

@@ -14,7 +14,7 @@ import {
} from "react"; } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
export const Route = createLazyFileRoute("/$account/_settings/profile")({ export const Route = createLazyFileRoute("/settings/$id/profile")({
component: Screen, component: Screen,
}); });
@@ -174,14 +174,14 @@ function Screen() {
} }
function PrivkeyButton() { function PrivkeyButton() {
const { account } = Route.useParams(); const { id } = Route.useParams();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isCopy, setIsCopy] = useState(false); const [isCopy, setIsCopy] = useState(false);
const copyPrivateKey = () => { const copyPrivateKey = () => {
startTransition(async () => { startTransition(async () => {
const res = await commands.getPrivateKey(account); const res = await commands.getPrivateKey(id);
if (res.status === "ok") { if (res.status === "ok") {
await writeText(res.data); await writeText(res.data);

View File

@@ -1,9 +1,9 @@
import { type Profile, commands } from "@/commands.gen"; import { type Profile, commands } from "@/commands.gen";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_settings/profile")({ export const Route = createFileRoute("/settings/$id/profile")({
beforeLoad: async ({ params }) => { beforeLoad: async ({ params }) => {
const res = await commands.getProfile(params.account); const res = await commands.getProfile(params.id);
if (res.status === "ok") { if (res.status === "ok") {
const profile: Profile = JSON.parse(res.data); const profile: Profile = JSON.parse(res.data);

View File

@@ -4,7 +4,7 @@ import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
export const Route = createLazyFileRoute("/$account/_settings/relay")({ export const Route = createLazyFileRoute("/settings/$id/relay")({
component: Screen, component: Screen,
}); });
@@ -12,7 +12,7 @@ function Screen() {
const { relayList } = Route.useRouteContext(); const { relayList } = Route.useRouteContext();
const [relays, setRelays] = useState<string[]>([]); const [relays, setRelays] = useState<string[]>([]);
const [newRelay, setNewRelay] = useState(""); const [newRelay, setNewRelay] = useState<string>("");
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const removeRelay = async (relay: string) => { const removeRelay = async (relay: string) => {
@@ -93,6 +93,7 @@ function Screen() {
onChange={(e) => setNewRelay(e.target.value)} onChange={(e) => setNewRelay(e.target.value)}
name="url" name="url"
placeholder="wss://..." placeholder="wss://..."
disabled={isPending}
spellCheck={false} spellCheck={false}
className="flex-1 px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400" className="flex-1 px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400"
/> />

View File

@@ -0,0 +1,14 @@
import { commands } from "@/commands.gen";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/settings/$id/relay")({
beforeLoad: async ({ params }) => {
const res = await commands.getRelays(params.id);
if (res.status === "ok") {
return { relayList: res.data };
} else {
throw new Error(res.error);
}
},
});

View File

@@ -0,0 +1,50 @@
import { commands } from "@/commands.gen";
import { Button } from "@getalby/bitcoin-connect-react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createLazyFileRoute("/settings/$id/wallet")({
component: Screen,
});
function Screen() {
const [_isConnect, setIsConnect] = useState(false);
const setWallet = async (uri: string) => {
const res = await commands.setWallet(uri);
if (res.status === "ok") {
setIsConnect((prev) => !prev);
} else {
throw new Error(res.error);
}
};
const removeWallet = async () => {
const res = await commands.removeWallet();
if (res.status === "ok") {
window.localStorage.removeItem("bc:config");
} else {
throw new Error(res.error);
}
};
return (
<div className="w-full px-3 pb-3">
<div className="flex flex-col w-full gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Wallet
</h2>
<div className="w-full h-44 flex items-center justify-center bg-black/5 dark:bg-white/5 rounded-xl">
<Button
onConnected={(provider) =>
setWallet(provider.client.nostrWalletConnectUrl)
}
onDisconnected={() => removeWallet()}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { init } from "@getalby/bitcoin-connect-react"; import { init } from "@getalby/bitcoin-connect-react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_settings/bitcoin-connect")({ export const Route = createFileRoute("/settings/$id/wallet")({
beforeLoad: () => { beforeLoad: async () => {
init({ init({
appName: "Lume", appName: "Lume",
filters: ["nwc"], filters: ["nwc"],

View File

@@ -1,8 +1,14 @@
import { User } from "@/components/user"; import { commands } from "@/commands.gen";
import { displayNpub } from "@/commons";
import { User } from "@/components";
import { LumeWindow } from "@/system";
import type { Metadata } from "@/types";
import { CaretDown } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { type Window, getCurrentWindow } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react"; import { useCallback, useEffect, useState, useTransition } from "react";
import CurrencyInput from "react-currency-input-field"; import CurrencyInput from "react-currency-input-field";
const DEFAULT_VALUES = [21, 50, 100, 200]; const DEFAULT_VALUES = [21, 50, 100, 200];
@@ -12,38 +18,102 @@ export const Route = createLazyFileRoute("/zap/$id")({
}); });
function Screen() { function Screen() {
const { event } = Route.useRouteContext(); const { accounts, event } = Route.useRouteContext();
const [currentUser, setCurrentUser] = useState<string>(null);
const [popup, setPopup] = useState<Window>(null);
const [amount, setAmount] = useState(21); const [amount, setAmount] = useState(21);
const [content, setContent] = useState(""); const [content, setContent] = useState<string>("");
const [isCompleted, setIsCompleted] = useState(false); const [isCompleted, setIsCompleted] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const submit = () => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
startTransition(async () => { e.preventDefault();
try {
const val = await event.zap(amount, content);
if (val) { const list = [];
for (const account of accounts) {
const res = await commands.getProfile(account);
let name = "unknown";
if (res.status === "ok") {
const profile: Metadata = JSON.parse(res.data);
name = profile.display_name ?? profile.name;
}
list.push(
MenuItem.new({
text: `Zap as ${name} (${displayNpub(account, 16)})`,
action: async () => setCurrentUser(account),
}),
);
}
const items = await Promise.all(list);
const menu = await Menu.new({ items });
await menu.popup().catch((e) => console.error(e));
}, []);
const zap = () => {
startTransition(async () => {
const res = await commands.zapEvent(event.id, amount.toString(), content);
if (res.status === "ok") {
setIsCompleted(true); setIsCompleted(true);
// close current window // close current window
await getCurrentWebviewWindow().close(); await getCurrentWindow().close();
} } else {
} catch (e) { await message(res.error, { kind: "error" });
await message(String(e), {
title: "Zap",
kind: "error",
});
return; return;
} }
}); });
}; };
const submit = async () => {
if (currentUser) {
const signer = await commands.hasSigner(currentUser);
if (signer.status === "ok") {
if (!signer.data) {
const newPopup = await LumeWindow.openPopup(
`/set-signer/${currentUser}`,
undefined,
false,
);
setPopup(newPopup);
return;
}
zap();
}
}
};
useEffect(() => {
if (!popup) return;
const unlisten = popup.listen("signer-updated", () => {
zap();
});
return () => {
unlisten.then((f) => f());
};
}, [popup]);
useEffect(() => {
if (accounts?.length) {
setCurrentUser(accounts[0]);
}
}, [accounts]);
return ( return (
<div data-tauri-drag-region className="flex flex-col pb-5 size-full"> <div data-tauri-drag-region className="flex flex-col pb-5 size-full">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex items-center justify-center h-24 gap-2 shrink-0" className="flex items-center justify-center h-32 gap-2 shrink-0"
> >
<p className="text-sm">Send zap to </p> <p className="text-sm">Send zap to </p>
<User.Provider pubkey={event.pubkey}> <User.Provider pubkey={event.pubkey}>
@@ -95,15 +165,34 @@ function Screen() {
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
placeholder="Enter message (optional)" placeholder="Enter message (optional)"
className="h-11 w-full resize-none rounded-xl border-transparent bg-black/5 px-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/5" className="h-10 w-full resize-none rounded-lg border-transparent bg-black/5 px-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/5"
/> />
<div className="inline-flex items-center gap-3">
<button <button
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
className="inline-flex items-center justify-center w-full h-10 font-medium rounded-xl bg-neutral-950 text-neutral-50 hover:bg-neutral-900 dark:bg-white/20 dark:hover:bg-white/30" className="inline-flex items-center justify-center w-full h-9 text-sm font-semibold rounded-lg bg-blue-500 text-white hover:bg-blue-600 dark:hover:bg-blue-400"
> >
{isCompleted ? "Zapped" : isPending ? "Processing..." : "Zap"} {isCompleted ? "Zapped" : isPending ? "Processing..." : "Zap"}
</button> </button>
{currentUser ? (
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center gap-1.5"
>
<User.Provider pubkey={currentUser}>
<User.Root>
<User.Avatar className="size-6 rounded-full" />
</User.Root>
</User.Provider>
<CaretDown
className="mt-px size-3 text-neutral-500"
weight="bold"
/>
</button>
) : null}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/zap/$id")({ export const Route = createFileRoute("/zap/$id")({
beforeLoad: async ({ params }) => { beforeLoad: async ({ params }) => {
const accounts = await commands.getAccounts();
const res = await commands.getEvent(params.id); const res = await commands.getEvent(params.id);
if (res.status === "ok") { if (res.status === "ok") {
@@ -15,7 +16,7 @@ export const Route = createFileRoute("/zap/$id")({
raw.meta = data.parsed; raw.meta = data.parsed;
} }
return { event: new LumeEvent(raw) }; return { accounts, event: new LumeEvent(raw) };
} else { } else {
throw new Error(res.error); throw new Error(res.error);
} }

View File

@@ -12,10 +12,10 @@ export class LumeEvent {
public meta: Meta; public meta: Meta;
public relay?: string; public relay?: string;
public replies?: LumeEvent[]; public replies?: LumeEvent[];
#raw: NostrEvent; public raw: NostrEvent;
constructor(event: NostrEvent) { constructor(event: NostrEvent) {
this.#raw = event; this.raw = event;
Object.assign(this, event); Object.assign(this, event);
} }
@@ -134,16 +134,6 @@ export class LumeEvent {
} }
} }
public async repost() {
const query = await commands.repost(JSON.stringify(this.#raw));
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async publish( static async publish(
content: string, content: string,
warning?: string, warning?: string,

View File

@@ -6,7 +6,7 @@ import { LumeEvent } from "./event";
export function useEvent(id: string, repost?: string) { export function useEvent(id: string, repost?: string) {
const { isLoading, isError, error, data } = useQuery({ const { isLoading, isError, error, data } = useQuery({
queryKey: ["event", id], queryKey: ["ids", "event", id],
queryFn: async () => { queryFn: async () => {
try { try {
if (repost?.length) { if (repost?.length) {

View File

@@ -9,7 +9,7 @@ export function useProfile(pubkey: string, embed?: string) {
isError, isError,
data: profile, data: profile,
} = useQuery({ } = useQuery({
queryKey: ["profile", pubkey], queryKey: ["metadata", "profile", pubkey],
queryFn: async () => { queryFn: async () => {
if (embed) { if (embed) {
const metadata: Metadata = JSON.parse(embed); const metadata: Metadata = JSON.parse(embed);

View File

@@ -1,6 +1,6 @@
import { commands } from "@/commands.gen"; import { commands } from "@/commands.gen";
import type { LumeColumn, NostrEvent } from "@/types"; import type { LumeColumn, NostrEvent } from "@/types";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { Window, getCurrentWindow } from "@tauri-apps/api/window";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import type { LumeEvent } from "./event"; import type { LumeEvent } from "./event";
@@ -11,7 +11,7 @@ export const LumeWindow = {
column, column,
}); });
}, },
openColumnsGallery: async () => { openLaunchpad: async () => {
await getCurrentWindow().emit("columns", { await getCurrentWindow().emit("columns", {
type: "add", type: "add",
column: { column: {
@@ -21,23 +21,36 @@ export const LumeWindow = {
}, },
}); });
}, },
openLocalFeeds: async () => { openNewsfeed: async (account: string) => {
await getCurrentWindow().emit("columns", { await getCurrentWindow().emit("columns", {
type: "add", type: "add",
column: { column: {
label: "newsfeed", label: "newsfeed",
name: "Newsfeed", name: "Newsfeed",
url: "/columns/newsfeed", url: `/columns/newsfeed/${account}`,
account,
}, },
}); });
}, },
openNotification: async () => { openStory: async (account: string) => {
await getCurrentWindow().emit("columns", {
type: "add",
column: {
label: "stories",
name: "Stories",
url: `/columns/stories/${account}`,
account,
},
});
},
openNotification: async (account: string) => {
await getCurrentWindow().emit("columns", { await getCurrentWindow().emit("columns", {
type: "add", type: "add",
column: { column: {
label: "notification", label: "notification",
name: "Notification", name: "Notification",
url: "/columns/notification", url: `/columns/notification/${account}`,
account,
}, },
}); });
}, },
@@ -86,27 +99,28 @@ export const LumeWindow = {
let url: string; let url: string;
if (reply_to) { if (reply_to) {
url = `/editor?reply_to=${reply_to}`; url = `/new-post?reply_to=${reply_to}`;
} }
if (quote?.length) { if (quote?.length) {
url = `/editor?quote=${quote}`; url = `/new-post?quote=${quote}`;
} }
if (!reply_to?.length && !quote?.length) { if (!reply_to?.length && !quote?.length) {
url = "/editor"; url = "/new-post";
} }
const label = `editor-${reply_to ? reply_to : 0}`; const label = `editor-${reply_to ? reply_to : 0}`;
const query = await commands.openWindow({ const query = await commands.openWindow({
label, label,
url, url,
title: "Editor", title: "New Post",
width: 560, width: 560,
height: 340, height: 340,
maximizable: false, maximizable: false,
minimizable: false, minimizable: false,
hidden_title: true, hidden_title: true,
closable: true,
}); });
if (query.status === "ok") { if (query.status === "ok") {
@@ -128,21 +142,25 @@ export const LumeWindow = {
maximizable: false, maximizable: false,
minimizable: false, minimizable: false,
hidden_title: true, hidden_title: true,
closable: true,
}); });
} else { } else {
await LumeWindow.openSettings(account, "bitcoin-connect"); await LumeWindow.openSettings(account, "wallet");
} }
}, },
openSettings: async (account: string, path?: string) => { openSettings: async (account: string, path?: string) => {
const query = await commands.openWindow({ const query = await commands.openWindow({
label: "settings", label: "settings",
url: path ? `${account}/${path}` : `${account}/general`, url: path
? `/settings/${account}/${path}`
: `/settings/${account}/general`,
title: "Settings", title: "Settings",
width: 800, width: 700,
height: 500, height: 500,
maximizable: false, maximizable: false,
minimizable: false, minimizable: false,
hidden_title: true, hidden_title: true,
closable: true,
}); });
if (query.status === "ok") { if (query.status === "ok") {
@@ -151,20 +169,21 @@ export const LumeWindow = {
throw new Error(query.error); throw new Error(query.error);
} }
}, },
openPopup: async (title: string, url: string) => { openPopup: async (url: string, title?: string, closable = true) => {
const query = await commands.openWindow({ const query = await commands.openWindow({
label: `popup-${nanoid()}`, label: `popup-${nanoid()}`,
url, url,
title, title: title ?? "",
width: 400, width: 400,
height: 500, height: 500,
maximizable: false, maximizable: false,
minimizable: false, minimizable: false,
hidden_title: false, hidden_title: !!title,
closable,
}); });
if (query.status === "ok") { if (query.status === "ok") {
return query.data; return await Window.getByLabel(query.data);
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }

View File

@@ -50,21 +50,13 @@ export interface Metadata {
lud16?: string; lud16?: string;
} }
export interface ColumnRouteSearch {
account?: string;
label?: string;
name?: string;
redirect?: string;
}
export interface LumeColumn { export interface LumeColumn {
label: string; label: string;
name: string; name: string;
description?: string;
url: string; url: string;
picture?: string; description?: string;
default?: boolean; default?: boolean;
official?: boolean; account?: string;
} }
export interface ColumnEvent { export interface ColumnEvent {