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:
@@ -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
20
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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": ""
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
204
src-tauri/src/commands/sync.rs
Normal file
204
src-tauri/src/commands/sync.rs
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 **/
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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"
|
||||||
: "",
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
19
src/components/icons/publish.tsx
Normal file
19
src/components/icons/publish.tsx
Normal 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>
|
||||||
|
);
|
||||||
23
src/components/icons/quote.tsx
Normal file
23
src/components/icons/quote.tsx
Normal 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>
|
||||||
|
);
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
40
src/components/note/buttons/quote.tsx
Normal file
40
src/components/note/buttons/quote.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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")} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"} />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/_app")();
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
195
src/routes/_layout.lazy.tsx
Normal 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
14
src/routes/_layout.tsx
Normal 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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|
||||||
3
src/routes/_layout/index.tsx
Normal file
3
src/routes/_layout/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_layout/')()
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
106
src/routes/auth/watch.lazy.tsx
Normal file
106
src/routes/auth/watch.lazy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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("#", ""));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
@@ -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;
|
||||||
@@ -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">
|
||||||
@@ -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} />)
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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")),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
@@ -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">
|
||||||
|
|||||||
92
src/routes/set-signer.$id.lazy.tsx
Normal file
92
src/routes/set-signer.$id.lazy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
189
src/routes/settings.$id/general.lazy.tsx
Normal file
189
src/routes/settings.$id/general.lazy.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/routes/settings.$id/general.tsx
Normal file
17
src/routes/settings.$id/general.tsx
Normal 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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
14
src/routes/settings.$id/relay.tsx
Normal file
14
src/routes/settings.$id/relay.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
50
src/routes/settings.$id/wallet.lazy.tsx
Normal file
50
src/routes/settings.$id/wallet.lazy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/types.ts
12
src/types.ts
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user