Compare commits

...

20 Commits

Author SHA1 Message Date
0c19ada1ab chore: bump version 2024-09-30 05:57:42 +07:00
09db39fce1 chore: add some small improvements 2024-09-30 04:49:17 +07:00
雨宮蓮
f0fc89724d feat: improve performance (#234)
* feat: use negentropy as much as possible

* update

* update
2024-09-29 16:53:39 +07:00
afa9327bb7 feat: add content parser to reply 2024-09-27 09:58:02 +07:00
5c3644f977 feat: update general settings 2024-09-27 09:12:38 +07:00
0a8eed9a46 feat: new post editor 2024-09-26 10:57:58 +07:00
bacfaed48a wip: local relay 2024-09-25 09:59:54 +07:00
3d5085785b feat: pow by default 2024-09-23 17:18:41 +07:00
9152c3e122 feat: add basic web of trust 2024-09-23 13:24:33 +07:00
a5574bef6c feat: add notification for nip42 2024-09-22 09:40:07 +07:00
2c7f3685b6 fix: build on macos 2024-09-20 09:26:48 +07:00
be0abc4075 chore: update deps 2024-09-20 08:42:08 +07:00
dafe35cd1f feat: add client tag 2024-09-20 08:05:32 +07:00
b23903240b fix: wrong menu item in repost button 2024-09-20 08:05:22 +07:00
872a6cee36 fix: missing context menu in user's avatar 2024-09-20 07:25:07 +07:00
雨宮蓮
ac7ce726c5 feat: Add gossip model (#232)
* feat: enable gossip

* chore: remove deprecated functions

* chore: use upstream rust nostr

* fix
2024-09-11 11:10:11 +07:00
Andrew
e5e290c0c3 fix: Improved text legibility on login/create account screens (#230)
* style: standardized placeholder text to be color neutral-400

* fix: changed dark:text-neutral-600 to dark:text-neutral-200 as in dark mode text was not visible
2024-08-30 20:04:44 +07:00
2eab6f04c7 chore: bump version 2024-08-30 13:30:29 +07:00
reya
e06b0334a5 chore: bump version 2024-08-29 08:00:58 +07:00
reya
74d8bf2ead fix: cannot import ncryptsec 2024-08-29 07:48:26 +07:00
50 changed files with 9627 additions and 22552 deletions

3
.gitignore vendored
View File

@@ -23,4 +23,5 @@ dist-ssr
*.sln
*.sw?
src/router.gen.ts
src/routes.gen.ts
src/commands.gen.ts

View File

@@ -19,28 +19,28 @@
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/query-persist-client-core": "^5.51.21",
"@tanstack/react-query": "^5.51.23",
"@tanstack/react-router": "^1.48.1",
"@tanstack/query-persist-client-core": "^5.56.2",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-router": "^1.58.3",
"@tanstack/react-store": "^0.5.5",
"@tanstack/store": "^0.5.5",
"@tauri-apps/api": "2.0.0-rc.1",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-rc.0",
"@tauri-apps/plugin-dialog": "2.0.0-rc.0",
"@tauri-apps/plugin-fs": "2.0.0-rc.1",
"@tauri-apps/plugin-http": "2.0.0-rc.1",
"@tauri-apps/plugin-os": "2.0.0-rc.0",
"@tauri-apps/plugin-process": "2.0.0-rc.0",
"@tauri-apps/plugin-shell": "2.0.0-rc.0",
"@tauri-apps/plugin-store": "2.0.0-rc.0",
"@tauri-apps/plugin-updater": "2.0.0-rc.0",
"@tauri-apps/plugin-upload": "2.0.0-rc.0",
"@tauri-apps/plugin-window-state": "2.0.0-rc.0",
"@tauri-apps/api": "2.0.0-rc.4",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-rc.1",
"@tauri-apps/plugin-dialog": "2.0.0-rc.1",
"@tauri-apps/plugin-fs": "2.0.0-rc.2",
"@tauri-apps/plugin-http": "2.0.0-rc.2",
"@tauri-apps/plugin-os": "2.0.0-rc.1",
"@tauri-apps/plugin-process": "2.0.0-rc.1",
"@tauri-apps/plugin-shell": "2.0.0-rc.1",
"@tauri-apps/plugin-store": "2.0.0-rc.1",
"@tauri-apps/plugin-updater": "2.0.0-rc.1",
"@tauri-apps/plugin-upload": "2.0.0-rc.1",
"@tauri-apps/plugin-window-state": "2.0.0-rc.1",
"bitcoin-units": "^1.0.0",
"boring-avatars": "^1.10.2",
"dayjs": "^1.11.12",
"embla-carousel-react": "^8.1.8",
"i18next": "^23.13.0",
"boring-avatars": "^1.11.2",
"dayjs": "^1.11.13",
"embla-carousel-react": "^8.3.0",
"i18next": "^23.15.1",
"i18next-resources-to-backend": "^1.2.1",
"light-bolt11-decoder": "^3.1.1",
"minidenticons": "^4.2.1",
@@ -49,36 +49,35 @@
"react": "19.0.0-rc-d025ddd3-20240722",
"react-currency-input-field": "^3.8.0",
"react-dom": "19.0.0-rc-d025ddd3-20240722",
"react-hook-form": "^7.52.2",
"react-i18next": "^15.0.1",
"react-hook-form": "^7.53.0",
"react-i18next": "^15.0.2",
"react-string-replace": "^1.1.1",
"slate": "^0.103.0",
"slate-react": "^0.107.1",
"rich-textarea": "^0.26.3",
"use-debounce": "^10.0.3",
"virtua": "^0.33.7"
},
"devDependencies": {
"@biomejs/biome": "^1.8.3",
"@biomejs/biome": "^1.9.2",
"@evilmartians/harmony": "^1.2.0",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.14",
"@tanstack/router-devtools": "^1.48.1",
"@tanstack/router-plugin": "^1.47.0",
"@tauri-apps/cli": "2.0.0-rc.4",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/router-devtools": "^1.58.3",
"@tanstack/router-plugin": "^1.58.4",
"@tauri-apps/cli": "2.0.0-rc.8",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625",
"clsx": "^2.1.1",
"postcss": "^8.4.41",
"postcss": "^8.4.47",
"tailwind-gradient-mask-image": "^1.2.0",
"tailwind-merge": "^2.5.2",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.10",
"tailwindcss": "^3.4.12",
"tailwindcss-content-visibility": "^0.2.0",
"typescript": "^5.5.4",
"vite": "^5.4.1",
"typescript": "^5.6.2",
"vite": "^5.4.6",
"vite-tsconfig-paths": "^5.0.1"
},
"overrides": {

1489
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,3 +5,6 @@
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# Config
.cargo

1561
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,12 +11,6 @@ rust-version = "1.70"
tauri-build = { version = "2.0.0-rc", features = [] }
[dependencies]
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
"sqlite",
] }
tokio = { version = "1", features = ["full"] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "2.0.0-rc", features = [
"unstable",
"tray-icon",
@@ -35,12 +29,18 @@ tauri-plugin-shell = "2.0.0-rc"
tauri-plugin-updater = "2.0.0-rc"
tauri-plugin-upload = "2.0.0-rc"
tauri-plugin-store = "2.0.0-rc"
tauri-plugin-theme = "0.4.1"
tauri-plugin-decorum = "1.0.0"
tauri-plugin-decorum = { git = "https://github.com/clearlysid/tauri-plugin-decorum.git" }
tauri-plugin-prevent-default = "0.4"
tauri-specta = { version = "2.0.0-rc.15", features = ["derive", "typescript"] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb"] }
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
specta = "^2.0.0-rc.20"
specta-typescript = "0.0.7"
tokio = { version = "1", features = ["full"] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
reqwest = "0.12.4"
url = "2.5.0"
futures = "0.3.30"
@@ -48,6 +48,7 @@ linkify = "0.10.0"
regex = "1.10.4"
keyring = { version = "3", features = ["apple-native", "windows-native"] }
keyring-search = "1.2.0"
tracing-subscriber = "0.3.18"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"

View File

@@ -1,6 +1,6 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"identifier": "column",
"description": "Capability for the column",
"platforms": [
"linux",

View File

@@ -1,6 +1,6 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"identifier": "window",
"description": "Capability for the desktop",
"platforms": [
"macOS",
@@ -41,8 +41,8 @@
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-set-size",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"decorum:allow-show-snap-overlay",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-text",
@@ -57,8 +57,6 @@
"process:allow-restart",
"process:allow-exit",
"fs:allow-read-file",
"theme:allow-set-theme",
"theme:allow-get-theme",
"core:menu:allow-new",
"core:menu:allow-popup",
"shell:allow-open",

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","panel","settings","search-*","zap-*","event-*","user-*","editor-*"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","core:window:allow-create","core:window:allow-close","core:window:allow-destroy","core:window:allow-set-focus","core:window:allow-center","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-set-size","core:window:allow-set-focus","core:window:allow-start-dragging","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","process:allow-exit","fs:allow-read-file","theme:allow-set-theme","theme:allow-get-theme","core:menu:allow-new","core:menu:allow-popup","shell:allow-open","store:allow-get","store:allow-set","store:allow-delete","prevent-default:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}}
{"column":{"identifier":"column","description":"Capability for the column","local":true,"windows":["column-*"],"permissions":["core:resources:default","core:tray:default","os:allow-locale","os:allow-os-type","clipboard-manager:allow-write-text","dialog:allow-open","dialog:allow-ask","dialog:allow-message","fs:allow-read-file","core:menu:default","core:menu:allow-new","core:menu:allow-popup","http:default","shell:allow-open","store:allow-get","store:allow-set","store:allow-delete",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["linux","macOS","windows"]},"window":{"identifier":"window","description":"Capability for the desktop","local":true,"windows":["main","panel","settings","search-*","zap-*","event-*","user-*","editor-*"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","core:window:allow-create","core:window:allow-close","core:window:allow-destroy","core:window:allow-set-focus","core:window:allow-center","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-set-size","core:window:allow-start-dragging","core:window:allow-toggle-maximize","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","process:allow-exit","fs:allow-read-file","core:menu:allow-new","core:menu:allow-popup","shell:allow-open","store:allow-get","store:allow-set","store:allow-delete","prevent-default:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,2 @@
wss://relay.damus.io,
wss://relay.nostr.net,
wss://purplepag.es/,
wss://directory.yabu.me/,

View File

@@ -3,7 +3,12 @@ use keyring_search::{Limit, List, Search};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{collections::HashSet, str::FromStr, time::Duration};
use std::{
collections::HashSet,
fs::{self, File},
str::FromStr,
time::Duration,
};
use tauri::{Emitter, Manager, State};
use crate::{
@@ -44,7 +49,7 @@ pub async fn create_account(
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().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())?;
@@ -80,38 +85,40 @@ pub async fn create_account(
#[tauri::command]
#[specta::specta]
pub async fn import_account(
key: String,
password: Option<String>,
state: State<'_, Nostr>,
) -> Result<String, String> {
let client = &state.client;
let secret_key = SecretKey::from_bech32(key).map_err(|err| err.to_string())?;
let keys = Keys::new(secret_key.clone());
let npub = keys.public_key().to_bech32().unwrap();
pub async fn import_account(key: String, password: String) -> Result<String, String> {
let (npub, enc_bech32) = match key.starts_with("ncryptsec") {
true => {
let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
let secret_key = enc.to_secret_key(password).map_err(|err| err.to_string())?;
let keys = Keys::new(secret_key);
let npub = keys.public_key().to_bech32().unwrap();
let enc_bech32 = match password {
Some(pw) => {
let enc = EncryptedSecretKey::new(&secret_key, pw, 16, KeySecurity::Medium)
(npub, enc_bech32)
}
false => {
let secret_key = SecretKey::from_bech32(key).map_err(|err| err.to_string())?;
let keys = Keys::new(secret_key.clone());
let npub = keys.public_key().to_bech32().unwrap();
let enc = EncryptedSecretKey::new(&secret_key, password, 16, KeySecurity::Medium)
.map_err(|err| err.to_string())?;
enc.to_bech32().map_err(|err| err.to_string())?
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
(npub, enc_bech32)
}
None => secret_key.to_bech32().map_err(|err| err.to_string())?,
};
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 client's signer
client.set_signer(Some(signer)).await;
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
Ok(npub)
}
@@ -125,13 +132,13 @@ pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<Str
Ok(bunker_uri) => {
// Local user
let app_keys = Keys::generate();
let app_secret = app_keys.secret_key().unwrap().to_string();
let app_secret = app_keys.secret_key().to_secret_hex();
// Get remote user
let remote_user = bunker_uri.signer_public_key().unwrap();
let remote_npub = remote_user.to_bech32().unwrap();
match Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(120), None).await {
match Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(120), None) {
Ok(signer) => {
let mut url = Url::parse(&uri).unwrap();
let query: Vec<(String, String)> = url
@@ -202,6 +209,17 @@ pub fn delete_account(id: String) -> Result<(), String> {
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn is_account_sync(id: String, handle: tauri::AppHandle) -> bool {
let config_dir = handle
.path()
.app_config_dir()
.expect("Error: app config directory not found.");
fs::metadata(config_dir.join(id)).is_ok()
}
#[tauri::command]
#[specta::specta]
pub async fn login(
@@ -242,7 +260,7 @@ pub async fn login(
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())?;
match Nip46Signer::new(uri, app_keys, Duration::from_secs(120), None).await {
match Nip46Signer::new(uri, app_keys, Duration::from_secs(120), None) {
Ok(signer) => {
// Update signer
client.set_signer(Some(signer.into())).await;
@@ -254,84 +272,135 @@ pub async fn login(
};
// Connect to user's relay (NIP-65)
init_nip65(client).await;
// Get user's contact list
if let Ok(contacts) = client.get_contact_list(Some(Duration::from_secs(5))).await {
let mut contacts_state = state.contact_list.lock().await;
*contacts_state = contacts;
};
// Get user's settings
if let Ok(settings) = get_user_settings(client).await {
let mut settings_state = state.settings.lock().await;
*settings_state = settings;
};
init_nip65(client, &public_key).await;
tauri::async_runtime::spawn(async move {
let config_dir = handle
.path()
.app_config_dir()
.expect("Error: app config directory not found.");
let state = handle.state::<Nostr>();
let client = &state.client;
let contact_list = state.contact_list.lock().await;
let signer = client.signer().await.unwrap();
let public_key = signer.public_key().await.unwrap();
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
let notification = Filter::new().pubkey(public_key).kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
]);
// Sync notification with negentropy
let _ = client
.reconcile(
notification.clone().limit(NOTIFICATION_NEG_LIMIT),
NegentropyOptions::default(),
)
.await;
// Subscribing for new notification...
if let Err(e) = client
.subscribe_with_id(
notification_id,
vec![notification.since(Timestamp::now())],
None,
)
.await
{
println!("Error: {}", e)
}
// Get user's settings
if let Ok(settings) = get_user_settings(client).await {
state.settings.lock().await.clone_from(&settings);
};
let contact_list = client
.get_contact_list(Some(Duration::from_secs(5)))
.await
.unwrap();
state.contact_list.lock().await.clone_from(&contact_list);
// Get user's contact list
if !contact_list.is_empty() {
let authors: Vec<PublicKey> = contact_list.iter().map(|f| f.public_key).collect();
let sync = Filter::new()
let metadata = Filter::new()
.authors(authors.clone())
.kind(Kind::Metadata)
.limit(authors.len());
if let Ok(report) = client
.reconcile(metadata, NegentropyOptions::default())
.await
{
println!("received [metadata]: {}", report.received.len())
}
let newsfeed = Filter::new()
.authors(authors.clone())
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(NEWSFEED_NEG_LIMIT);
if client
.reconcile(sync, NegentropyOptions::default())
.reconcile(newsfeed, NegentropyOptions::default())
.await
.is_ok()
{
handle.emit("newsfeed_synchronized", ()).unwrap();
// Save state
let _ = File::create(config_dir.join(public_key.to_bech32().unwrap()));
// Update frontend
handle.emit("synchronized", ()).unwrap();
}
let contacts = Filter::new()
.authors(authors.clone())
.kind(Kind::ContactList)
.limit(authors.len() * 1000);
if let Ok(report) = client
.reconcile(contacts, NegentropyOptions::default())
.await
{
println!("received [contact list]: {}", report.received.len())
}
for author in authors.into_iter() {
let filter = Filter::new()
.author(author)
.kind(Kind::ContactList)
.limit(1);
let mut circles = state.circles.lock().await;
let mut list: Vec<PublicKey> = Vec::new();
if let Ok(events) = client.database().query(vec![filter]).await {
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()
{
list.push(public_key)
}
}
if !list.is_empty() {
circles.insert(author, list);
};
}
}
}
} else {
handle.emit("synchronized", ()).unwrap();
};
drop(contact_list);
let sync = Filter::new()
.pubkey(public_key)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
])
.limit(NOTIFICATION_NEG_LIMIT);
// Sync notification with negentropy
if client
.reconcile(sync, NegentropyOptions::default())
.await
.is_ok()
{
handle.emit("notification_synchronized", ()).unwrap();
}
let notification = Filter::new()
.pubkey(public_key)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
])
.since(Timestamp::now());
// Subscribing for new notification...
if let Err(e) = client
.subscribe_with_id(notification_id, vec![notification], None)
.await
{
println!("Error: {}", e)
}
});
Ok(public_key)

View File

@@ -6,7 +6,7 @@ use std::{str::FromStr, time::Duration};
use tauri::State;
use crate::common::{create_event_tags, filter_converstation, parse_event, Meta};
use crate::{Nostr, FETCH_LIMIT};
use crate::{Nostr, DEFAULT_DIFFICULTY, FETCH_LIMIT};
#[derive(Debug, Clone, Serialize, Type)]
pub struct RichEvent {
@@ -28,13 +28,7 @@ pub async fn get_event(id: String, state: State<'_, Nostr>) -> Result<RichEvent,
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
let filter = Filter::new().id(event_id);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
match client.database().query(vec![filter.clone()]).await {
Ok(events) => {
if let Some(event) = events.first() {
let raw = event.as_json();
@@ -46,7 +40,29 @@ pub async fn get_event(id: String, state: State<'_, Nostr>) -> Result<RichEvent,
Ok(RichEvent { raw, parsed })
} else {
Err("Cannot found this event with current relay list".into())
match client
.get_events_of(
vec![filter],
EventSource::relays(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
Err("Cannot found this event with current relay list".into())
}
}
Err(err) => Err(err.to_string()),
}
}
}
Err(err) => Err(err.to_string()),
@@ -191,15 +207,12 @@ pub async fn get_events_by(
let author = PublicKey::parse(&public_key).map_err(|err| err.to_string())?;
let filter = Filter::new()
.kinds(vec![Kind::TextNote])
.kinds(vec![Kind::TextNote, Kind::Repost])
.author(author)
.limit(limit as usize);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.get_events_of(vec![filter], EventSource::Database)
.await
{
Ok(events) => {
@@ -224,17 +237,11 @@ pub async fn get_events_by(
#[tauri::command]
#[specta::specta]
pub async fn get_events_from_contacts(
pub async fn get_local_events(
until: Option<&str>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let contact_list = state.contact_list.lock().await;
let authors: Vec<PublicKey> = contact_list.iter().map(|f| f.public_key).collect();
if authors.is_empty() {
return Err("Contact List is empty.".into());
}
let as_of = match until {
Some(until) => Timestamp::from_str(until).map_err(|err| err.to_string())?,
@@ -244,10 +251,9 @@ pub async fn get_events_from_contacts(
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of)
.authors(authors);
.until(as_of);
match client.database().query(vec![filter], Order::Desc).await {
match client.database().query(vec![filter]).await {
Ok(events) => {
let fils = filter_converstation(events);
let futures = fils.iter().map(|ev| async move {
@@ -295,7 +301,7 @@ pub async fn get_group_events(
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.limit(20)
.until(as_of)
.authors(authors);
@@ -341,7 +347,7 @@ pub async fn get_global_events(
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.limit(20)
.until(as_of);
match client
@@ -385,7 +391,7 @@ pub async fn get_hashtag_events(
};
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.limit(20)
.until(as_of)
.hashtags(hashtags);
@@ -429,36 +435,26 @@ pub async fn publish(
// Create tags from content
let mut tags = create_event_tags(&content);
// Add client tag
// TODO: allow user config this setting
tags.push(Tag::custom(TagKind::custom("client"), vec!["Lume"]));
// Add content-warning tag if present
if let Some(reason) = warning {
let t = TagStandard::ContentWarning {
reason: Some(reason),
};
let tag = Tag::from(t);
let tag = Tag::from_standardized(t);
tags.push(tag)
};
// Get signer
let signer = match client.signer().await {
Ok(signer) => signer,
Err(_) => return Err("Signer is required.".into()),
};
// Get public key
let public_key = signer.public_key().await.map_err(|err| err.to_string())?;
// Create unsigned event
let unsigned_event = match difficulty {
Some(num) => EventBuilder::text_note(content, tags).to_unsigned_pow_event(public_key, num),
None => EventBuilder::text_note(content, tags).to_unsigned_event(public_key),
};
let builder =
EventBuilder::text_note(content, tags).pow(difficulty.unwrap_or(DEFAULT_DIFFICULTY));
// Publish
match signer.sign_event(unsigned_event).await {
Ok(event) => match client.send_event(event).await {
Ok(event_id) => Ok(event_id.to_bech32().map_err(|err| err.to_string())?),
Err(err) => Err(err.to_string()),
},
match client.send_event_builder(builder).await {
Ok(event_id) => Ok(event_id.to_bech32().unwrap()),
Err(err) => Err(err.to_string()),
}
}
@@ -479,14 +475,11 @@ pub async fn reply(
let reply_id = EventId::parse(&to).map_err(|err| err.to_string())?;
match database
.query(vec![Filter::new().id(reply_id)], Order::Desc)
.await
{
match database.query(vec![Filter::new().id(reply_id)]).await {
Ok(events) => {
if let Some(event) = events.first() {
let relay_hint = if let Some(relays) = database
.event_seen_on_relays(event.id)
.event_seen_on_relays(&event.id)
.await
.map_err(|err| err.to_string())?
{
@@ -515,13 +508,10 @@ pub async fn reply(
Err(_) => return Err("Event is not valid.".into()),
};
if let Ok(events) = database
.query(vec![Filter::new().id(root_id)], Order::Desc)
.await
{
if let Ok(events) = database.query(vec![Filter::new().id(root_id)]).await {
if let Some(event) = events.first() {
let relay_hint = if let Some(relays) = database
.event_seen_on_relays(event.id)
.event_seen_on_relays(&event.id)
.await
.map_err(|err| err.to_string())?
{
@@ -579,7 +569,7 @@ pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<Stri
let seens = client
.database()
.event_seen_on_relays(event_id)
.event_seen_on_relays(&event_id)
.await
.map_err(|err| err.to_string())?;

View File

@@ -20,13 +20,25 @@ pub struct Profile {
website: Option<String>,
}
#[derive(Clone, Serialize, Deserialize, Type)]
pub struct Mention {
pubkey: String,
avatar: String,
display_name: String,
name: String,
}
#[tauri::command]
#[specta::specta]
pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let public_key: PublicKey = match id {
Some(user_id) => PublicKey::parse(&user_id).map_err(|e| e.to_string())?,
None => client.signer().await.unwrap().public_key().await.unwrap(),
None => {
let signer = client.signer().await.map_err(|e| e.to_string())?;
signer.public_key().await.map_err(|e| e.to_string())?
}
};
let filter = Filter::new()
@@ -34,13 +46,7 @@ pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<
.kind(Kind::Metadata)
.limit(1);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(3))),
)
.await
{
match client.database().query(vec![filter.clone()]).await {
Ok(events) => {
if let Some(event) = events.first() {
if let Ok(metadata) = Metadata::from_json(&event.content) {
@@ -49,7 +55,26 @@ pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<
Err("Parse metadata failed".into())
}
} else {
Ok(Metadata::new().as_json())
match client
.get_events_of(
vec![filter],
EventSource::relays(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
if let Ok(metadata) = Metadata::from_json(&event.content) {
Ok(metadata.as_json())
} else {
Err("Parse metadata failed".into())
}
} else {
Ok(Metadata::new().as_json())
}
}
Err(e) => Err(e.to_string()),
}
}
}
Err(e) => Err(e.to_string()),
@@ -71,9 +96,6 @@ pub async fn set_contact_list(
})
.collect();
// Update local state
state.contact_list.lock().await.clone_from(&contact_list);
match client.set_contact_list(contact_list).await {
Ok(_) => Ok(true),
Err(err) => Err(err.to_string()),
@@ -85,18 +107,13 @@ pub async fn set_contact_list(
pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
let client = &state.client;
match client.get_contact_list(Some(Duration::from_secs(10))).await {
match client.get_contact_list(Some(Duration::from_secs(5))).await {
Ok(contact_list) => {
if !contact_list.is_empty() {
let list = contact_list
.into_iter()
.map(|f| f.public_key.to_hex())
.collect();
Ok(list)
} else {
Err("Empty.".into())
}
let list = contact_list
.into_iter()
.map(|f| f.public_key.to_hex())
.collect();
Ok(list)
}
Err(err) => Err(err.to_string()),
}
@@ -137,21 +154,13 @@ pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<St
#[tauri::command]
#[specta::specta]
pub async fn is_contact_list_empty(state: State<'_, Nostr>) -> Result<bool, ()> {
Ok(state.contact_list.lock().await.is_empty())
}
pub async fn check_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let contact_list = &state.contact_list.lock().await;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
#[tauri::command]
#[specta::specta]
pub async fn check_contact(hex: String, state: State<'_, Nostr>) -> Result<bool, String> {
let contact_list = state.contact_list.lock().await;
match PublicKey::parse(&hex) {
Ok(public_key) => match contact_list.iter().position(|x| x.public_key == public_key) {
Some(_) => Ok(true),
None => Ok(false),
},
Err(e) => Err(e.to_string()),
match contact_list.iter().position(|x| x.public_key == public_key) {
Some(_) => Ok(true),
None => Ok(false),
}
}
@@ -181,9 +190,6 @@ pub async fn toggle_contact(
}
}
// Update local state
state.contact_list.lock().await.clone_from(&contact_list);
// Publish
match client.set_contact_list(contact_list).await {
Ok(event_id) => Ok(event_id.to_string()),
@@ -194,6 +200,36 @@ pub async fn toggle_contact(
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_mention_list(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
let client = &state.client;
let filter = Filter::new().kind(Kind::Metadata);
let events = client
.database()
.query(vec![filter])
.await
.map_err(|e| e.to_string())?;
let data: Vec<Mention> = events
.iter()
.map(|event| {
let pubkey = event.pubkey.to_bech32().unwrap();
let metadata = Metadata::from_json(&event.content).unwrap_or(Metadata::new());
Mention {
pubkey,
avatar: metadata.picture.unwrap_or_else(|| "".to_string()),
display_name: metadata.display_name.unwrap_or_else(|| "".to_string()),
name: metadata.name.unwrap_or_else(|| "".to_string()),
}
})
.collect();
Ok(data)
}
#[tauri::command]
#[specta::specta]
pub async fn set_lume_store(
@@ -206,7 +242,7 @@ pub async fn set_lume_store(
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
let encrypted = signer
.nip44_encrypt(public_key, content)
.nip44_encrypt(&public_key, content)
.await
.map_err(|e| e.to_string())?;
let tag = Tag::identifier(key);
@@ -237,7 +273,7 @@ pub async fn get_lume_store(key: String, state: State<'_, Nostr>) -> Result<Stri
{
Ok(events) => {
if let Some(event) = get_latest_event(&events) {
match signer.nip44_decrypt(public_key, event.content()).await {
match signer.nip44_decrypt(&public_key, &event.content).await {
Ok(decrypted) => Ok(decrypted),
Err(_) => Err(event.content.to_string()),
}
@@ -382,7 +418,7 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
.await
{
for event in contact_list_events.into_iter() {
for tag in event.into_iter_tags() {
for tag in event.tags.into_iter() {
if let Some(TagStandard::PublicKey {
public_key,
relay_url,
@@ -405,74 +441,6 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
}
}
pub async fn get_following(
state: State<'_, Nostr>,
public_key: &str,
) -> Result<Vec<String>, String> {
let client = &state.client;
let public_key = PublicKey::parse(public_key).map_err(|e| e.to_string())?;
let filter = Filter::new().kind(Kind::ContactList).author(public_key);
let events = match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => events,
Err(err) => return Err(err.to_string()),
};
let mut ret: Vec<String> = vec![];
if let Some(latest_event) = events.iter().max_by_key(|event| event.created_at()) {
ret.extend(latest_event.tags().iter().filter_map(|tag| {
if let Some(TagStandard::PublicKey {
uppercase: false, ..
}) = <nostr_sdk::Tag as Clone>::clone(tag).to_standardized()
{
tag.content().map(String::from)
} else {
None
}
}));
}
Ok(ret)
}
pub async fn get_followers(
state: State<'_, Nostr>,
public_key: &str,
) -> Result<Vec<String>, String> {
let client = &state.client;
let public_key = PublicKey::parse(public_key).map_err(|e| e.to_string())?;
let filter = Filter::new().kind(Kind::ContactList).custom_tag(
SingleLetterTag::lowercase(Alphabet::P),
vec![public_key.to_hex()],
);
let events = match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => events,
Err(err) => return Err(err.to_string()),
};
let ret: Vec<String> = events
.into_iter()
.map(|event| event.author().to_hex())
.collect();
Ok(ret)
// TODO: get more than 500 events
}
#[tauri::command]
#[specta::specta]
pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
@@ -491,11 +459,7 @@ pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, S
])
.limit(200);
match client
.database()
.query(vec![filter], Order::default())
.await
{
match client.database().query(vec![filter]).await {
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
Err(err) => Err(err.to_string()),
}
@@ -522,7 +486,7 @@ pub async fn set_settings(
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 encrypted = signer
.nip44_encrypt(public_key, settings)
.nip44_encrypt(&public_key, settings)
.await
.map_err(|e| e.to_string())?;
let tag = Tag::identifier(ident);
@@ -555,3 +519,13 @@ pub async fn verify_nip05(id: String, nip05: &str) -> Result<bool, String> {
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn is_trusted_user(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let circles = &state.circles.lock().await;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
let trusted = circles.values().any(|v| v.contains(&public_key));
Ok(trusted)
}

View File

@@ -13,7 +13,6 @@ use crate::Settings;
pub struct Meta {
pub content: String,
pub images: Vec<String>,
pub videos: Vec<String>,
pub events: Vec<String>,
pub mentions: Vec<String>,
pub hashtags: Vec<String>,
@@ -44,7 +43,6 @@ const NOSTR_MENTIONS: [&str; 10] = [
"Nostr:naddr1",
];
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
const VIDEOS: [&str; 5] = ["mp4", "mov", "avi", "webm", "mkv"];
pub fn get_latest_event(events: &[Event]) -> Option<&Event> {
events.iter().next()
@@ -115,7 +113,6 @@ pub async fn parse_event(content: &str) -> Meta {
.collect::<Vec<_>>();
let mut images = Vec::new();
let mut videos = Vec::new();
let mut text = content.to_string();
if !urls.is_empty() {
@@ -135,12 +132,6 @@ pub async fn parse_event(content: &str) -> Meta {
// Process the next item.
continue;
}
if VIDEOS.contains(&ext) {
text = text.replace(url_str, "");
videos.push(url_str.to_string());
// Process the next item.
continue;
}
}
// Check the content type of URL via HEAD request
@@ -167,7 +158,6 @@ pub async fn parse_event(content: &str) -> Meta {
mentions,
hashtags,
images,
videos,
}
}
@@ -252,31 +242,17 @@ pub fn create_event_tags(content: &str) -> Vec<Tag> {
tags
}
pub async fn init_nip65(client: &Client) {
let signer = match client.signer().await {
Ok(signer) => signer,
Err(e) => {
eprintln!("Failed to get signer: {:?}", e);
return;
}
};
let public_key = match signer.public_key().await {
Ok(public_key) => public_key,
Err(e) => {
eprintln!("Failed to get public key: {:?}", e);
return;
}
};
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);
let filter = Filter::new()
.author(public_key)
.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
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
EventSource::relays(Some(Duration::from_secs(5))),
)
.await
{
@@ -288,7 +264,7 @@ pub async fn init_nip65(client: &Client) {
Some(_) => RelayOptions::new().write(true).read(false),
None => RelayOptions::default(),
};
if let Err(e) = client.add_relay_with_opts(&url.to_string(), opts).await {
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 {
@@ -298,8 +274,6 @@ pub async fn init_nip65(client: &Client) {
}
}
}
} else {
eprintln!("Failed to get events for RelayList.");
}
}
@@ -329,8 +303,7 @@ pub async fn get_user_settings(client: &Client) -> Result<Settings, String> {
{
Ok(events) => {
if let Some(event) = events.first() {
let content = event.content();
match signer.nip44_decrypt(public_key, content).await {
match signer.nip44_decrypt(&public_key, &event.content).await {
Ok(decrypted) => match serde_json::from_str(&decrypted) {
Ok(parsed) => Ok(parsed),
Err(_) => Err("Could not parse settings payload".into()),
@@ -359,7 +332,6 @@ mod tests {
assert_eq!(meta.content, "Check this image: #cool @npub1");
assert_eq!(meta.images, vec!["https://example.com/image.jpg"]);
assert_eq!(meta.videos, Vec::<String>::new());
assert_eq!(meta.hashtags, vec!["#cool"]);
assert_eq!(meta.mentions, vec!["@npub1"]);
}
@@ -371,7 +343,6 @@ mod tests {
assert_eq!(meta.content, "Check this video: #cool @npub1");
assert_eq!(meta.images, Vec::<String>::new());
assert_eq!(meta.videos, vec!["https://example.com/video.mp4"]);
assert_eq!(meta.hashtags, vec!["#cool"]);
assert_eq!(meta.mentions, vec!["@npub1"]);
}

View File

@@ -7,11 +7,13 @@
use border::WebviewWindowExt as BorderWebviewWindowExt;
use commands::{account::*, event::*, metadata::*, relay::*, window::*};
use common::parse_event;
use nostr_relay_builder::prelude::*;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use specta::Type;
use specta_typescript::Typescript;
use std::{
collections::HashMap,
fs,
io::{self, BufRead},
str::FromStr,
@@ -28,8 +30,9 @@ pub mod common;
pub struct Nostr {
client: Client,
contact_list: Mutex<Vec<Contact>>,
settings: Mutex<Settings>,
contact_list: Mutex<Vec<Contact>>,
circles: Mutex<HashMap<PublicKey, Vec<PublicKey>>>,
}
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -38,6 +41,7 @@ pub struct Settings {
image_resize_service: Option<String>,
use_relay_hint: bool,
content_warning: bool,
trusted_only: bool,
display_avatar: bool,
display_zap_button: bool,
display_repost_button: bool,
@@ -52,6 +56,7 @@ impl Default for Settings {
image_resize_service: Some("https://wsrv.nl".to_string()),
use_relay_hint: true,
content_warning: true,
trusted_only: false,
display_avatar: true,
display_zap_button: true,
display_repost_button: true,
@@ -77,12 +82,16 @@ struct Subscription {
#[derive(Serialize, Deserialize, Type, Clone, TauriEvent)]
struct NewSettings(Settings);
pub const FETCH_LIMIT: usize = 44;
pub const NEWSFEED_NEG_LIMIT: usize = 256;
pub const DEFAULT_DIFFICULTY: u8 = 21;
pub const FETCH_LIMIT: usize = 100;
pub const NEWSFEED_NEG_LIMIT: usize = 512;
pub const NOTIFICATION_NEG_LIMIT: usize = 64;
pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
fn main() {
#[cfg(debug_assertions)]
tracing_subscriber::fmt::init();
let builder = Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(collect_commands![
@@ -98,14 +107,15 @@ fn main() {
get_private_key,
delete_account,
reset_password,
is_account_sync,
login,
get_profile,
set_profile,
get_contact_list,
set_contact_list,
is_contact_list_empty,
check_contact,
toggle_contact,
get_mention_list,
get_lume_store,
set_lume_store,
set_wallet,
@@ -118,13 +128,14 @@ fn main() {
get_settings,
set_settings,
verify_nip05,
is_trusted_user,
get_event_meta,
get_event,
get_event_from,
get_replies,
subscribe_to,
get_events_by,
get_events_from_contacts,
get_local_events,
get_group_events,
get_global_events,
get_hashtag_events,
@@ -156,8 +167,6 @@ fn main() {
#[cfg(not(target_os = "macos"))]
let tauri_builder = tauri::Builder::default();
let mut ctx = tauri::generate_context!();
tauri_builder
.invoke_handler(builder.invoke_handler())
.setup(move |app| {
@@ -168,6 +177,19 @@ fn main() {
let handle_clone_child = handle_clone.clone();
let main_window = app.get_webview_window("main").unwrap();
let config_dir = handle
.path()
.app_config_dir()
.expect("Error: app config directory not found.");
let data_dir = handle
.path()
.app_data_dir()
.expect("Error: app data directory not found.");
let _ = fs::create_dir_all(&config_dir);
let _ = fs::create_dir_all(&data_dir);
// Set custom decoration for Windows
#[cfg(target_os = "windows")]
main_window.create_overlay_titlebar().unwrap();
@@ -191,23 +213,17 @@ fn main() {
});
let client = tauri::async_runtime::block_on(async move {
// Create data folder if not exist
let dir = handle
.path()
.app_config_dir()
.expect("App config directory not found.");
let _ = fs::create_dir_all(dir.clone());
// Setup database
let database = SQLiteDatabase::open(dir.join("nostr.db"))
.await
.expect("Database error.");
let database = NostrLMDB::open(config_dir.join("nostr-lmdb"))
.expect("Error: cannot create database.");
// Config
let opts = Options::new()
.gossip(true)
.max_avg_latency(Duration::from_millis(500))
.automatic_authentication(true)
.connection_timeout(Some(Duration::from_secs(5)))
.automatic_authentication(false)
.connection_timeout(Some(Duration::from_secs(20)))
.send_timeout(Some(Duration::from_secs(10)))
.timeout(Duration::from_secs(20));
// Setup nostr client
@@ -234,7 +250,7 @@ fn main() {
} else {
RelayOptions::new().write(true).read(false)
};
let _ = client.add_relay_with_opts(relay, opts).await;
let _ = client.pool().add_relay(relay, opts).await;
}
Err(_) => {
let _ = client.add_relay(relay).await;
@@ -244,6 +260,14 @@ fn main() {
}
}
if let Err(e) = client.add_discovery_relay("wss://purplepag.es/").await {
println!("Add discovery relay failed: {}", e)
}
if let Err(e) = client.add_discovery_relay("wss://directory.yabu.me/").await {
println!("Add discovery relay failed: {}", e)
}
// Connect
client.connect().await;
@@ -253,8 +277,9 @@ fn main() {
// Create global state
app.manage(Nostr {
client,
contact_list: Mutex::new(vec![]),
settings: Mutex::new(Settings::default()),
contact_list: Mutex::new(Vec::new()),
circles: Mutex::new(HashMap::new()),
});
Subscription::listen_any(app, move |event| {
@@ -269,27 +294,43 @@ fn main() {
SubKind::Subscribe => {
let subscription_id = SubscriptionId::new(payload.label);
let filter = if let Some(id) = payload.event_id {
let event_id = EventId::from_str(&id).unwrap();
match payload.event_id {
Some(id) => {
let event_id = EventId::from_str(&id).unwrap();
let filter =
Filter::new().event(event_id).since(Timestamp::now());
Filter::new().event(event_id).since(Timestamp::now())
} else {
let contact_list = state.contact_list.lock().await;
let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e)
}
}
None => {
let contact_list = client
.get_contact_list(Some(Duration::from_secs(5)))
.await
.unwrap();
Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.authors(authors)
.since(Timestamp::now())
if !contact_list.is_empty() {
let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.authors(authors)
.since(Timestamp::now());
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e)
}
}
}
};
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e)
}
}
SubKind::Unsubscribe => {
let subscription_id = SubscriptionId::new(payload.label);
@@ -299,6 +340,22 @@ fn main() {
});
});
// Run local relay thread
//tauri::async_runtime::spawn(async move {
// let database = NostrLMDB::open(data_dir.join("local-relay"))
// .expect("Error: cannot create database.");
// let builder = RelayBuilder::default().database(database).port(1984);
//
// if let Ok(relay) = LocalRelay::run(builder).await {
// println!("Running local relay: {}", relay.url())
// }
//
// loop {
// tokio::time::sleep(Duration::from_secs(60)).await;
// }
//});
// Run notification thread
tauri::async_runtime::spawn(async move {
let state = handle_clone.state::<Nostr>();
let client = &state.client;
@@ -315,11 +372,52 @@ fn main() {
};
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
let mut notifications = client.pool().notifications();
client
.handle_notifications(|notification| async {
if let RelayPoolNotification::Message { message, .. } = notification {
if let RelayMessage::Event {
while let Ok(notification) = notifications.recv().await {
match notification {
RelayPoolNotification::Message { relay_url, message } => {
if let RelayMessage::Auth { challenge } = message {
match client.auth(challenge, relay_url.clone()).await {
Ok(..) => {
if let Ok(relay) = client.relay(&relay_url).await {
let msg =
format!("Authenticated to {} relay.", relay_url);
let opts = RelaySendOptions::new()
.skip_send_confirmation(true);
if let Err(e) = relay.resubscribe(opts).await {
println!("Error: {}", e);
}
if allow_notification {
if let Err(e) = &handle_clone
.notification()
.builder()
.body(&msg)
.title("Lume")
.show()
{
println!("Error: {}", e);
}
}
}
}
Err(e) => {
if allow_notification {
if let Err(e) = &handle_clone
.notification()
.builder()
.body(e.to_string())
.title("Lume")
.show()
{
println!("Error: {}", e);
}
}
}
}
} else if let RelayMessage::Event {
subscription_id,
event,
} = message
@@ -329,11 +427,14 @@ fn main() {
// Send native notification
if allow_notification {
let author = client
.metadata(event.pubkey)
.fetch_metadata(
event.pubkey,
Some(Duration::from_secs(3)),
)
.await
.unwrap_or_else(|_| Metadata::new());
send_notification(&event, author, &handle_clone);
send_event_notification(&event, author, &handle_clone);
}
}
@@ -352,19 +453,50 @@ fn main() {
RichEvent { raw, parsed },
)
.unwrap();
} else {
println!("new message: {}", message.as_json())
}
};
}
Ok(false)
})
.await
RelayPoolNotification::Shutdown => break,
_ => (),
}
}
});
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::Focused(focused) = event {
if !focused {
let handle = window.app_handle().to_owned();
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
if client.signer().await.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();
let newsfeed = Filter::new()
.authors(authors)
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(NEWSFEED_NEG_LIMIT);
if client
.reconcile(newsfeed, NegentropyOptions::default())
.await
.is_ok()
{
handle.emit("synchronized", ()).unwrap();
}
}
}
});
}
}
})
.plugin(prevent_default())
.plugin(tauri_plugin_theme::init(ctx.config_mut()))
.plugin(tauri_plugin_decorum::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_clipboard_manager::init())
@@ -377,7 +509,7 @@ fn main() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.run(ctx)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
@@ -395,8 +527,8 @@ fn prevent_default() -> tauri::plugin::TauriPlugin<tauri::Wry> {
tauri_plugin_prevent_default::Builder::new().build()
}
fn send_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle) {
match event.kind() {
fn send_event_notification(event: &Event, author: Metadata, handle: &tauri::AppHandle) {
match event.kind {
Kind::TextNote => {
if let Err(e) = handle
.notification()

View File

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"productName": "Lume",
"version": "4.1.0",
"version": "4.2.0",
"identifier": "nu.lume.Lume",
"build": {
"beforeDevCommand": "pnpm dev",

View File

@@ -1,27 +1,24 @@
import {
type PersistedQuery,
experimental_createPersister,
} from "@tanstack/query-persist-client-core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { type } from "@tauri-apps/plugin-os";
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { newQueryStorage } from "./commons";
import { routeTree } from "./routes.gen"; // auto generated file
import type { LumeEvent } from "./system";
import "./app.css";
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
import { Store } from "@tauri-apps/plugin-store";
import { newQueryStorage } from "./commons";
const platform = type();
const tauriStore = new Store(".lume.dat");
const store = new Store(".cache");
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 30,
persister: experimental_createPersister<PersistedQuery>({
storage: newQueryStorage(tauriStore),
maxAge: 1000 * 60 * 60 * 24, // 24 hours,
persister: experimental_createPersister({
storage: newQueryStorage(store),
maxAge: 1000 * 60 * 60 * 12, // 12 hours
}),
},
},

View File

@@ -56,7 +56,7 @@ async createAccount(name: string, about: string, picture: string, password: stri
else return { status: "error", error: e as any };
}
},
async importAccount(key: string, password: string | null) : Promise<Result<string, string>> {
async importAccount(key: string, password: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("import_account", { key, password }) };
} catch (e) {
@@ -96,6 +96,9 @@ async resetPassword(key: string, password: string) : Promise<Result<null, string
else return { status: "error", error: e as any };
}
},
async isAccountSync(id: string) : Promise<boolean> {
return await TAURI_INVOKE("is_account_sync", { id });
},
async login(account: string, password: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("login", { account, password }) };
@@ -136,17 +139,9 @@ async setContactList(publicKeys: string[]) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async isContactListEmpty() : Promise<Result<boolean, null>> {
async checkContact(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_contact_list_empty") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async checkContact(hex: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("check_contact", { hex }) };
return { status: "ok", data: await TAURI_INVOKE("check_contact", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -160,6 +155,14 @@ async toggleContact(id: string, alias: string | null) : Promise<Result<string, s
else return { status: "error", error: e as any };
}
},
async getMentionList() : Promise<Result<Mention[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_mention_list") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getLumeStore(key: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_lume_store", { key }) };
@@ -256,6 +259,14 @@ async verifyNip05(id: string, nip05: string) : Promise<Result<boolean, string>>
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>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) };
@@ -304,9 +315,9 @@ async getEventsBy(publicKey: string, limit: number) : Promise<Result<RichEvent[]
else return { status: "error", error: e as any };
}
},
async getEventsFromContacts(until: string | null) : Promise<Result<RichEvent[], string>> {
async getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_events_from_contacts", { until }) };
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -458,12 +469,13 @@ subscription: "subscription"
/** user-defined types **/
export type Column = { label: string; url: string; x: number; y: number; width: number; height: number }
export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: 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 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 Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | 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; 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 Subscription = { label: string; kind: SubKind; event_id: string | null }
export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean }
@@ -484,7 +496,7 @@ type __EventObj__<T> = {
once: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: T extends null
emit: null extends T
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
};

View File

@@ -15,8 +15,6 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale";
import { decode } from "light-bolt11-decoder";
import { type BaseEditor, Transforms } from "slate";
import { ReactEditor } from "slate-react";
import { twMerge } from "tailwind-merge";
import type { RichEvent, Settings } from "./commands.gen";
import { LumeEvent } from "./system";
@@ -59,51 +57,6 @@ export const isImageUrl = (url: string) => {
}
};
export const insertImage = (editor: ReactEditor | BaseEditor, url: string) => {
const text = { text: "" };
const image = [
{
type: "image",
url,
children: [text],
},
];
const extraText = [
{
type: "paragraph",
children: [text],
},
];
// @ts-ignore, idk
ReactEditor.focus(editor);
Transforms.insertNodes(editor, image);
Transforms.insertNodes(editor, extraText);
};
export const insertNostrEvent = (
editor: ReactEditor | BaseEditor,
eventId: string,
) => {
const text = { text: "" };
const event = [
{
type: "event",
eventId: `nostr:${eventId}`,
children: [text],
},
];
const extraText = [
{
type: "paragraph",
children: [text],
},
];
Transforms.insertNodes(editor, event);
Transforms.insertNodes(editor, extraText);
};
export function formatCreatedAt(time: number, message = false) {
let formated: string;
@@ -257,18 +210,16 @@ export async function upload(filePath?: string) {
];
const selected =
filePath ||
(
await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
})
).path;
filePath ??
(await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
}));
// User cancelled action
if (!selected) return null;
@@ -331,6 +282,7 @@ export const appSettings = new Store<Settings>({
image_resize_service: "https://wsrv.nl",
use_relay_hint: true,
content_warning: true,
trusted_only: true,
display_avatar: true,
display_zap_button: true,
display_repost_button: true,

View File

@@ -87,7 +87,7 @@ export const Column = memo(function Column({ column }: { column: LumeColumn }) {
return (
<div className="h-full w-[440px] shrink-0 p-2">
<div className="flex flex-col w-full h-full rounded-xl bg-black/5 dark:bg-white/15">
<div className="flex flex-col w-full h-full rounded-xl bg-black/5 dark:bg-white/20">
<Header label={column.label} name={column.name} />
<div ref={container} className="flex-1 w-full h-full">
{!isCreated ? (

View File

@@ -1,5 +1,5 @@
import { LumeWindow } from "@/system";
import { FrameCorners } from "@phosphor-icons/react";
import { ListPlus } from "@phosphor-icons/react";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useNoteContext } from "../provider";
@@ -15,7 +15,7 @@ export function NoteOpenThread() {
onClick={() => LumeWindow.openEvent(event)}
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
>
<FrameCorners className="shrink-0 size-4" />
<ListPlus className="shrink-0 size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>

View File

@@ -41,11 +41,11 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
const menuItems = await Promise.all([
MenuItem.new({
text: "Quote",
text: "Repost",
action: async () => repost(),
}),
MenuItem.new({
text: "Repost",
text: "Quote",
action: () => LumeWindow.openEditor(null, event.id),
}),
]);

View File

@@ -7,7 +7,6 @@ import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { Images } from "./preview/images";
import { Videos } from "./preview/videos";
import { useNoteContext } from "./provider";
export function NoteContent({
@@ -102,14 +101,9 @@ export function NoteContent({
{content}
</div>
{visible ? (
<>
{event.meta?.images.length ? (
<Images urls={event.meta.images} />
) : null}
{event.meta?.videos.length ? (
<Videos urls={event.meta.videos} />
) : null}
</>
event.meta?.images.length ? (
<Images urls={event.meta.images} />
) : null
) : null}
</div>
);

View File

@@ -6,7 +6,6 @@ import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { ImagePreview } from "./preview/image";
import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider";
export function NoteContentLarge({
@@ -18,7 +17,7 @@ export function NoteContentLarge({
const content = useMemo(() => {
try {
// Get parsed meta
const { images, videos, hashtags, events, mentions } = event.meta;
const { images, hashtags, events, mentions } = event.meta;
// Define rich content
let richContent: ReactNode[] | string = event.content;
@@ -48,12 +47,6 @@ export function NoteContentLarge({
));
}
for (const video of videos) {
richContent = reactStringReplace(richContent, video, (match, i) => (
<VideoPreview key={match + i} url={match} />
));
}
richContent = reactStringReplace(
richContent,
/(https?:\/\/\S+)/gi,
@@ -75,8 +68,7 @@ export function NoteContentLarge({
));
return richContent;
} catch (e) {
console.log("[parser]: ", e);
} catch {
return event.content;
}
}, [event.content]);

View File

@@ -1,9 +1,24 @@
import { cn, replyTime } from "@/commons";
import { commands } from "@/commands.gen";
import { appSettings, cn, replyTime } from "@/commons";
import { Note } from "@/components/note";
import type { LumeEvent } from "@/system";
import { type LumeEvent, LumeWindow } from "@/system";
import { CaretDown } from "@phosphor-icons/react";
import { Link, useSearch } from "@tanstack/react-router";
import { memo } from "react";
import { useStore } from "@tanstack/react-store";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { nip19 } from "nostr-tools";
import {
type ReactNode,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./note/mentions/hashtag";
import { MentionUser } from "./note/mentions/user";
import { User } from "./user";
export const ReplyNote = memo(function ReplyNote({
@@ -13,24 +28,72 @@ export const ReplyNote = memo(function ReplyNote({
event: LumeEvent;
className?: string;
}) {
const trustedOnly = useStore(appSettings, (state) => state.trusted_only);
const search = useSearch({ strict: false });
const [isTrusted, setIsTrusted] = useState<boolean>(null);
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(event.pubkey),
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
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 (
<Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}>
<Note.Root className={cn("flex gap-2.5", className)}>
<User.Root className="shrink-0">
<User.Avatar className="size-8 rounded-full" />
<button type="button" onClick={(e) => showContextMenu(e)}>
<User.Avatar className="size-8 rounded-full" />
</button>
</User.Root>
<div className="flex-1 flex flex-col gap-1">
<div>
<User.Name
className="shrink-0 inline font-medium text-blue-500"
className="mr-2 shrink-0 inline font-medium text-blue-500"
suffix=":"
/>
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
{event.content}
</div>
<Content
text={event.content}
className="inline select-text text-balance content-break overflow-hidden"
/>
</div>
<div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500">
@@ -74,17 +137,44 @@ export const ReplyNote = memo(function ReplyNote({
function ChildReply({ event }: { event: LumeEvent }) {
const search = useSearch({ strict: false });
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(event.pubkey),
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
return (
<Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}>
<div className="group flex flex-col gap-1">
<div>
<User.Root className="inline">
<User.Name className="font-medium text-blue-500" suffix=":" />
<User.Root className="inline mr-2">
<button type="button" onClick={(e) => showContextMenu(e)}>
<User.Name className="font-medium text-blue-500" suffix=":" />
</button>
</User.Root>
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
{event.content}
</div>
<Content
text={event.content}
className="inline select-text text-balance content-break overflow-hidden"
/>
</div>
<div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500">
@@ -124,3 +214,64 @@ function ChildReply({ event }: { event: LumeEvent }) {
</Note.Provider>
);
}
function Content({ text, className }: { text: string; className?: string }) {
const content = useMemo(() => {
let replacedText: ReactNode[] | string = text.trim();
const nostr = replacedText
.split(/\s+/)
.filter((w) => w.startsWith("nostr:"));
replacedText = reactStringReplace(text, /(https?:\/\/\S+)/g, (match, i) => (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
));
replacedText = reactStringReplace(replacedText, /#(\w+)/g, (match, i) => (
<Hashtag key={match + i} tag={match} />
));
for (const word of nostr) {
const bech32 = word.replace("nostr:", "");
const data = nip19.decode(bech32);
switch (data.type) {
case "npub":
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<MentionUser key={match + i} pubkey={data.data} />
));
break;
case "nprofile":
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<MentionUser key={match + i} pubkey={data.data.pubkey} />
));
break;
default:
replacedText = reactStringReplace(replacedText, word, (match, i) => (
<a
key={match + i}
href={`https://njump.me/${bech32}`}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
));
break;
}
}
return replacedText;
}, [text]);
return <div className={className}>{content}</div>;
}

View File

@@ -15,8 +15,7 @@ export function UserAvatar({ className }: { className?: string }) {
const picture = useMemo(() => {
if (service?.length && user.profile?.picture?.length) {
const url = `${service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
return url;
return `${service}?url=${user.profile?.picture}&w=100&h=100&n=-1&default=${user.profile?.picture}`;
} else {
return user.profile?.picture;
}

View File

@@ -59,10 +59,10 @@ export function UserFollowButton({ className }: { className?: string }) {
type="button"
disabled={isPending}
onClick={() => toggleFollow()}
className={cn("w-max", className)}
className={cn("w-max gap-1", className)}
>
{isError ? "Error" : null}
{isPending || isLoading ? <Spinner className="size-4" /> : null}
{isPending || isLoading ? <Spinner className="size-3" /> : null}
{isFollow ? "Unfollow" : "Follow"}
</button>
);

View File

@@ -13,6 +13,7 @@ import { createFileRoute } from '@tanstack/react-router'
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as LoadingImport } from './routes/loading'
import { Route as BootstrapRelaysImport } from './routes/bootstrap-relays'
import { Route as IndexImport } from './routes/index'
import { Route as EditorIndexImport } from './routes/editor/index'
@@ -95,6 +96,11 @@ const NewLazyRoute = NewLazyImport.update({
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/new.lazy').then((d) => d.Route))
const LoadingRoute = LoadingImport.update({
path: '/loading',
getParentRoute: () => rootRoute,
} as any)
const BootstrapRelaysRoute = BootstrapRelaysImport.update({
path: '/bootstrap-relays',
getParentRoute: () => rootRoute,
@@ -338,6 +344,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BootstrapRelaysImport
parentRoute: typeof rootRoute
}
'/loading': {
id: '/loading'
path: '/loading'
fullPath: '/loading'
preLoaderRoute: typeof LoadingImport
parentRoute: typeof rootRoute
}
'/new': {
id: '/new'
path: '/new'
@@ -595,51 +608,396 @@ declare module '@tanstack/react-router' {
// Create and export the route tree
export const routeTree = rootRoute.addChildren({
IndexRoute,
BootstrapRelaysRoute,
NewLazyRoute,
ResetLazyRoute,
AccountRoute: AccountRoute.addChildren({
AccountAppRoute: AccountAppRoute.addChildren({ AccountAppHomeRoute }),
AccountBackupRoute,
AccountSettingsLazyRoute: AccountSettingsLazyRoute.addChildren({
AccountSettingsBitcoinConnectRoute,
AccountSettingsGeneralRoute,
AccountSettingsProfileRoute,
AccountSettingsRelayRoute,
AccountSettingsWalletRoute,
}),
}),
ColumnsRoute: ColumnsRoute.addChildren({
ColumnsLayoutRoute: ColumnsLayoutRoute.addChildren({
ColumnsLayoutCreateGroupRoute,
ColumnsLayoutCreateNewsfeedRoute:
ColumnsLayoutCreateNewsfeedRoute.addChildren({
ColumnsLayoutCreateNewsfeedF2fRoute,
ColumnsLayoutCreateNewsfeedUsersRoute,
}),
ColumnsLayoutGalleryRoute,
ColumnsLayoutGlobalRoute,
ColumnsLayoutGroupRoute,
ColumnsLayoutStoriesRoute,
ColumnsLayoutNewsfeedLazyRoute,
ColumnsLayoutNotificationLazyRoute,
ColumnsLayoutOnboardingLazyRoute,
ColumnsLayoutSearchLazyRoute,
ColumnsLayoutTrendingLazyRoute,
ColumnsLayoutEventsIdLazyRoute,
ColumnsLayoutHashtagsContentLazyRoute,
ColumnsLayoutRepliesIdLazyRoute,
ColumnsLayoutUsersIdLazyRoute,
}),
}),
ZapIdRoute,
AuthConnectLazyRoute,
AuthImportLazyRoute,
AuthNewLazyRoute,
EditorIndexRoute,
})
interface AccountAppRouteChildren {
AccountAppHomeRoute: typeof AccountAppHomeRoute
}
const AccountAppRouteChildren: AccountAppRouteChildren = {
AccountAppHomeRoute: AccountAppHomeRoute,
}
const AccountAppRouteWithChildren = AccountAppRoute._addFileChildren(
AccountAppRouteChildren,
)
interface AccountSettingsLazyRouteChildren {
AccountSettingsBitcoinConnectRoute: typeof AccountSettingsBitcoinConnectRoute
AccountSettingsGeneralRoute: typeof AccountSettingsGeneralRoute
AccountSettingsProfileRoute: typeof AccountSettingsProfileRoute
AccountSettingsRelayRoute: typeof AccountSettingsRelayRoute
AccountSettingsWalletRoute: typeof AccountSettingsWalletRoute
}
const AccountSettingsLazyRouteChildren: AccountSettingsLazyRouteChildren = {
AccountSettingsBitcoinConnectRoute: AccountSettingsBitcoinConnectRoute,
AccountSettingsGeneralRoute: AccountSettingsGeneralRoute,
AccountSettingsProfileRoute: AccountSettingsProfileRoute,
AccountSettingsRelayRoute: AccountSettingsRelayRoute,
AccountSettingsWalletRoute: AccountSettingsWalletRoute,
}
const AccountSettingsLazyRouteWithChildren =
AccountSettingsLazyRoute._addFileChildren(AccountSettingsLazyRouteChildren)
interface AccountRouteChildren {
AccountAppRoute: typeof AccountAppRouteWithChildren
AccountBackupRoute: typeof AccountBackupRoute
AccountSettingsLazyRoute: typeof AccountSettingsLazyRouteWithChildren
}
const AccountRouteChildren: AccountRouteChildren = {
AccountAppRoute: AccountAppRouteWithChildren,
AccountBackupRoute: AccountBackupRoute,
AccountSettingsLazyRoute: AccountSettingsLazyRouteWithChildren,
}
const AccountRouteWithChildren =
AccountRoute._addFileChildren(AccountRouteChildren)
interface ColumnsLayoutCreateNewsfeedRouteChildren {
ColumnsLayoutCreateNewsfeedF2fRoute: typeof ColumnsLayoutCreateNewsfeedF2fRoute
ColumnsLayoutCreateNewsfeedUsersRoute: typeof ColumnsLayoutCreateNewsfeedUsersRoute
}
const ColumnsLayoutCreateNewsfeedRouteChildren: ColumnsLayoutCreateNewsfeedRouteChildren =
{
ColumnsLayoutCreateNewsfeedF2fRoute: ColumnsLayoutCreateNewsfeedF2fRoute,
ColumnsLayoutCreateNewsfeedUsersRoute:
ColumnsLayoutCreateNewsfeedUsersRoute,
}
const ColumnsLayoutCreateNewsfeedRouteWithChildren =
ColumnsLayoutCreateNewsfeedRoute._addFileChildren(
ColumnsLayoutCreateNewsfeedRouteChildren,
)
interface ColumnsLayoutRouteChildren {
ColumnsLayoutCreateGroupRoute: typeof ColumnsLayoutCreateGroupRoute
ColumnsLayoutCreateNewsfeedRoute: typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
ColumnsLayoutGalleryRoute: typeof ColumnsLayoutGalleryRoute
ColumnsLayoutGlobalRoute: typeof ColumnsLayoutGlobalRoute
ColumnsLayoutGroupRoute: typeof ColumnsLayoutGroupRoute
ColumnsLayoutStoriesRoute: typeof ColumnsLayoutStoriesRoute
ColumnsLayoutNewsfeedLazyRoute: typeof ColumnsLayoutNewsfeedLazyRoute
ColumnsLayoutNotificationLazyRoute: typeof ColumnsLayoutNotificationLazyRoute
ColumnsLayoutOnboardingLazyRoute: typeof ColumnsLayoutOnboardingLazyRoute
ColumnsLayoutSearchLazyRoute: typeof ColumnsLayoutSearchLazyRoute
ColumnsLayoutTrendingLazyRoute: typeof ColumnsLayoutTrendingLazyRoute
ColumnsLayoutEventsIdLazyRoute: typeof ColumnsLayoutEventsIdLazyRoute
ColumnsLayoutHashtagsContentLazyRoute: typeof ColumnsLayoutHashtagsContentLazyRoute
ColumnsLayoutRepliesIdLazyRoute: typeof ColumnsLayoutRepliesIdLazyRoute
ColumnsLayoutUsersIdLazyRoute: typeof ColumnsLayoutUsersIdLazyRoute
}
const ColumnsLayoutRouteChildren: ColumnsLayoutRouteChildren = {
ColumnsLayoutCreateGroupRoute: ColumnsLayoutCreateGroupRoute,
ColumnsLayoutCreateNewsfeedRoute:
ColumnsLayoutCreateNewsfeedRouteWithChildren,
ColumnsLayoutGalleryRoute: ColumnsLayoutGalleryRoute,
ColumnsLayoutGlobalRoute: ColumnsLayoutGlobalRoute,
ColumnsLayoutGroupRoute: ColumnsLayoutGroupRoute,
ColumnsLayoutStoriesRoute: ColumnsLayoutStoriesRoute,
ColumnsLayoutNewsfeedLazyRoute: ColumnsLayoutNewsfeedLazyRoute,
ColumnsLayoutNotificationLazyRoute: ColumnsLayoutNotificationLazyRoute,
ColumnsLayoutOnboardingLazyRoute: ColumnsLayoutOnboardingLazyRoute,
ColumnsLayoutSearchLazyRoute: ColumnsLayoutSearchLazyRoute,
ColumnsLayoutTrendingLazyRoute: ColumnsLayoutTrendingLazyRoute,
ColumnsLayoutEventsIdLazyRoute: ColumnsLayoutEventsIdLazyRoute,
ColumnsLayoutHashtagsContentLazyRoute: ColumnsLayoutHashtagsContentLazyRoute,
ColumnsLayoutRepliesIdLazyRoute: ColumnsLayoutRepliesIdLazyRoute,
ColumnsLayoutUsersIdLazyRoute: ColumnsLayoutUsersIdLazyRoute,
}
const ColumnsLayoutRouteWithChildren = ColumnsLayoutRoute._addFileChildren(
ColumnsLayoutRouteChildren,
)
interface ColumnsRouteChildren {
ColumnsLayoutRoute: typeof ColumnsLayoutRouteWithChildren
}
const ColumnsRouteChildren: ColumnsRouteChildren = {
ColumnsLayoutRoute: ColumnsLayoutRouteWithChildren,
}
const ColumnsRouteWithChildren =
ColumnsRoute._addFileChildren(ColumnsRouteChildren)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/bootstrap-relays': typeof BootstrapRelaysRoute
'/loading': typeof LoadingRoute
'/new': typeof NewLazyRoute
'/reset': typeof ResetLazyRoute
'/$account': typeof AccountSettingsLazyRouteWithChildren
'/$account/backup': typeof AccountBackupRoute
'/columns': typeof ColumnsLayoutRouteWithChildren
'/zap/$id': typeof ZapIdRoute
'/auth/connect': typeof AuthConnectLazyRoute
'/auth/import': typeof AuthImportLazyRoute
'/auth/new': typeof AuthNewLazyRoute
'/editor': typeof EditorIndexRoute
'/$account/home': typeof AccountAppHomeRoute
'/$account/bitcoin-connect': typeof AccountSettingsBitcoinConnectRoute
'/$account/general': typeof AccountSettingsGeneralRoute
'/$account/profile': typeof AccountSettingsProfileRoute
'/$account/relay': typeof AccountSettingsRelayRoute
'/$account/wallet': typeof AccountSettingsWalletRoute
'/columns/create-group': typeof ColumnsLayoutCreateGroupRoute
'/columns/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
'/columns/gallery': typeof ColumnsLayoutGalleryRoute
'/columns/global': typeof ColumnsLayoutGlobalRoute
'/columns/group': typeof ColumnsLayoutGroupRoute
'/columns/stories': typeof ColumnsLayoutStoriesRoute
'/columns/newsfeed': typeof ColumnsLayoutNewsfeedLazyRoute
'/columns/notification': typeof ColumnsLayoutNotificationLazyRoute
'/columns/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
'/columns/search': typeof ColumnsLayoutSearchLazyRoute
'/columns/trending': typeof ColumnsLayoutTrendingLazyRoute
'/columns/create-newsfeed/f2f': typeof ColumnsLayoutCreateNewsfeedF2fRoute
'/columns/create-newsfeed/users': typeof ColumnsLayoutCreateNewsfeedUsersRoute
'/columns/events/$id': typeof ColumnsLayoutEventsIdLazyRoute
'/columns/hashtags/$content': typeof ColumnsLayoutHashtagsContentLazyRoute
'/columns/replies/$id': typeof ColumnsLayoutRepliesIdLazyRoute
'/columns/users/$id': typeof ColumnsLayoutUsersIdLazyRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/bootstrap-relays': typeof BootstrapRelaysRoute
'/loading': typeof LoadingRoute
'/new': typeof NewLazyRoute
'/reset': typeof ResetLazyRoute
'/$account': typeof AccountSettingsLazyRouteWithChildren
'/$account/backup': typeof AccountBackupRoute
'/columns': typeof ColumnsLayoutRouteWithChildren
'/zap/$id': typeof ZapIdRoute
'/auth/connect': typeof AuthConnectLazyRoute
'/auth/import': typeof AuthImportLazyRoute
'/auth/new': typeof AuthNewLazyRoute
'/editor': typeof EditorIndexRoute
'/$account/home': typeof AccountAppHomeRoute
'/$account/bitcoin-connect': typeof AccountSettingsBitcoinConnectRoute
'/$account/general': typeof AccountSettingsGeneralRoute
'/$account/profile': typeof AccountSettingsProfileRoute
'/$account/relay': typeof AccountSettingsRelayRoute
'/$account/wallet': typeof AccountSettingsWalletRoute
'/columns/create-group': typeof ColumnsLayoutCreateGroupRoute
'/columns/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
'/columns/gallery': typeof ColumnsLayoutGalleryRoute
'/columns/global': typeof ColumnsLayoutGlobalRoute
'/columns/group': typeof ColumnsLayoutGroupRoute
'/columns/stories': typeof ColumnsLayoutStoriesRoute
'/columns/newsfeed': typeof ColumnsLayoutNewsfeedLazyRoute
'/columns/notification': typeof ColumnsLayoutNotificationLazyRoute
'/columns/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
'/columns/search': typeof ColumnsLayoutSearchLazyRoute
'/columns/trending': typeof ColumnsLayoutTrendingLazyRoute
'/columns/create-newsfeed/f2f': typeof ColumnsLayoutCreateNewsfeedF2fRoute
'/columns/create-newsfeed/users': typeof ColumnsLayoutCreateNewsfeedUsersRoute
'/columns/events/$id': typeof ColumnsLayoutEventsIdLazyRoute
'/columns/hashtags/$content': typeof ColumnsLayoutHashtagsContentLazyRoute
'/columns/replies/$id': typeof ColumnsLayoutRepliesIdLazyRoute
'/columns/users/$id': typeof ColumnsLayoutUsersIdLazyRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/bootstrap-relays': typeof BootstrapRelaysRoute
'/loading': typeof LoadingRoute
'/new': typeof NewLazyRoute
'/reset': typeof ResetLazyRoute
'/$account': typeof AccountRouteWithChildren
'/$account/_app': typeof AccountAppRouteWithChildren
'/$account/backup': typeof AccountBackupRoute
'/columns': typeof ColumnsRouteWithChildren
'/columns/_layout': typeof ColumnsLayoutRouteWithChildren
'/zap/$id': typeof ZapIdRoute
'/$account/_settings': typeof AccountSettingsLazyRouteWithChildren
'/auth/connect': typeof AuthConnectLazyRoute
'/auth/import': typeof AuthImportLazyRoute
'/auth/new': typeof AuthNewLazyRoute
'/editor/': typeof EditorIndexRoute
'/$account/_app/home': typeof AccountAppHomeRoute
'/$account/_settings/bitcoin-connect': typeof AccountSettingsBitcoinConnectRoute
'/$account/_settings/general': typeof AccountSettingsGeneralRoute
'/$account/_settings/profile': typeof AccountSettingsProfileRoute
'/$account/_settings/relay': typeof AccountSettingsRelayRoute
'/$account/_settings/wallet': typeof AccountSettingsWalletRoute
'/columns/_layout/create-group': typeof ColumnsLayoutCreateGroupRoute
'/columns/_layout/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
'/columns/_layout/gallery': typeof ColumnsLayoutGalleryRoute
'/columns/_layout/global': typeof ColumnsLayoutGlobalRoute
'/columns/_layout/group': typeof ColumnsLayoutGroupRoute
'/columns/_layout/stories': typeof ColumnsLayoutStoriesRoute
'/columns/_layout/newsfeed': typeof ColumnsLayoutNewsfeedLazyRoute
'/columns/_layout/notification': typeof ColumnsLayoutNotificationLazyRoute
'/columns/_layout/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
'/columns/_layout/search': typeof ColumnsLayoutSearchLazyRoute
'/columns/_layout/trending': typeof ColumnsLayoutTrendingLazyRoute
'/columns/_layout/create-newsfeed/f2f': typeof ColumnsLayoutCreateNewsfeedF2fRoute
'/columns/_layout/create-newsfeed/users': typeof ColumnsLayoutCreateNewsfeedUsersRoute
'/columns/_layout/events/$id': typeof ColumnsLayoutEventsIdLazyRoute
'/columns/_layout/hashtags/$content': typeof ColumnsLayoutHashtagsContentLazyRoute
'/columns/_layout/replies/$id': typeof ColumnsLayoutRepliesIdLazyRoute
'/columns/_layout/users/$id': typeof ColumnsLayoutUsersIdLazyRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/bootstrap-relays'
| '/loading'
| '/new'
| '/reset'
| '/$account'
| '/$account/backup'
| '/columns'
| '/zap/$id'
| '/auth/connect'
| '/auth/import'
| '/auth/new'
| '/editor'
| '/$account/home'
| '/$account/bitcoin-connect'
| '/$account/general'
| '/$account/profile'
| '/$account/relay'
| '/$account/wallet'
| '/columns/create-group'
| '/columns/create-newsfeed'
| '/columns/gallery'
| '/columns/global'
| '/columns/group'
| '/columns/stories'
| '/columns/newsfeed'
| '/columns/notification'
| '/columns/onboarding'
| '/columns/search'
| '/columns/trending'
| '/columns/create-newsfeed/f2f'
| '/columns/create-newsfeed/users'
| '/columns/events/$id'
| '/columns/hashtags/$content'
| '/columns/replies/$id'
| '/columns/users/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/bootstrap-relays'
| '/loading'
| '/new'
| '/reset'
| '/$account'
| '/$account/backup'
| '/columns'
| '/zap/$id'
| '/auth/connect'
| '/auth/import'
| '/auth/new'
| '/editor'
| '/$account/home'
| '/$account/bitcoin-connect'
| '/$account/general'
| '/$account/profile'
| '/$account/relay'
| '/$account/wallet'
| '/columns/create-group'
| '/columns/create-newsfeed'
| '/columns/gallery'
| '/columns/global'
| '/columns/group'
| '/columns/stories'
| '/columns/newsfeed'
| '/columns/notification'
| '/columns/onboarding'
| '/columns/search'
| '/columns/trending'
| '/columns/create-newsfeed/f2f'
| '/columns/create-newsfeed/users'
| '/columns/events/$id'
| '/columns/hashtags/$content'
| '/columns/replies/$id'
| '/columns/users/$id'
id:
| '__root__'
| '/'
| '/bootstrap-relays'
| '/loading'
| '/new'
| '/reset'
| '/$account'
| '/$account/_app'
| '/$account/backup'
| '/columns'
| '/columns/_layout'
| '/zap/$id'
| '/$account/_settings'
| '/auth/connect'
| '/auth/import'
| '/auth/new'
| '/editor/'
| '/$account/_app/home'
| '/$account/_settings/bitcoin-connect'
| '/$account/_settings/general'
| '/$account/_settings/profile'
| '/$account/_settings/relay'
| '/$account/_settings/wallet'
| '/columns/_layout/create-group'
| '/columns/_layout/create-newsfeed'
| '/columns/_layout/gallery'
| '/columns/_layout/global'
| '/columns/_layout/group'
| '/columns/_layout/stories'
| '/columns/_layout/newsfeed'
| '/columns/_layout/notification'
| '/columns/_layout/onboarding'
| '/columns/_layout/search'
| '/columns/_layout/trending'
| '/columns/_layout/create-newsfeed/f2f'
| '/columns/_layout/create-newsfeed/users'
| '/columns/_layout/events/$id'
| '/columns/_layout/hashtags/$content'
| '/columns/_layout/replies/$id'
| '/columns/_layout/users/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BootstrapRelaysRoute: typeof BootstrapRelaysRoute
LoadingRoute: typeof LoadingRoute
NewLazyRoute: typeof NewLazyRoute
ResetLazyRoute: typeof ResetLazyRoute
AccountRoute: typeof AccountRouteWithChildren
ColumnsRoute: typeof ColumnsRouteWithChildren
ZapIdRoute: typeof ZapIdRoute
AuthConnectLazyRoute: typeof AuthConnectLazyRoute
AuthImportLazyRoute: typeof AuthImportLazyRoute
AuthNewLazyRoute: typeof AuthNewLazyRoute
EditorIndexRoute: typeof EditorIndexRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BootstrapRelaysRoute: BootstrapRelaysRoute,
LoadingRoute: LoadingRoute,
NewLazyRoute: NewLazyRoute,
ResetLazyRoute: ResetLazyRoute,
AccountRoute: AccountRouteWithChildren,
ColumnsRoute: ColumnsRouteWithChildren,
ZapIdRoute: ZapIdRoute,
AuthConnectLazyRoute: AuthConnectLazyRoute,
AuthImportLazyRoute: AuthImportLazyRoute,
AuthNewLazyRoute: AuthNewLazyRoute,
EditorIndexRoute: EditorIndexRoute,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* prettier-ignore-end */
@@ -651,6 +1009,7 @@ export const routeTree = rootRoute.addChildren({
"children": [
"/",
"/bootstrap-relays",
"/loading",
"/new",
"/reset",
"/$account",
@@ -668,6 +1027,9 @@ export const routeTree = rootRoute.addChildren({
"/bootstrap-relays": {
"filePath": "bootstrap-relays.tsx"
},
"/loading": {
"filePath": "loading.tsx"
},
"/new": {
"filePath": "new.lazy.tsx"
},

View File

@@ -61,6 +61,11 @@ function Screen() {
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">
@@ -128,11 +133,12 @@ function Screen() {
</div>
</div>
</div>
<div className="sticky bottom-0 left-0 w-full h-11 flex items-center justify-end px-3 bg-white/20 dark:bg-black-20 backdrop-blur-md border-t border-black/5 dark:border-white/5">
<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="inline-flex items-center justify-center w-20 rounded-md shadow h-7 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium"
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>

View File

@@ -68,12 +68,12 @@ function Screen() {
placeholder="bunker://..."
value={uri}
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"
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"
/>
<button
type="button"
onClick={() => pasteFromClipboard()}
className="absolute top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500"
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>

View File

@@ -84,24 +84,26 @@ function Screen() {
placeholder="nsec or ncryptsec..."
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-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
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"
/>
<button
type="button"
onClick={() => pasteFromClipboard()}
className="absolute uppercase top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500"
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>
{key.length && !key.startsWith("ncryptsec") ? (
{key.length ? (
<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 key
{!key.startsWith("ncryptsec")
? "Set password to secure your key"
: "Enter password to decrypt your key"}
</label>
<input
name="password"

View File

@@ -110,7 +110,7 @@ function Screen() {
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-600"
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">
@@ -126,7 +126,7 @@ function Screen() {
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-600"
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" />
@@ -142,7 +142,7 @@ function Screen() {
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-600"
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>

View File

@@ -182,7 +182,7 @@ function ReplyList() {
useEffect(() => {
events.subscription
.emit({ label, kind: "Subscribe", event_id: id, local_only: undefined })
.emit({ label, kind: "Subscribe", event_id: id })
.then(() => console.log("Subscribe: ", label));
return () => {
@@ -191,7 +191,6 @@ function ReplyList() {
label,
kind: "Unsubscribe",
event_id: id,
local_only: undefined,
})
.then(() => console.log("Unsubscribe: ", label));
};
@@ -225,7 +224,7 @@ function ReplyList() {
{isLoading ? (
<div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<Spinner className="size-4" />
<span className="text-sm font-medium">Getting replies...</span>
</div>
</div>

View File

@@ -48,7 +48,7 @@ export function Screen() {
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const until = pageParam > 0 ? pageParam.toString() : undefined;
const res = await commands.getEventsFromContacts(until);
const res = await commands.getLocalEvents(until);
if (res.status === "error") {
throw new Error(res.error);
@@ -82,7 +82,7 @@ export function Screen() {
);
useEffect(() => {
const unlisten = listen("newsfeed_synchronized", async () => {
const unlisten = listen("synchronized", async () => {
await queryClient.invalidateQueries({ queryKey: [label, account] });
});

View File

@@ -1,7 +1,7 @@
import { commands } from "@/commands.gen";
import { decodeZapInvoice, formatCreatedAt } from "@/commons";
import { Note, Spinner, User } from "@/components";
import { LumeEvent, useEvent } from "@/system";
import { LumeEvent, LumeWindow, useEvent } from "@/system";
import { Kind, type NostrEvent } from "@/types";
import { Info, Repeat } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
@@ -264,40 +264,46 @@ function TextNote({ event }: { event: LumeEvent }) {
.slice(0, 3);
return (
<Note.Provider event={event}>
<Note.Root className="flex flex-col p-3 mb-3 bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
<User.Provider pubkey={event.pubkey}>
<User.Root className="inline-flex items-center gap-2">
<User.Avatar className="rounded-full size-9" />
<div className="flex flex-col flex-1">
<div className="flex items-baseline justify-between w-full">
<User.Name className="text-sm font-semibold leading-tight" />
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
{formatCreatedAt(event.created_at)}
</span>
</div>
<div className="inline-flex items-baseline gap-1 text-xs">
<span className="leading-tight text-black/50 dark:text-white/50">
Reply to:
</span>
<div className="inline-flex items-baseline gap-1">
{[...new Set(pTags)].map((replyTo) => (
<User.Provider key={replyTo} pubkey={replyTo}>
<User.Root>
<User.Name className="font-medium leading-tight" />
</User.Root>
</User.Provider>
))}
<button
type="button"
onClick={() => LumeWindow.openEvent(event)}
className="w-full rounded-xl hover:ring-1 ring-blue-500 mb-3"
>
<Note.Provider event={event}>
<Note.Root className="flex flex-col p-3 rounded-xl bg-white dark:bg-black/20 shadow-primary dark:ring-1 dark:ring-white/5">
<User.Provider pubkey={event.pubkey}>
<User.Root className="inline-flex items-center gap-2">
<User.Avatar className="rounded-full size-9" />
<div className="flex flex-col flex-1">
<div className="flex items-baseline justify-between w-full">
<User.Name className="text-sm font-semibold leading-tight" />
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
{formatCreatedAt(event.created_at)}
</span>
</div>
<div className="inline-flex items-baseline gap-1 text-xs">
<span className="leading-tight text-black/50 dark:text-white/50">
Reply to:
</span>
<div className="inline-flex items-baseline gap-1">
{[...new Set(pTags)].map((replyTo) => (
<User.Provider key={replyTo} pubkey={replyTo}>
<User.Root>
<User.Name className="font-medium leading-tight" />
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
</div>
</User.Root>
</User.Provider>
<div className="flex gap-2">
<div className="w-9 shrink-0" />
<div className="line-clamp-1 text-start">{event.content}</div>
</div>
</Note.Root>
</Note.Provider>
</User.Root>
</User.Provider>
<div className="flex gap-2">
<div className="w-9 shrink-0" />
<div className="line-clamp-1 text-start">{event.content}</div>
</div>
</Note.Root>
</Note.Provider>
</button>
);
}

View File

@@ -11,7 +11,7 @@ export const Route = createLazyFileRoute("/columns/_layout/trending")({
component: Screen,
});
export function Screen() {
function Screen() {
const { isLoading, isError, data } = useQuery({
queryKey: ["trending-notes"],
queryFn: async ({ signal }) => {

View File

@@ -1,20 +1,31 @@
import { insertImage, isImagePath, upload } from "@/commons";
import { isImagePath, upload } from "@/commons";
import { Spinner } from "@/components";
import { Images } from "@phosphor-icons/react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useTransition } from "react";
import { useSlateStatic } from "slate-react";
import {
type Dispatch,
type SetStateAction,
useEffect,
useTransition,
} from "react";
export function MediaButton() {
const editor = useSlateStatic();
export function MediaButton({
setText,
setAttaches,
}: {
setText: Dispatch<SetStateAction<string>>;
setAttaches: Dispatch<SetStateAction<string[]>>;
}) {
const [isPending, startTransition] = useTransition();
const uploadMedia = () => {
startTransition(async () => {
try {
const image = await upload();
return insertImage(editor, image);
setText((prev) => `${prev}\n${image}`);
setAttaches((prev) => [...prev, image]);
return;
} catch (e) {
await message(String(e), { title: "Upload", kind: "error" });
return;
@@ -32,7 +43,8 @@ export function MediaButton() {
for (const item of items) {
if (isImagePath(item)) {
const image = await upload(item);
insertImage(editor, image);
setText((prev) => `${prev}\n${image}`);
setAttaches((prev) => [...prev, image]);
}
}

View File

@@ -1,23 +1,20 @@
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@/commons";
// @ts-nocheck
import { type Mention, commands } from "@/commands.gen";
import { cn } from "@/commons";
import { Spinner } from "@/components";
import { Note } from "@/components/note";
import { MentionNote } from "@/components/note/mentions/note";
import { User } from "@/components/user";
import { LumeEvent, useEvent } from "@/system";
import { Feather } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools";
import { useEffect, useState } from "react";
import { type Descendant, Node, Transforms, createEditor } from "slate";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { createPortal } from "react-dom";
import {
Editable,
ReactEditor,
Slate,
useFocused,
useSelected,
useSlateStatic,
withReact,
} from "slate-react";
RichTextarea,
type RichTextareaHandle,
createRegexRenderer,
} from "rich-textarea";
import { MediaButton } from "./-components/media";
import { PowButton } from "./-components/pow";
import { WarningButton } from "./-components/warning";
@@ -27,11 +24,33 @@ type EditorSearch = {
quote: string;
};
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
};
const MENTION_REG = /\B@([\-+\w]*)$/;
const MAX_LIST_LENGTH = 5;
const renderer = createRegexRenderer([
[
/https?:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+/g,
({ children, key, value }) => (
<a
key={key}
href={value}
target="_blank"
rel="noreferrer"
className="text-blue-500 !underline"
>
{children}
</a>
),
],
[
/(?:^|\W)nostr:(\w+)(?!\w)/g,
({ children, key, value }) => (
<span key={key} className="text-blue-500">
{children}
</span>
),
],
]);
export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): EditorSearch => {
@@ -40,201 +59,295 @@ export const Route = createFileRoute("/editor/")({
quote: search.quote,
};
},
beforeLoad: ({ search }) => {
let initialValue: EditorElement[];
beforeLoad: async ({ search }) => {
let users: Mention[] = [];
let initialValue: string;
if (search?.quote?.length) {
const eventId = nip19.noteEncode(search.quote);
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
eventId: `nostr:${eventId}`,
children: [{ text: "" }],
},
];
initialValue = `\nnostr:${nip19.noteEncode(search.quote)}`;
} else {
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
];
initialValue = "";
}
return { initialValue };
const res = await commands.getMentionList();
if (res.status === "ok") {
users = res.data;
}
return { users, initialValue };
},
component: Screen,
});
function Screen() {
const { reply_to } = Route.useSearch();
const { initialValue } = Route.useRouteContext();
const { users, initialValue } = Route.useRouteContext();
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition();
const [text, setText] = useState("");
const [attaches, setAttaches] = useState<string[]>(null);
const [warning, setWarning] = useState({ enable: false, reason: "" });
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
const [index, setIndex] = useState<number>(0);
const [pos, setPos] = useState<{
top: number;
left: number;
caret: number;
} | null>(null);
const ref = useRef<RichTextareaHandle>(null);
const targetText = pos ? text.slice(0, pos.caret) : text;
const match = pos && targetText.match(MENTION_REG);
const name = match?.[1] ?? "";
const filtered = useMemo(
() =>
users
.filter((u) => u.name.toLowerCase().startsWith(name.toLowerCase()))
.slice(0, MAX_LIST_LENGTH),
[name],
);
const reset = () => {
// @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
};
const insert = (i: number) => {
if (!ref.current || !pos) return;
const serialize = (nodes: Descendant[]) => {
return nodes
.map((n) => {
// @ts-expect-error, backlog
if (n.type === "image") return n.url;
// @ts-expect-error, backlog
if (n.type === "event") return n.eventId;
const selected = filtered[i];
// @ts-expect-error, backlog
if (n.children.length) {
// @ts-expect-error, backlog
return n.children
.map((n) => {
if (n.type === "mention") return n.npub;
return Node.string(n).trim();
})
.join(" ");
}
ref.current.setRangeText(
`nostr:${selected.pubkey} `,
pos.caret - name.length - 1,
pos.caret,
"end",
);
return Node.string(n);
})
.join("\n");
setPos(null);
setIndex(0);
};
const publish = async () => {
try {
// start loading
setLoading(true);
startTransition(async () => {
try {
const content = text.trim();
const content = serialize(editor.children);
const eventId = await LumeEvent.publish(
content,
warning.enable && warning.reason.length ? warning.reason : null,
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
reply_to,
);
await LumeEvent.publish(
content,
warning.enable && warning.reason.length ? warning.reason : null,
difficulty.num,
reply_to,
);
if (eventId) {
// stop loading
setLoading(false);
// reset form
reset();
setText("");
} catch {
return;
}
} catch (e) {
setLoading(false);
}
});
};
useEffect(() => {
setEditorValue(initialValue);
if (initialValue?.length) {
setText(initialValue);
}
}, [initialValue]);
if (!editorValue) return null;
return (
<div className="flex flex-col w-full h-full">
<Slate editor={editor} initialValue={editorValue}>
<div data-tauri-drag-region className="h-11 shrink-0" />
<div className="flex flex-col flex-1 overflow-y-auto">
{reply_to?.length ? (
<div className="flex flex-col gap-2 px-3.5 pb-3 border-b border-black/5 dark:border-white/5">
<span className="text-sm font-semibold">Reply to:</span>
<ChildNote id={reply_to} />
</div>
) : null}
<div className="px-4 py-4 overflow-y-auto">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
autoCapitalize="none"
autoCorrect="none"
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={
reply_to ? "Type your reply..." : "What're you up to?"
}
className="focus:outline-none"
/>
</div>
</div>
{warning.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Reason:
</span>
<input
type="text"
placeholder="NSFW..."
value={warning.reason}
onChange={(e) =>
setWarning((prev) => ({ ...prev, reason: e.target.value }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
<div data-tauri-drag-region className="h-11 shrink-0" />
<div className="flex flex-col flex-1 overflow-y-auto">
{reply_to?.length ? (
<div className="flex flex-col gap-2 px-3.5 pb-3 border-b border-black/5 dark:border-white/5">
<span className="text-sm font-semibold">Reply to:</span>
<EmbedNote id={reply_to} />
</div>
) : null}
{difficulty.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Difficulty:
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]"
onKeyDown={(event) => {
if (!/[0-9]/.test(event.key)) {
event.preventDefault();
<div className="p-4 overflow-y-auto h-full">
<RichTextarea
ref={ref}
value={text}
placeholder={reply_to ? "Type your reply..." : "What're you up to?"}
style={{ width: "100%", height: "100%" }}
className="text-[15px] leading-normal resize-none border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 placeholder:pt-[1.5px] placeholder:pl-2"
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (!pos || !filtered.length) return;
switch (e.code) {
case "ArrowUp": {
e.preventDefault();
const nextIndex =
index <= 0 ? filtered.length - 1 : index - 1;
setIndex(nextIndex);
break;
}
}}
placeholder="21"
defaultValue={difficulty.num}
onChange={(e) =>
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
case "ArrowDown": {
e.preventDefault();
const prevIndex =
index >= filtered.length - 1 ? 0 : index + 1;
setIndex(prevIndex);
break;
}
case "Enter":
e.preventDefault();
insert(index);
break;
case "Escape":
e.preventDefault();
setPos(null);
setIndex(0);
break;
default:
break;
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
<div
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"
>
<button
type="button"
onClick={() => publish()}
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"
}}
onSelectionChange={(r) => {
if (
r.focused &&
MENTION_REG.test(text.slice(0, r.selectionStart))
) {
setPos({
top: r.top + r.height,
left: r.left,
caret: r.selectionStart,
});
setIndex(0);
} else {
setPos(null);
setIndex(0);
}
}}
>
{loading ? (
<Spinner className="size-4" />
) : (
<Feather className="size-4" weight="fill" />
)}
Publish
</button>
<div className="inline-flex items-center flex-1 gap-2 pl-4">
<MediaButton />
<WarningButton setWarning={setWarning} />
<PowButton setDifficulty={setDifficulty} />
</div>
{renderer}
</RichTextarea>
{pos
? createPortal(
<Menu
top={pos.top}
left={pos.left}
users={filtered}
index={index}
insert={insert}
/>,
document.body,
)
: null}
</div>
</Slate>
</div>
{warning.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Reason:
</span>
<input
type="text"
placeholder="NSFW..."
value={warning.reason}
onChange={(e) =>
setWarning((prev) => ({ ...prev, reason: e.target.value }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
{difficulty.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Difficulty:
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]"
onKeyDown={(event) => {
if (!/[0-9]/.test(event.key)) {
event.preventDefault();
}
}}
placeholder="21"
defaultValue={difficulty.num}
onChange={(e) =>
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
<div
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"
>
<button
type="button"
onClick={() => publish()}
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"
>
{isPending ? (
<Spinner className="size-4" />
) : (
<Feather className="size-4" weight="fill" />
)}
Publish
</button>
<div className="inline-flex items-center flex-1 gap-2 pl-4">
<MediaButton setText={setText} setAttaches={setAttaches} />
<WarningButton setWarning={setWarning} />
<PowButton setDifficulty={setDifficulty} />
</div>
</div>
</div>
);
}
function ChildNote({ id }: { id: string }) {
function Menu({
users,
index,
top,
left,
insert,
}: {
users: Mention[];
index: number;
top: number;
left: number;
insert: (index: number) => void;
}) {
return (
<div
style={{
top: top,
left: left,
}}
className="fixed w-[200px] text-sm bg-white dark:bg-black shadow-lg shadow-neutral-500/20 dark:shadow-none dark:ring-1 dark:ring-neutral-700 rounded-lg overflow-hidden"
>
{users.map((u, i) => (
<div
key={u.pubkey}
className={cn(
"flex items-center gap-1.5 p-2",
index === i ? "bg-neutral-100 dark:bg-neutral-900" : null,
)}
onMouseDown={(e) => {
e.preventDefault();
insert(i);
}}
>
<div className="size-7 shrink-0">
{u.avatar?.length ? (
<img
src={u.avatar}
className="size-7 rounded-full outline outline-1 -outline-offset-1 outline-black/15"
loading="lazy"
decoding="async"
/>
) : (
<div className="size-7 rounded-full bg-blue-500" />
)}
</div>
{u.name}
</div>
))}
</div>
);
}
function EmbedNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id);
if (isLoading) {
@@ -258,142 +371,3 @@ function ChildNote({ id }: { id: string }) {
</Note.Provider>
);
}
const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (text.startsWith("nevent") || text.startsWith("note")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
}
};
return editor;
};
const withMentions = (editor: ReactEditor) => {
const { isInline, isVoid, markableVoid } = editor;
editor.isInline = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element);
};
editor.markableVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element);
};
return editor;
};
const withImages = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (isImageUrl(text)) {
insertImage(editor, text);
} else {
insertData(data);
}
};
return editor;
};
const Image = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const selected = useSelected();
const focused = useFocused();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
<img
src={element.url}
alt={element.url}
className={cn(
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
/>
</div>
);
};
const Mention = ({ attributes, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<span
{...attributes}
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
>{`@${element.name}`}</span>
);
};
const Event = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
<div
contentEditable={false}
className="relative my-2 user-select-none"
onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
>
<MentionNote eventId={element.eventId} />
</div>
</div>
);
};
const Element = (props) => {
const { attributes, children, element } = props;
switch (element.type) {
case "image":
return <Image {...props} />;
case "mention":
return <Mention {...props} />;
case "event":
return <Event {...props} />;
default:
return (
<p {...attributes} className="text-[15px]">
{children}
</p>
);
}
};

View File

@@ -64,11 +64,21 @@ function Screen() {
appSettings.setState(() => settings.data);
}
navigate({
to: "/$account/home",
params: { account: res.data },
replace: true,
});
const status = await commands.isAccountSync(res.data);
if (status) {
navigate({
to: "/$account/home",
params: { account: res.data },
replace: true,
});
} else {
navigate({
to: "/loading",
search: { account: res.data },
replace: true,
});
}
} else {
await message(res.error, { title: "Login", kind: "error" });
return;
@@ -147,8 +157,9 @@ function Screen() {
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 dark:placeholder:text-neutral-600"
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>
) : (

View File

@@ -17,6 +17,6 @@ export const Route = createFileRoute("/")({
});
}
return { accounts };
return { accounts: accounts.filter((account) => !account.endsWith("Lume")) };
},
});

47
src/routes/loading.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { 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("synchronized", () => {
navigate({
to: "/$account/home",
params: { account: search.account },
replace: true,
});
});
return () => {
unlisten.then((f) => f());
};
}, []);
return (
<div className="size-full flex items-center justify-center">
<div className="flex flex-col gap-2 items-center justify-center text-center">
<Spinner />
<p className="text-sm">
Fetching necessary data for the first time login...
</p>
</div>
</div>
);
}

View File

@@ -27,8 +27,6 @@ export function useEvent(id: string) {
}
}
console.log(relayHint);
// Build query
if (relayHint?.length) {
query = await commands.getEventFrom(normalizeId, relayHint);
@@ -56,7 +54,7 @@ export function useEvent(id: string) {
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: false,
retry: 2,
});
return { isLoading, isError, error, data };

View File

@@ -17,18 +17,15 @@ export function useProfile(pubkey: string, embed?: string) {
}
let normalizeId = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
let relayHint: string;
if (normalizeId.startsWith("nprofile")) {
const decoded = nip19.decode(normalizeId);
if (decoded.type === "nprofile") {
relayHint = decoded.data.relays[0];
normalizeId = decoded.data.pubkey;
}
}
console.log(relayHint);
const query = await commands.getProfile(normalizeId);
if (query.status === "ok") {
@@ -41,7 +38,7 @@ export function useProfile(pubkey: string, embed?: string) {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: false,
retry: 2,
});
return { isLoading, isError, profile };

View File

@@ -58,7 +58,7 @@ export const LumeWindow = {
eTags.find((el) => el[3] === "reply")?.[1] ?? eTags[1]?.[1];
const url = `/columns/events/${root ?? reply ?? event.id}`;
const label = `event-${root ?? reply ?? event.id}`;
const label = `event-${root?.substring(0, 6) ?? reply?.substring(0, 6) ?? event.id.substring(0, 6)}`;
LumeWindow.openColumn({ label, url, name: "Thread" });
},

View File

@@ -13,7 +13,6 @@ export enum Kind {
export interface Meta {
content: string;
images: string[];
videos: string[];
events: string[];
mentions: string[];
hashtags: string[];