refactor: app settings

This commit is contained in:
2024-10-30 10:57:43 +07:00
parent 11dcef4e87
commit 618a45d349
33 changed files with 617 additions and 629 deletions

View File

@@ -24,8 +24,6 @@
"@tanstack/query-persist-client-core": "^5.59.16", "@tanstack/query-persist-client-core": "^5.59.16",
"@tanstack/react-query": "^5.59.16", "@tanstack/react-query": "^5.59.16",
"@tanstack/react-router": "^1.77.5", "@tanstack/react-router": "^1.77.5",
"@tanstack/react-store": "^0.5.6",
"@tanstack/store": "^0.5.5",
"@tauri-apps/api": "^2.0.3", "@tauri-apps/api": "^2.0.3",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0", "@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.1", "@tauri-apps/plugin-dialog": "^2.0.1",

6
pnpm-lock.yaml generated
View File

@@ -50,12 +50,6 @@ importers:
'@tanstack/react-router': '@tanstack/react-router':
specifier: ^1.77.5 specifier: ^1.77.5
version: 1.77.5(@tanstack/router-generator@1.74.2)(react-dom@19.0.0-rc-cae764ce-20241025(react@19.0.0-rc-cae764ce-20241025))(react@19.0.0-rc-cae764ce-20241025) version: 1.77.5(@tanstack/router-generator@1.74.2)(react-dom@19.0.0-rc-cae764ce-20241025(react@19.0.0-rc-cae764ce-20241025))(react@19.0.0-rc-cae764ce-20241025)
'@tanstack/react-store':
specifier: ^0.5.6
version: 0.5.6(react-dom@19.0.0-rc-cae764ce-20241025(react@19.0.0-rc-cae764ce-20241025))(react@19.0.0-rc-cae764ce-20241025)
'@tanstack/store':
specifier: ^0.5.5
version: 0.5.5
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.0.3 specifier: ^2.0.3
version: 2.0.3 version: 2.0.3

16
src-tauri/Cargo.lock generated
View File

@@ -3480,7 +3480,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]] [[package]]
name = "nostr" name = "nostr"
version = "0.35.0" version = "0.35.0"
source = "git+https://github.com/rust-nostr/nostr#282fbc39373674b7394806d47311a8e483da0ef0" source = "git+https://github.com/rust-nostr/nostr#939ebbdbc0b7c605411676e810c775ac3d80ef94"
dependencies = [ dependencies = [
"aes", "aes",
"base64 0.22.1", "base64 0.22.1",
@@ -3510,7 +3510,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-database" name = "nostr-database"
version = "0.35.0" version = "0.35.0"
source = "git+https://github.com/rust-nostr/nostr#282fbc39373674b7394806d47311a8e483da0ef0" source = "git+https://github.com/rust-nostr/nostr#939ebbdbc0b7c605411676e810c775ac3d80ef94"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"flatbuffers", "flatbuffers",
@@ -3524,7 +3524,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-lmdb" name = "nostr-lmdb"
version = "0.35.0" version = "0.35.0"
source = "git+https://github.com/rust-nostr/nostr#282fbc39373674b7394806d47311a8e483da0ef0" source = "git+https://github.com/rust-nostr/nostr#939ebbdbc0b7c605411676e810c775ac3d80ef94"
dependencies = [ dependencies = [
"heed", "heed",
"nostr", "nostr",
@@ -3537,7 +3537,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-relay-pool" name = "nostr-relay-pool"
version = "0.35.0" version = "0.35.0"
source = "git+https://github.com/rust-nostr/nostr#282fbc39373674b7394806d47311a8e483da0ef0" source = "git+https://github.com/rust-nostr/nostr#939ebbdbc0b7c605411676e810c775ac3d80ef94"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"async-wsocket", "async-wsocket",
@@ -3555,7 +3555,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-sdk" name = "nostr-sdk"
version = "0.35.0" version = "0.35.0"
source = "git+https://github.com/rust-nostr/nostr#282fbc39373674b7394806d47311a8e483da0ef0" source = "git+https://github.com/rust-nostr/nostr#939ebbdbc0b7c605411676e810c775ac3d80ef94"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"atomic-destructor", "atomic-destructor",
@@ -3575,7 +3575,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-signer" name = "nostr-signer"
version = "0.35.0" version = "0.35.0"
source = "git+https://github.com/rust-nostr/nostr#282fbc39373674b7394806d47311a8e483da0ef0" source = "git+https://github.com/rust-nostr/nostr#939ebbdbc0b7c605411676e810c775ac3d80ef94"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"nostr", "nostr",
@@ -3588,7 +3588,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-zapper" name = "nostr-zapper"
version = "0.35.0" version = "0.35.0"
source = "git+https://github.com/rust-nostr/nostr#282fbc39373674b7394806d47311a8e483da0ef0" source = "git+https://github.com/rust-nostr/nostr#939ebbdbc0b7c605411676e810c775ac3d80ef94"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"nostr", "nostr",
@@ -3732,7 +3732,7 @@ dependencies = [
[[package]] [[package]]
name = "nwc" name = "nwc"
version = "0.35.0" version = "0.35.0"
source = "git+https://github.com/rust-nostr/nostr#282fbc39373674b7394806d47311a8e483da0ef0" source = "git+https://github.com/rust-nostr/nostr#939ebbdbc0b7c605411676e810c775ac3d80ef94"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"nostr", "nostr",

View File

@@ -5,7 +5,7 @@ use specta::Type;
use std::{str::FromStr, time::Duration}; use std::{str::FromStr, time::Duration};
use tauri::{Emitter, State}; use tauri::{Emitter, State};
use crate::{common::get_all_accounts, Nostr}; use crate::{common::get_all_accounts, Nostr, Settings};
#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Serialize, Deserialize, Type)]
struct Account { struct Account {
@@ -249,3 +249,21 @@ pub async fn set_signer(
} }
} }
} }
#[tauri::command]
#[specta::specta]
pub async fn get_app_settings(state: State<'_, Nostr>) -> Result<Settings, String> {
let settings = state.settings.read().await.clone();
Ok(settings)
}
#[tauri::command]
#[specta::specta]
pub async fn set_app_settings(settings: String, state: State<'_, Nostr>) -> Result<(), String> {
let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
let mut settings = state.settings.write().await;
// Update state
settings.clone_from(&parsed);
Ok(())
}

View File

@@ -36,6 +36,7 @@ pub async fn get_event(id: String, state: State<'_, Nostr>) -> Result<RichEvent,
Ok(RichEvent { raw, parsed }) Ok(RichEvent { raw, parsed })
} else { } else {
let filter = Filter::new().id(event_id); let filter = Filter::new().id(event_id);
let mut rich_event = RichEvent { let mut rich_event = RichEvent {
raw: "".to_string(), raw: "".to_string(),
parsed: None, parsed: None,

View File

@@ -7,21 +7,9 @@ use tauri::{Emitter, Manager, State};
use crate::{ use crate::{
common::{get_latest_event, process_event}, common::{get_latest_event, process_event},
Nostr, RichEvent, Settings, Nostr, RichEvent,
}; };
#[derive(Clone, Serialize, Deserialize, Type)]
pub struct Profile {
name: String,
display_name: String,
about: Option<String>,
picture: String,
banner: Option<String>,
nip05: Option<String>,
lud16: Option<String>,
website: Option<String>,
}
#[derive(Clone, Serialize, Deserialize, Type)] #[derive(Clone, Serialize, Deserialize, Type)]
pub struct Mention { pub struct Mention {
pubkey: String, pubkey: String,
@@ -116,30 +104,9 @@ pub async fn get_contact_list(id: String, state: State<'_, Nostr>) -> Result<Vec
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<String, String> { pub async fn set_profile(new_profile: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client; let client = &state.client;
let mut metadata = Metadata::new() let metadata = Metadata::from_json(new_profile).map_err(|e| e.to_string())?;
.name(profile.name)
.display_name(profile.display_name)
.about(profile.about.unwrap_or_default())
.nip05(profile.nip05.unwrap_or_default())
.lud16(profile.lud16.unwrap_or_default());
if let Ok(url) = Url::parse(&profile.picture) {
metadata = metadata.picture(url)
}
if let Some(b) = profile.banner {
if let Ok(url) = Url::parse(&b) {
metadata = metadata.banner(url)
}
}
if let Some(w) = profile.website {
if let Ok(url) = Url::parse(&w) {
metadata = metadata.website(url)
}
}
match client.set_metadata(&metadata).await { match client.set_metadata(&metadata).await {
Ok(id) => Ok(id.to_string()), Ok(id) => Ok(id.to_string()),
@@ -612,21 +579,6 @@ pub async fn get_notifications(id: String, state: State<'_, Nostr>) -> Result<Ve
} }
} }
#[tauri::command]
#[specta::specta]
pub async fn get_user_settings(state: State<'_, Nostr>) -> Result<Settings, String> {
Ok(state.settings.lock().await.clone())
}
#[tauri::command]
#[specta::specta]
pub async fn set_user_settings(settings: String, state: State<'_, Nostr>) -> Result<(), String> {
let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
state.settings.lock().await.clone_from(&parsed);
Ok(())
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn verify_nip05(id: String, nip05: &str) -> Result<bool, String> { pub async fn verify_nip05(id: String, nip05: &str) -> Result<bool, String> {

View File

@@ -22,7 +22,7 @@ use tauri::{path::BaseDirectory, Emitter, EventTarget, Listener, Manager};
use tauri_plugin_decorum::WebviewWindowExt; use tauri_plugin_decorum::WebviewWindowExt;
use tauri_plugin_notification::{NotificationExt, PermissionState}; use tauri_plugin_notification::{NotificationExt, PermissionState};
use tauri_specta::{collect_commands, Builder}; use tauri_specta::{collect_commands, Builder};
use tokio::{sync::Mutex, sync::RwLock, time::sleep}; use tokio::{sync::RwLock, time::sleep};
pub mod commands; pub mod commands;
pub mod common; pub mod common;
@@ -30,7 +30,7 @@ pub mod common;
pub struct Nostr { pub struct Nostr {
client: Client, client: Client,
queue: RwLock<HashSet<PublicKey>>, queue: RwLock<HashSet<PublicKey>>,
settings: Mutex<Settings>, settings: RwLock<Settings>,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@@ -40,37 +40,30 @@ pub struct Payload {
#[derive(Clone, Serialize, Deserialize, Type)] #[derive(Clone, Serialize, Deserialize, Type)]
pub struct Settings { pub struct Settings {
proxy: Option<String>, resize_service: bool,
image_resize_service: Option<String>,
use_relay_hint: bool,
content_warning: bool, content_warning: bool,
trusted_only: bool,
display_avatar: bool, display_avatar: bool,
display_zap_button: bool, display_zap_button: bool,
display_repost_button: bool, display_repost_button: bool,
display_media: bool, display_media: bool,
transparent: bool,
} }
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
proxy: None,
image_resize_service: Some("https://wsrv.nl".to_string()),
use_relay_hint: true,
content_warning: true, content_warning: true,
trusted_only: false, resize_service: true,
display_avatar: true, display_avatar: true,
display_zap_button: true, display_zap_button: true,
display_repost_button: true, display_repost_button: true,
display_media: true, display_media: true,
transparent: true,
} }
} }
} }
pub const DEFAULT_DIFFICULTY: u8 = 0; pub const DEFAULT_DIFFICULTY: u8 = 0;
pub const FETCH_LIMIT: usize = 50; pub const FETCH_LIMIT: usize = 50;
pub const QUEUE_DELAY: u64 = 300;
pub const NOTIFICATION_SUB_ID: &str = "lume_notification"; pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
fn main() { fn main() {
@@ -113,8 +106,6 @@ fn main() {
zap_event, zap_event,
copy_friend, copy_friend,
get_notifications, get_notifications,
get_user_settings,
set_user_settings,
verify_nip05, verify_nip05,
get_meta_from_event, get_meta_from_event,
get_event, get_event,
@@ -138,6 +129,8 @@ fn main() {
close_column, close_column,
close_all_columns, close_all_columns,
open_window, open_window,
get_app_settings,
set_app_settings,
]); ]);
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@@ -182,7 +175,7 @@ fn main() {
// Config // Config
let opts = Options::new() let opts = Options::new()
.gossip(true) .gossip(false)
.max_avg_latency(Duration::from_millis(300)) .max_avg_latency(Duration::from_millis(300))
.automatic_authentication(true) .automatic_authentication(true)
.connection_timeout(Some(Duration::from_secs(5))) .connection_timeout(Some(Duration::from_secs(5)))
@@ -238,7 +231,7 @@ fn main() {
app.manage(Nostr { app.manage(Nostr {
client, client,
queue: RwLock::new(HashSet::new()), queue: RwLock::new(HashSet::new()),
settings: Mutex::new(Settings::default()), settings: RwLock::new(Settings::default()),
}); });
// Listen for request metadata // Listen for request metadata
@@ -250,22 +243,27 @@ fn main() {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>(); let state = handle.state::<Nostr>();
let client = &state.client; let client = &state.client;
let mut write_queue = state.queue.write().await;
if let Ok(public_key) = PublicKey::parse(parsed_payload.id) { if let Ok(public_key) = PublicKey::parse(parsed_payload.id) {
let mut write_queue = state.queue.write().await;
write_queue.insert(public_key); write_queue.insert(public_key);
} }
sleep(Duration::from_millis(300)).await; sleep(Duration::from_millis(QUEUE_DELAY)).await;
let read_queue = state.queue.read().await; let read_queue = state.queue.read().await;
let filter_opts = FilterOptions::WaitDurationAfterEOSE(Duration::from_secs(2));
let opts = SubscribeAutoCloseOptions::default().filter(filter_opts);
let limit = read_queue.len() * 2;
let authors: Vec<PublicKey> = read_queue.iter().copied().collect(); let authors: Vec<PublicKey> = read_queue.iter().copied().collect();
let filter = Filter::new().authors(authors).kind(Kind::Metadata); let filter = Filter::new()
let opts = SubscribeAutoCloseOptions::default() .authors(authors)
.filter(FilterOptions::WaitDurationAfterEOSE(Duration::from_secs(3))); .kind(Kind::Metadata)
.limit(limit);
if client.subscribe(vec![filter], Some(opts)).await.is_ok() { if client.subscribe(vec![filter], Some(opts)).await.is_ok() {
let mut write_queue = state.queue.write().await;
write_queue.clear(); write_queue.clear();
} }
}); });

View File

@@ -45,7 +45,7 @@ broadcastQueryClient({
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
context: { queryClient, platform }, context: { store, queryClient, platform },
Wrap: ({ children }) => { Wrap: ({ children }) => {
return ( return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>

View File

@@ -136,9 +136,9 @@ async getProfile(id: string) : Promise<Result<string, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async setProfile(profile: Profile) : Promise<Result<string, string>> { async setProfile(newProfile: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("set_profile", { profile }) }; return { status: "ok", data: await TAURI_INVOKE("set_profile", { newProfile }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -288,22 +288,6 @@ async getNotifications(id: string) : Promise<Result<string[], string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getUserSettings() : Promise<Result<Settings, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_user_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setUserSettings(settings: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_user_settings", { settings }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async verifyNip05(id: string, nip05: string) : Promise<Result<boolean, string>> { async verifyNip05(id: string, nip05: string) : Promise<Result<boolean, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("verify_nip05", { id, nip05 }) }; return { status: "ok", data: await TAURI_INVOKE("verify_nip05", { id, nip05 }) };
@@ -487,6 +471,22 @@ async openWindow(window: NewWindow) : Promise<Result<string, string>> {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
},
async getAppSettings() : Promise<Result<Settings, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_app_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setAppSettings(settings: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_app_settings", { settings }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
} }
} }
@@ -504,10 +504,9 @@ export type Column = { label: string; url: string; x: number; y: number; width:
export type Mention = { pubkey: string; avatar: string; display_name: string; name: string } export type Mention = { pubkey: string; avatar: string; display_name: string; name: string }
export type Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] } export type Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] }
export type NewWindow = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean; closable: boolean } export type NewWindow = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean; closable: boolean }
export type Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null }
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null } export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
export type RichEvent = { raw: string; parsed: Meta | null } export type RichEvent = { raw: string; parsed: Meta | null }
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; trusted_only: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean; transparent: boolean } export type Settings = { resize_service: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean }
export type TAURI_CHANNEL<TSend> = null export type TAURI_CHANNEL<TSend> = null
/** tauri-specta globals **/ /** tauri-specta globals **/

View File

@@ -3,7 +3,6 @@ import type {
MaybePromise, MaybePromise,
PersistedQuery, PersistedQuery,
} from "@tanstack/query-persist-client-core"; } from "@tanstack/query-persist-client-core";
import { Store } from "@tanstack/react-store";
import { ask, message, open } from "@tauri-apps/plugin-dialog"; import { ask, message, open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs"; import { readFile } from "@tauri-apps/plugin-fs";
import { relaunch } from "@tauri-apps/plugin-process"; import { relaunch } from "@tauri-apps/plugin-process";
@@ -16,76 +15,79 @@ import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale"; import updateLocale from "dayjs/plugin/updateLocale";
import { decode } from "light-bolt11-decoder"; import { decode } from "light-bolt11-decoder";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import type { RichEvent, Settings } from "./commands.gen"; import type { RichEvent } from "./commands.gen";
import { LumeEvent } from "./system"; import { LumeEvent } from "./system";
import type { LumeColumn, NostrEvent } from "./types"; import type { NostrEvent } from "./types";
dayjs.extend(relativeTime);
dayjs.extend(updateLocale);
dayjs.updateLocale("en", {
relativeTime: {
past: "%s ago",
s: "just now",
m: "1m",
mm: "%dm",
h: "1h",
hh: "%dh",
d: "1d",
dd: "%dd",
},
});
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export const isImagePath = (path: string) => { export const isImagePath = (path: string) => {
for (const suffix of ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"]) { const exts = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
if (path.endsWith(suffix)) return true;
for (const suffix of exts) {
if (path.endsWith(suffix)) {
return true;
}
} }
return false; return false;
}; };
export const isImageUrl = (url: string) => { export function createdAt(time: number) {
try { // Config for dayjs
if (!url) return false; dayjs.extend(relativeTime);
const ext = new URL(url).pathname.split(".").pop(); dayjs.extend(updateLocale);
return ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"].includes(ext);
} catch {
return false;
}
};
export function formatCreatedAt(time: number, message = false) { // Config locale text
let formated: string; dayjs.updateLocale("en", {
relativeTime: {
past: "%s ago",
s: "just now",
m: "1m",
mm: "%dm",
h: "1h",
hh: "%dh",
d: "1d",
dd: "%dd",
},
});
const now = dayjs(); const now = dayjs();
const inputTime = dayjs.unix(time); const inputTime = dayjs.unix(time);
const diff = now.diff(inputTime, "hour"); const diff = now.diff(inputTime, "hour");
if (message) { if (diff < 24) {
if (diff < 12) { return inputTime.from(now, true);
formated = inputTime.format("HH:mm A");
} else {
formated = inputTime.format("MMM DD");
}
} else { } else {
if (diff < 24) { return inputTime.format("MMM DD");
formated = inputTime.from(now, true);
} else {
formated = inputTime.format("MMM DD");
}
} }
return formated;
} }
export function replyTime(time: number) { export function replyAt(time: number) {
const inputTime = dayjs.unix(time); // Config for dayjs
const formated = inputTime.format("MM-DD-YY HH:mm"); dayjs.extend(relativeTime);
dayjs.extend(updateLocale);
return formated; // Config locale text
dayjs.updateLocale("en", {
relativeTime: {
past: "%s ago",
s: "just now",
m: "1m",
mm: "%dm",
h: "1h",
hh: "%dh",
d: "1d",
dd: "%dd",
},
});
const inputTime = dayjs.unix(time);
const format = inputTime.format("MM-DD-YY HH:mm");
return format;
} }
export function displayNpub(pubkey: string, len: number) { export function displayNpub(pubkey: string, len: number) {
@@ -114,7 +116,7 @@ export function displayLongHandle(str: string) {
return `${handle.substring(0, 16)}...@${service}`; return `${handle.substring(0, 16)}...@${service}`;
} }
// source: https://github.com/synonymdev/bitkit/blob/master/src/utils/displayValues/index.ts // Source: https://github.com/synonymdev/bitkit/blob/master/src/utils/displayValues/index.ts
export function getBitcoinDisplayValues(satoshis: number) { export function getBitcoinDisplayValues(satoshis: number) {
let bitcoinFormatted = new BitcoinUnit(satoshis, "satoshis") let bitcoinFormatted = new BitcoinUnit(satoshis, "satoshis")
.getValue() .getValue()
@@ -145,20 +147,27 @@ export function getBitcoinDisplayValues(satoshis: number) {
}; };
} }
export function decodeZapInvoice(tags?: string[][]) { export function decodeZapInvoice(tags: string[][]) {
const invoice = tags.find((tag) => tag[0] === "bolt11")?.[1]; const invoice = tags.find((tag) => tag[0] === "bolt11")?.[1];
if (!invoice) return; if (!invoice) return;
const decodedInvoice = decode(invoice); const decodedInvoice = decode(invoice);
const amountSection = decodedInvoice.sections.find( const section = decodedInvoice.sections.find(
(s: { name: string }) => s.name === "amount", (s: { name: string }) => s.name === "amount",
); );
// @ts-ignore, its fine. if (!section) {
const amount = Number.parseInt(amountSection.value) / 1000; return null;
const displayValue = getBitcoinDisplayValues(amount); }
return displayValue; if (section.name === "amount") {
const amount = Number.parseInt(section.value) / 1000;
const displayValue = getBitcoinDisplayValues(amount);
return displayValue;
} else {
return null;
}
} }
export async function checkForAppUpdates(silent: boolean) { export async function checkForAppUpdates(silent: boolean) {
@@ -277,18 +286,3 @@ export function newQueryStorage(
(await store.delete(key)) as unknown as MaybePromise<void>, (await store.delete(key)) as unknown as MaybePromise<void>,
}; };
} }
export const appSettings = new Store<Settings>({
proxy: null,
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,
display_media: true,
transparent: true,
});
export const appColumns = new Store<LumeColumn[]>([]);

View File

@@ -1,10 +1,15 @@
import { commands } from "@/commands.gen"; import { commands } from "@/commands.gen";
import { appSettings, cn, displayNpub } from "@/commons"; import { cn, displayNpub } from "@/commons";
import { RepostIcon, Spinner } from "@/components"; import { RepostIcon, Spinner } from "@/components";
import { settingsQueryOptions } from "@/routes/__root";
import type { Metadata } from "@/types"; import type { Metadata } from "@/types";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import {
import { useStore } from "@tanstack/react-store"; useMutation,
useQuery,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useTransition } from "react"; import { useCallback, useTransition } from "react";
@@ -15,7 +20,7 @@ export function NoteRepost({
smol = false, smol = false,
}: { label?: boolean; smol?: boolean }) { }: { label?: boolean; smol?: boolean }) {
const event = useNoteContext(); const event = useNoteContext();
const visible = useStore(appSettings, (state) => state.display_repost_button); const settings = useSuspenseQuery(settingsQueryOptions);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isLoading, data: status } = useQuery({ const { isLoading, data: status } = useQuery({
@@ -28,7 +33,7 @@ export function NoteRepost({
return false; return false;
} }
}, },
enabled: visible, enabled: settings.data.display_repost_button,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
@@ -120,7 +125,7 @@ export function NoteRepost({
}); });
}; };
if (!visible) return null; if (!settings.data.display_repost_button) return null;
return ( return (
<Tooltip.Provider> <Tooltip.Provider>

View File

@@ -1,8 +1,9 @@
import { appSettings, cn } from "@/commons"; import { cn } from "@/commons";
import { ZapIcon } from "@/components"; import { ZapIcon } from "@/components";
import { settingsQueryOptions } from "@/routes/__root";
import { LumeWindow } from "@/system"; import { LumeWindow } from "@/system";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router"; import { useSearch } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
export function NoteZap({ export function NoteZap({
@@ -10,10 +11,10 @@ export function NoteZap({
smol = false, smol = false,
}: { label?: boolean; smol?: boolean }) { }: { label?: boolean; smol?: boolean }) {
const search = useSearch({ strict: false }); const search = useSearch({ strict: false });
const visible = useStore(appSettings, (state) => state.display_zap_button); const settings = useSuspenseQuery(settingsQueryOptions);
const event = useNoteContext(); const event = useNoteContext();
if (!visible) return null; if (!settings.data.display_zap_button) return null;
return ( return (
<button <button

View File

@@ -1,5 +1,6 @@
import { appSettings, cn } from "@/commons"; import { cn } from "@/commons";
import { useStore } from "@tanstack/react-store"; import { settingsQueryOptions } from "@/routes/__root";
import { useSuspenseQuery } from "@tanstack/react-query";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { type ReactNode, useMemo, useState } from "react"; import { type ReactNode, useMemo, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
@@ -21,14 +22,17 @@ export function NoteContent({
className?: string; className?: string;
}) { }) {
const event = useNoteContext(); const event = useNoteContext();
const visible = useStore(appSettings, (state) => state.display_media); const settings = useSuspenseQuery(settingsQueryOptions);
const content = useMemo(() => { const content = useMemo(() => {
try { try {
// Get parsed meta // Get parsed meta
const { content, hashtags, events, mentions } = event.meta; const { content, hashtags, events, mentions } = event.meta;
// Define rich content // Define rich content
let richContent: ReactNode[] | string = visible ? content : event.content; let richContent: ReactNode[] | string = settings.data.display_media
? content
: event.content;
for (const hashtag of hashtags) { for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g"); const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
@@ -103,7 +107,7 @@ export function NoteContent({
> >
{content} {content}
</div> </div>
{visible ? ( {settings.data.display_media ? (
event.meta?.images.length ? ( event.meta?.images.length ? (
<Images urls={event.meta.images} /> <Images urls={event.meta.images} />
) : null ) : null

View File

@@ -1,6 +1,5 @@
import { replyTime } from "@/commons"; import { replyAt } from "@/commons";
import { Note, Spinner } from "@/components"; import { Note, Spinner, User } from "@/components";
import { User } from "@/components/user";
import { LumeWindow, useEvent } from "@/system"; import { LumeWindow, useEvent } from "@/system";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { type ReactNode, memo, useMemo } from "react"; import { type ReactNode, memo, useMemo } from "react";
@@ -52,7 +51,7 @@ export const MentionNote = memo(function MentionNote({
</div> </div>
<div className="flex-1 flex items-center justify-between"> <div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500"> <span className="text-sm text-neutral-500">
{replyTime(event.created_at)} {replyAt(event.created_at)}
</span> </span>
<div className="invisible group-hover:visible flex items-center justify-end"> <div className="invisible group-hover:visible flex items-center justify-end">
<button <button

View File

@@ -1,23 +1,20 @@
import { appSettings } from "@/commons"; import { settingsQueryOptions } from "@/routes/__root";
import { useStore } from "@tanstack/react-store"; import { useSuspenseQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
export function ImagePreview({ url }: { url: string }) { export function ImagePreview({ url }: { url: string }) {
const [service, visible] = useStore(appSettings, (state) => [ const settings = useSuspenseQuery(settingsQueryOptions);
state.image_resize_service,
state.display_media,
]);
const imageUrl = useMemo(() => { const imageUrl = useMemo(() => {
if (service?.length) { if (settings.data.resize_service) {
const newUrl = `${service}?url=${url}&ll&af&default=1&n=-1`; const newUrl = `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
return newUrl; return newUrl;
} else { } else {
return url; return url;
} }
}, [service]); }, [settings.data.resize_service]);
if (!visible) { if (!settings.data.display_media) {
return ( return (
<a <a
href={url} href={url}

View File

@@ -1,23 +1,24 @@
import { appSettings, cn } from "@/commons"; import { cn } from "@/commons";
import { Spinner } from "@/components"; import { Spinner } from "@/components";
import { settingsQueryOptions } from "@/routes/__root";
import { ArrowLeft, ArrowRight } from "@phosphor-icons/react"; import { ArrowLeft, ArrowRight } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-store"; import { useSuspenseQuery } from "@tanstack/react-query";
import { open } from "@tauri-apps/plugin-shell"; import { open } from "@tauri-apps/plugin-shell";
import useEmblaCarousel from "embla-carousel-react"; import useEmblaCarousel from "embla-carousel-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
export function Images({ urls }: { urls: string[] }) { export function Images({ urls }: { urls: string[] }) {
const [slidesInView, setSlidesInView] = useState([]); const [slidesInView, setSlidesInView] = useState<number[]>([]);
const [emblaRef, emblaApi] = useEmblaCarousel({ const [emblaRef, emblaApi] = useEmblaCarousel({
dragFree: true, dragFree: true,
align: "start", align: "start",
watchSlides: false, watchSlides: false,
}); });
const service = useStore(appSettings, (state) => state.image_resize_service); const settings = useSuspenseQuery(settingsQueryOptions);
const imageUrls = useMemo(() => { const imageUrls = useMemo(() => {
if (service?.length) { if (settings.data.resize_service) {
let newUrls: string[]; let newUrls: string[];
if (urls.length === 1) { if (urls.length === 1) {
@@ -28,11 +29,12 @@ export function Images({ urls }: { urls: string[] }) {
if (url.includes("bsky.network")) { if (url.includes("bsky.network")) {
return url; return url;
} }
return `${service}?url=${url}&ll&af&default=1&n=-1`; return `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
}); });
} else { } else {
newUrls = urls.map( newUrls = urls.map(
(url) => `${service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`, (url) =>
`https://wsrv.nl?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
); );
} }
@@ -40,7 +42,7 @@ export function Images({ urls }: { urls: string[] }) {
} else { } else {
return urls; return urls;
} }
}, [service]); }, [settings.data.resize_service]);
const scrollPrev = useCallback(() => { const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev(); if (emblaApi) emblaApi.scrollPrev();
@@ -50,23 +52,27 @@ export function Images({ urls }: { urls: string[] }) {
if (emblaApi) emblaApi.scrollNext(); if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]); }, [emblaApi]);
const updateSlidesInView = useCallback((emblaApi) => { const updateSlidesInView = useCallback(() => {
setSlidesInView((slidesInView) => { setSlidesInView((slidesInView) => {
if (slidesInView.length === emblaApi.slideNodes().length) { if (slidesInView.length === emblaApi?.slideNodes().length) {
emblaApi.off("slidesInView", updateSlidesInView); emblaApi?.off("slidesInView", updateSlidesInView);
} }
const inView = emblaApi const inView = emblaApi
.slidesInView() ?.slidesInView()
.filter((index) => !slidesInView.includes(index)); .filter((index) => !slidesInView.includes(index));
return slidesInView.concat(inView); if (inView) {
return slidesInView.concat(inView);
} else {
return slidesInView;
}
}); });
}, []); }, [emblaApi]);
useEffect(() => { useEffect(() => {
if (emblaApi && urls.length > 1) { if (emblaApi && urls.length > 1) {
updateSlidesInView(emblaApi); updateSlidesInView();
emblaApi.on("slidesInView", updateSlidesInView); emblaApi.on("slidesInView", updateSlidesInView);
emblaApi.on("reInit", updateSlidesInView); emblaApi.on("reInit", updateSlidesInView);

View File

@@ -1,10 +1,10 @@
import { appSettings } from "@/commons"; import { settingsQueryOptions } from "@/routes/__root";
import { useStore } from "@tanstack/react-store"; import { useSuspenseQuery } from "@tanstack/react-query";
export function VideoPreview({ url }: { url: string }) { export function VideoPreview({ url }: { url: string }) {
const visible = useStore(appSettings, (state) => state.display_media); const settings = useSuspenseQuery(settingsQueryOptions);
if (!visible) { if (!settings.data.display_zap_button) {
return ( return (
<a <a
href={url} href={url}

View File

@@ -1,5 +1,5 @@
import { cn, replyTime } from "@/commons"; import { cn, replyAt } from "@/commons";
import { Note } from "@/components/note"; import { Note, User } from "@/components";
import { type LumeEvent, LumeWindow } from "@/system"; import { type LumeEvent, LumeWindow } from "@/system";
import { CaretDown } from "@phosphor-icons/react"; import { CaretDown } from "@phosphor-icons/react";
import { Link, useSearch } from "@tanstack/react-router"; import { Link, useSearch } from "@tanstack/react-router";
@@ -10,7 +10,6 @@ import { type ReactNode, memo, useCallback, useMemo } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { Hashtag } from "./note/mentions/hashtag"; import { Hashtag } from "./note/mentions/hashtag";
import { MentionUser } from "./note/mentions/user"; import { MentionUser } from "./note/mentions/user";
import { User } from "./user";
export const ReplyNote = memo(function ReplyNote({ export const ReplyNote = memo(function ReplyNote({
event, event,
@@ -66,7 +65,7 @@ export const ReplyNote = memo(function ReplyNote({
</div> </div>
<div className="flex-1 flex items-center justify-between"> <div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500"> <span className="text-sm text-neutral-500">
{replyTime(event.created_at)} {replyAt(event.created_at)}
</span> </span>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<Note.Reply smol /> <Note.Reply smol />
@@ -147,7 +146,7 @@ function ChildReply({ event }: { event: LumeEvent }) {
</div> </div>
<div className="flex-1 flex items-center justify-between"> <div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500"> <span className="text-sm text-neutral-500">
{replyTime(event.created_at)} {replyAt(event.created_at)}
</span> </span>
<div className="invisible group-hover:visible flex items-center justify-end"> <div className="invisible group-hover:visible flex items-center justify-end">
<Note.Reply smol /> <Note.Reply smol />

View File

@@ -1,38 +1,36 @@
import { appSettings, cn } from "@/commons"; import { cn } from "@/commons";
import { settingsQueryOptions } from "@/routes/__root";
import * as Avatar from "@radix-ui/react-avatar"; import * as Avatar from "@radix-ui/react-avatar";
import { useStore } from "@tanstack/react-store"; import { useSuspenseQuery } from "@tanstack/react-query";
import { minidenticon } from "minidenticons"; import { minidenticon } from "minidenticons";
import { useMemo } from "react"; import { useMemo } from "react";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) { export function UserAvatar({ className }: { className?: string }) {
const [service, visible] = useStore(appSettings, (state) => [ const settings = useSuspenseQuery(settingsQueryOptions);
state.image_resize_service,
state.display_avatar,
]);
const user = useUserContext(); const user = useUserContext();
const picture = useMemo(() => { const picture = useMemo(() => {
if (service?.length && user.profile?.picture?.length) { if (settings.data.resize_service && user?.profile?.picture?.length) {
if (user.profile?.picture.includes("_next/")) { if (user.profile?.picture.includes("_next/")) {
return user.profile?.picture; return user.profile?.picture;
} }
if (user.profile?.picture.includes("bsky.network")) { if (user.profile?.picture.includes("bsky.network")) {
return user.profile?.picture; return user.profile?.picture;
} }
return `${service}?url=${user.profile?.picture}&w=100&h=100&n=-1&default=${user.profile?.picture}`; return `https://wsrv.nl?url=${user.profile?.picture}&w=100&h=100&n=-1&default=${user.profile?.picture}`;
} else { } else {
return user.profile?.picture; return user?.profile?.picture;
} }
}, [user.profile?.picture]); }, [user]);
const fallback = useMemo( const fallback = useMemo(
() => () =>
`data:image/svg+xml;utf8,${encodeURIComponent( `data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey, 60, 50), minidenticon(user ? user.pubkey : "lume", 60, 50),
)}`, )}`,
[user.pubkey], [user],
); );
return ( return (
@@ -42,18 +40,19 @@ export function UserAvatar({ className }: { className?: string }) {
className, className,
)} )}
> >
{visible ? ( {settings.data.display_avatar ? (
<Avatar.Image <Avatar.Image
src={picture} src={picture}
alt={user.pubkey} alt={user?.pubkey}
decoding="async" decoding="async"
onContextMenu={(e) => e.preventDefault()}
className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]" className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/> />
) : null} ) : null}
<Avatar.Fallback> <Avatar.Fallback>
<img <img
src={fallback} src={fallback}
alt={user.pubkey} alt={user?.pubkey}
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]" className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/> />
</Avatar.Fallback> </Avatar.Fallback>

View File

@@ -1,4 +1,4 @@
import { cn, formatCreatedAt } from "@/commons"; import { cn, createdAt } from "@/commons";
import { useMemo } from "react"; import { useMemo } from "react";
export function UserTime({ export function UserTime({
@@ -8,11 +8,11 @@ export function UserTime({
time: number; time: number;
className?: string; className?: string;
}) { }) {
const createdAt = useMemo(() => formatCreatedAt(time), [time]); const displayCreatedAt = useMemo(() => createdAt(time), [time]);
return ( return (
<div className={cn("text-neutral-600 dark:text-neutral-400", className)}> <div className={cn("text-neutral-600 dark:text-neutral-400", className)}>
{createdAt} {displayCreatedAt}
</div> </div>
); );
} }

View File

@@ -19,11 +19,11 @@ import { Route as NewPostIndexImport } from './routes/new-post/index'
import { Route as AppIndexImport } from './routes/_app/index' import { Route as AppIndexImport } from './routes/_app/index'
import { Route as ZapIdImport } from './routes/zap.$id' import { Route as ZapIdImport } from './routes/zap.$id'
import { Route as ColumnsLayoutImport } from './routes/columns/_layout' import { Route as ColumnsLayoutImport } from './routes/columns/_layout'
import { Route as IdSetProfileImport } from './routes/$id.set-profile'
import { Route as IdSetInterestImport } from './routes/$id.set-interest' import { Route as IdSetInterestImport } from './routes/$id.set-interest'
import { Route as IdSetGroupImport } from './routes/$id.set-group' import { Route as IdSetGroupImport } from './routes/$id.set-group'
import { Route as SettingsIdWalletImport } from './routes/settings.$id/wallet' import { Route as SettingsIdWalletImport } from './routes/settings.$id/wallet'
import { Route as SettingsIdRelayImport } from './routes/settings.$id/relay' import { Route as SettingsIdRelayImport } from './routes/settings.$id/relay'
import { Route as SettingsIdProfileImport } from './routes/settings.$id/profile'
import { Route as SettingsIdGeneralImport } from './routes/settings.$id/general' import { Route as SettingsIdGeneralImport } from './routes/settings.$id/general'
import { Route as ColumnsLayoutGlobalImport } from './routes/columns/_layout/global' import { Route as ColumnsLayoutGlobalImport } from './routes/columns/_layout/global'
import { Route as ColumnsLayoutCreateNewsfeedImport } from './routes/columns/_layout/create-newsfeed' import { Route as ColumnsLayoutCreateNewsfeedImport } from './routes/columns/_layout/create-newsfeed'
@@ -149,6 +149,14 @@ const ColumnsLayoutRoute = ColumnsLayoutImport.update({
getParentRoute: () => ColumnsRoute, getParentRoute: () => ColumnsRoute,
} as any) } as any)
const IdSetProfileRoute = IdSetProfileImport.update({
id: '/$id/set-profile',
path: '/$id/set-profile',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/$id.set-profile.lazy').then((d) => d.Route),
)
const IdSetInterestRoute = IdSetInterestImport.update({ const IdSetInterestRoute = IdSetInterestImport.update({
id: '/$id/set-interest', id: '/$id/set-interest',
path: '/$id/set-interest', path: '/$id/set-interest',
@@ -204,14 +212,6 @@ const SettingsIdRelayRoute = SettingsIdRelayImport.update({
import('./routes/settings.$id/relay.lazy').then((d) => d.Route), import('./routes/settings.$id/relay.lazy').then((d) => d.Route),
) )
const SettingsIdProfileRoute = SettingsIdProfileImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => SettingsIdLazyRoute,
} as any).lazy(() =>
import('./routes/settings.$id/profile.lazy').then((d) => d.Route),
)
const SettingsIdGeneralRoute = SettingsIdGeneralImport.update({ const SettingsIdGeneralRoute = SettingsIdGeneralImport.update({
id: '/general', id: '/general',
path: '/general', path: '/general',
@@ -364,6 +364,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IdSetInterestImport preLoaderRoute: typeof IdSetInterestImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/$id/set-profile': {
id: '/$id/set-profile'
path: '/$id/set-profile'
fullPath: '/$id/set-profile'
preLoaderRoute: typeof IdSetProfileImport
parentRoute: typeof rootRoute
}
'/columns': { '/columns': {
id: '/columns' id: '/columns'
path: '/columns' path: '/columns'
@@ -448,13 +455,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsIdGeneralImport preLoaderRoute: typeof SettingsIdGeneralImport
parentRoute: typeof SettingsIdLazyImport parentRoute: typeof SettingsIdLazyImport
} }
'/settings/$id/profile': {
id: '/settings/$id/profile'
path: '/profile'
fullPath: '/settings/$id/profile'
preLoaderRoute: typeof SettingsIdProfileImport
parentRoute: typeof SettingsIdLazyImport
}
'/settings/$id/relay': { '/settings/$id/relay': {
id: '/settings/$id/relay' id: '/settings/$id/relay'
path: '/relay' path: '/relay'
@@ -651,14 +651,12 @@ const ColumnsRouteWithChildren =
interface SettingsIdLazyRouteChildren { interface SettingsIdLazyRouteChildren {
SettingsIdGeneralRoute: typeof SettingsIdGeneralRoute SettingsIdGeneralRoute: typeof SettingsIdGeneralRoute
SettingsIdProfileRoute: typeof SettingsIdProfileRoute
SettingsIdRelayRoute: typeof SettingsIdRelayRoute SettingsIdRelayRoute: typeof SettingsIdRelayRoute
SettingsIdWalletRoute: typeof SettingsIdWalletRoute SettingsIdWalletRoute: typeof SettingsIdWalletRoute
} }
const SettingsIdLazyRouteChildren: SettingsIdLazyRouteChildren = { const SettingsIdLazyRouteChildren: SettingsIdLazyRouteChildren = {
SettingsIdGeneralRoute: SettingsIdGeneralRoute, SettingsIdGeneralRoute: SettingsIdGeneralRoute,
SettingsIdProfileRoute: SettingsIdProfileRoute,
SettingsIdRelayRoute: SettingsIdRelayRoute, SettingsIdRelayRoute: SettingsIdRelayRoute,
SettingsIdWalletRoute: SettingsIdWalletRoute, SettingsIdWalletRoute: SettingsIdWalletRoute,
} }
@@ -673,6 +671,7 @@ export interface FileRoutesByFullPath {
'/new': typeof NewLazyRoute '/new': typeof NewLazyRoute
'/$id/set-group': typeof IdSetGroupRoute '/$id/set-group': typeof IdSetGroupRoute
'/$id/set-interest': typeof IdSetInterestRoute '/$id/set-interest': typeof IdSetInterestRoute
'/$id/set-profile': typeof IdSetProfileRoute
'/columns': typeof ColumnsLayoutRouteWithChildren '/columns': typeof ColumnsLayoutRouteWithChildren
'/zap/$id': typeof ZapIdRoute '/zap/$id': typeof ZapIdRoute
'/new-account/connect': typeof NewAccountConnectLazyRoute '/new-account/connect': typeof NewAccountConnectLazyRoute
@@ -684,7 +683,6 @@ export interface FileRoutesByFullPath {
'/columns/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren '/columns/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
'/columns/global': typeof ColumnsLayoutGlobalRoute '/columns/global': typeof ColumnsLayoutGlobalRoute
'/settings/$id/general': typeof SettingsIdGeneralRoute '/settings/$id/general': typeof SettingsIdGeneralRoute
'/settings/$id/profile': typeof SettingsIdProfileRoute
'/settings/$id/relay': typeof SettingsIdRelayRoute '/settings/$id/relay': typeof SettingsIdRelayRoute
'/settings/$id/wallet': typeof SettingsIdWalletRoute '/settings/$id/wallet': typeof SettingsIdWalletRoute
'/columns/onboarding': typeof ColumnsLayoutOnboardingLazyRoute '/columns/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
@@ -708,6 +706,7 @@ export interface FileRoutesByTo {
'/new': typeof NewLazyRoute '/new': typeof NewLazyRoute
'/$id/set-group': typeof IdSetGroupRoute '/$id/set-group': typeof IdSetGroupRoute
'/$id/set-interest': typeof IdSetInterestRoute '/$id/set-interest': typeof IdSetInterestRoute
'/$id/set-profile': typeof IdSetProfileRoute
'/columns': typeof ColumnsLayoutRouteWithChildren '/columns': typeof ColumnsLayoutRouteWithChildren
'/zap/$id': typeof ZapIdRoute '/zap/$id': typeof ZapIdRoute
'/new-account/connect': typeof NewAccountConnectLazyRoute '/new-account/connect': typeof NewAccountConnectLazyRoute
@@ -719,7 +718,6 @@ export interface FileRoutesByTo {
'/columns/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren '/columns/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
'/columns/global': typeof ColumnsLayoutGlobalRoute '/columns/global': typeof ColumnsLayoutGlobalRoute
'/settings/$id/general': typeof SettingsIdGeneralRoute '/settings/$id/general': typeof SettingsIdGeneralRoute
'/settings/$id/profile': typeof SettingsIdProfileRoute
'/settings/$id/relay': typeof SettingsIdRelayRoute '/settings/$id/relay': typeof SettingsIdRelayRoute
'/settings/$id/wallet': typeof SettingsIdWalletRoute '/settings/$id/wallet': typeof SettingsIdWalletRoute
'/columns/onboarding': typeof ColumnsLayoutOnboardingLazyRoute '/columns/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
@@ -745,6 +743,7 @@ export interface FileRoutesById {
'/new': typeof NewLazyRoute '/new': typeof NewLazyRoute
'/$id/set-group': typeof IdSetGroupRoute '/$id/set-group': typeof IdSetGroupRoute
'/$id/set-interest': typeof IdSetInterestRoute '/$id/set-interest': typeof IdSetInterestRoute
'/$id/set-profile': typeof IdSetProfileRoute
'/columns': typeof ColumnsRouteWithChildren '/columns': typeof ColumnsRouteWithChildren
'/columns/_layout': typeof ColumnsLayoutRouteWithChildren '/columns/_layout': typeof ColumnsLayoutRouteWithChildren
'/zap/$id': typeof ZapIdRoute '/zap/$id': typeof ZapIdRoute
@@ -757,7 +756,6 @@ export interface FileRoutesById {
'/columns/_layout/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren '/columns/_layout/create-newsfeed': typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
'/columns/_layout/global': typeof ColumnsLayoutGlobalRoute '/columns/_layout/global': typeof ColumnsLayoutGlobalRoute
'/settings/$id/general': typeof SettingsIdGeneralRoute '/settings/$id/general': typeof SettingsIdGeneralRoute
'/settings/$id/profile': typeof SettingsIdProfileRoute
'/settings/$id/relay': typeof SettingsIdRelayRoute '/settings/$id/relay': typeof SettingsIdRelayRoute
'/settings/$id/wallet': typeof SettingsIdWalletRoute '/settings/$id/wallet': typeof SettingsIdWalletRoute
'/columns/_layout/onboarding': typeof ColumnsLayoutOnboardingLazyRoute '/columns/_layout/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
@@ -784,6 +782,7 @@ export interface FileRouteTypes {
| '/new' | '/new'
| '/$id/set-group' | '/$id/set-group'
| '/$id/set-interest' | '/$id/set-interest'
| '/$id/set-profile'
| '/columns' | '/columns'
| '/zap/$id' | '/zap/$id'
| '/new-account/connect' | '/new-account/connect'
@@ -795,7 +794,6 @@ export interface FileRouteTypes {
| '/columns/create-newsfeed' | '/columns/create-newsfeed'
| '/columns/global' | '/columns/global'
| '/settings/$id/general' | '/settings/$id/general'
| '/settings/$id/profile'
| '/settings/$id/relay' | '/settings/$id/relay'
| '/settings/$id/wallet' | '/settings/$id/wallet'
| '/columns/onboarding' | '/columns/onboarding'
@@ -818,6 +816,7 @@ export interface FileRouteTypes {
| '/new' | '/new'
| '/$id/set-group' | '/$id/set-group'
| '/$id/set-interest' | '/$id/set-interest'
| '/$id/set-profile'
| '/columns' | '/columns'
| '/zap/$id' | '/zap/$id'
| '/new-account/connect' | '/new-account/connect'
@@ -829,7 +828,6 @@ export interface FileRouteTypes {
| '/columns/create-newsfeed' | '/columns/create-newsfeed'
| '/columns/global' | '/columns/global'
| '/settings/$id/general' | '/settings/$id/general'
| '/settings/$id/profile'
| '/settings/$id/relay' | '/settings/$id/relay'
| '/settings/$id/wallet' | '/settings/$id/wallet'
| '/columns/onboarding' | '/columns/onboarding'
@@ -853,6 +851,7 @@ export interface FileRouteTypes {
| '/new' | '/new'
| '/$id/set-group' | '/$id/set-group'
| '/$id/set-interest' | '/$id/set-interest'
| '/$id/set-profile'
| '/columns' | '/columns'
| '/columns/_layout' | '/columns/_layout'
| '/zap/$id' | '/zap/$id'
@@ -865,7 +864,6 @@ export interface FileRouteTypes {
| '/columns/_layout/create-newsfeed' | '/columns/_layout/create-newsfeed'
| '/columns/_layout/global' | '/columns/_layout/global'
| '/settings/$id/general' | '/settings/$id/general'
| '/settings/$id/profile'
| '/settings/$id/relay' | '/settings/$id/relay'
| '/settings/$id/wallet' | '/settings/$id/wallet'
| '/columns/_layout/onboarding' | '/columns/_layout/onboarding'
@@ -891,6 +889,7 @@ export interface RootRouteChildren {
NewLazyRoute: typeof NewLazyRoute NewLazyRoute: typeof NewLazyRoute
IdSetGroupRoute: typeof IdSetGroupRoute IdSetGroupRoute: typeof IdSetGroupRoute
IdSetInterestRoute: typeof IdSetInterestRoute IdSetInterestRoute: typeof IdSetInterestRoute
IdSetProfileRoute: typeof IdSetProfileRoute
ColumnsRoute: typeof ColumnsRouteWithChildren ColumnsRoute: typeof ColumnsRouteWithChildren
ZapIdRoute: typeof ZapIdRoute ZapIdRoute: typeof ZapIdRoute
NewAccountConnectLazyRoute: typeof NewAccountConnectLazyRoute NewAccountConnectLazyRoute: typeof NewAccountConnectLazyRoute
@@ -906,6 +905,7 @@ const rootRouteChildren: RootRouteChildren = {
NewLazyRoute: NewLazyRoute, NewLazyRoute: NewLazyRoute,
IdSetGroupRoute: IdSetGroupRoute, IdSetGroupRoute: IdSetGroupRoute,
IdSetInterestRoute: IdSetInterestRoute, IdSetInterestRoute: IdSetInterestRoute,
IdSetProfileRoute: IdSetProfileRoute,
ColumnsRoute: ColumnsRouteWithChildren, ColumnsRoute: ColumnsRouteWithChildren,
ZapIdRoute: ZapIdRoute, ZapIdRoute: ZapIdRoute,
NewAccountConnectLazyRoute: NewAccountConnectLazyRoute, NewAccountConnectLazyRoute: NewAccountConnectLazyRoute,
@@ -932,6 +932,7 @@ export const routeTree = rootRoute
"/new", "/new",
"/$id/set-group", "/$id/set-group",
"/$id/set-interest", "/$id/set-interest",
"/$id/set-profile",
"/columns", "/columns",
"/zap/$id", "/zap/$id",
"/new-account/connect", "/new-account/connect",
@@ -959,6 +960,9 @@ export const routeTree = rootRoute
"/$id/set-interest": { "/$id/set-interest": {
"filePath": "$id.set-interest.tsx" "filePath": "$id.set-interest.tsx"
}, },
"/$id/set-profile": {
"filePath": "$id.set-profile.tsx"
},
"/columns": { "/columns": {
"filePath": "columns", "filePath": "columns",
"children": [ "children": [
@@ -1001,7 +1005,6 @@ export const routeTree = rootRoute
"filePath": "settings.$id.lazy.tsx", "filePath": "settings.$id.lazy.tsx",
"children": [ "children": [
"/settings/$id/general", "/settings/$id/general",
"/settings/$id/profile",
"/settings/$id/relay", "/settings/$id/relay",
"/settings/$id/wallet" "/settings/$id/wallet"
] ]
@@ -1029,10 +1032,6 @@ export const routeTree = rootRoute
"filePath": "settings.$id/general.tsx", "filePath": "settings.$id/general.tsx",
"parent": "/settings/$id" "parent": "/settings/$id"
}, },
"/settings/$id/profile": {
"filePath": "settings.$id/profile.tsx",
"parent": "/settings/$id"
},
"/settings/$id/relay": { "/settings/$id/relay": {
"filePath": "settings.$id/relay.tsx", "filePath": "settings.$id/relay.tsx",
"parent": "/settings/$id" "parent": "/settings/$id"

View File

@@ -0,0 +1,230 @@
import { commands } from "@/commands.gen";
import { cn, upload } from "@/commons";
import { Spinner } from "@/components";
import type { Metadata } from "@/types";
import { Plus } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import {
type Dispatch,
type ReactNode,
type SetStateAction,
useState,
useTransition,
} from "react";
import { useForm } from "react-hook-form";
export const Route = createLazyFileRoute("/$id/set-profile")({
component: Screen,
});
function Screen() {
const { id } = Route.useParams();
const { profile, queryClient } = Route.useRouteContext();
const { register, handleSubmit } = useForm({ defaultValues: profile });
const [picture, setPicture] = useState<string>("");
const [isPending, startTransition] = useTransition();
const onSubmit = (data: Metadata) => {
startTransition(async () => {
const signer = await commands.hasSigner(id);
if (signer.status === "ok") {
if (!signer.data) {
const res = await commands.setSigner(id);
if (res.status === "error") {
await message(res.error, { kind: "error" });
return;
}
}
} else {
await message(signer.error, { kind: "error" });
return;
}
const newProfile: Metadata = { ...profile, ...data, picture };
const res = await commands.setProfile(JSON.stringify(newProfile));
if (res.status === "ok") {
// Invalidate cache
await queryClient.invalidateQueries({
queryKey: ["profile", id],
});
// Close current popup
await getCurrentWindow().close();
} else {
await message(res.error, { title: "Profile", kind: "error" });
return;
}
});
};
return (
<div className="flex flex-col size-full">
<div data-tauri-drag-region className="shrink-0 h-11" />
<form
onSubmit={handleSubmit(onSubmit)}
className="min-h-0 flex-1 flex flex-col mb-0"
>
<div className="shrink-0 h-20 flex items-center gap-3 p-3 border-b border-black/5 dark:border-white/5">
<div className="relative rounded-full size-12 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{profile.picture ? (
<img
src={picture || profile.picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 object-cover size-12 rounded-full"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
className="absolute inset-0 z-20 flex items-center justify-center size-full text-white rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<Plus className="size-5" />
</AvatarUploader>
</div>
<div className="flex-1 flex justify-between items-center">
<div>
<div className="font-semibold">{profile.display_name}</div>
<div className="leading-tight text-sm text-neutral-700 dark:text-neutral-300">
{profile.nip05?.startsWith("_")
? profile.nip05.replace("_@", "")
: profile.nip05}
</div>
</div>
<button
type="submit"
disabled={isPending}
className="inline-flex items-center justify-center w-28 h-8 px-2 text-xs font-semibold text-white bg-blue-500 rounded-full hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? <Spinner className="size-4" /> : "Update Profile"}
</button>
</div>
</div>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="min-h-0 flex-1 overflow-hidden"
>
<ScrollArea.Viewport className="bg-white dark:bg-black h-full p-3">
<div className="flex flex-col gap-4">
<div className="flex flex-col w-full gap-1.5">
<label htmlFor="display_name" className="text-sm font-medium">
Display Name
</label>
<input
{...register("display_name")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1.5">
<label htmlFor="name" className="text-sm font-medium">
Name
</label>
<input
{...register("name")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1.5">
<label htmlFor="website" className="text-sm font-medium">
Website
</label>
<input
type="url"
{...register("website")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1.5">
<label htmlFor="banner" className="text-sm font-medium">
Cover
</label>
<input
type="url"
{...register("banner")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1.5">
<label htmlFor="nip05" className="text-sm font-medium">
NIP-05
</label>
<input
type="email"
{...register("nip05")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1.5">
<label htmlFor="lnaddress" className="text-sm font-medium">
Lightning Address
</label>
<input
type="email"
{...register("lud16")}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</form>
</div>
);
}
function AvatarUploader({
setPicture,
children,
className,
}: {
setPicture: Dispatch<SetStateAction<string>>;
children: ReactNode;
className?: string;
}) {
const [isPending, startTransition] = useTransition();
const uploadAvatar = () => {
startTransition(async () => {
try {
const image = await upload();
if (image) {
setPicture(image);
}
} catch (e) {
await message(String(e));
return;
}
});
};
return (
<button
type="button"
onClick={() => uploadAvatar()}
className={cn("size-4", className)}
>
{isPending ? <Spinner className="size-4" /> : children}
</button>
);
}

View File

@@ -1,7 +1,7 @@
import { type Profile, commands } from "@/commands.gen"; import { type Profile, commands } from "@/commands.gen";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/settings/$id/profile")({ export const Route = createFileRoute("/$id/set-profile")({
beforeLoad: async ({ params }) => { beforeLoad: async ({ params }) => {
const res = await commands.getProfile(params.id); const res = await commands.getProfile(params.id);

View File

@@ -1,20 +1,38 @@
import { commands } from "@/commands.gen";
import { Spinner } from "@/components"; import { Spinner } from "@/components";
import type { Metadata, NostrEvent } from "@/types"; import type { Metadata, NostrEvent } from "@/types";
import type { QueryClient } from "@tanstack/react-query"; import { type QueryClient, queryOptions } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import type { OsType } from "@tauri-apps/plugin-os"; import type { OsType } from "@tauri-apps/plugin-os";
import type { Store } from "@tauri-apps/plugin-store";
import { useEffect } from "react"; import { useEffect } from "react";
interface RouterContext { interface RouterContext {
store: Store;
queryClient: QueryClient; queryClient: QueryClient;
platform: OsType; platform: OsType;
accounts?: string[]; accounts?: string[];
} }
export const settingsQueryOptions = queryOptions({
queryKey: ["settings"],
queryFn: async () => {
const res = await commands.getAppSettings();
if (res.status === "ok") {
return res.data;
} else {
throw new Error(res.error);
}
},
});
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
component: Screen, component: Screen,
pendingComponent: Pending, pendingComponent: Pending,
loader: ({ context }) =>
context.queryClient.ensureQueryData(settingsQueryOptions),
}); });
function Screen() { function Screen() {

View File

@@ -1,5 +1,5 @@
import { commands } from "@/commands.gen"; import { commands } from "@/commands.gen";
import { appColumns, cn } from "@/commons"; import { cn } from "@/commons";
import { PublishIcon } from "@/components"; import { PublishIcon } from "@/components";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { LumeWindow } from "@/system"; import { LumeWindow } from "@/system";
@@ -14,6 +14,7 @@ import {
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
export const Route = createLazyFileRoute("/_app")({ export const Route = createLazyFileRoute("/_app")({
@@ -107,7 +108,7 @@ function Account({ pubkey }: { pubkey: string }) {
const items = await Promise.all([ const items = await Promise.all([
MenuItem.new({ MenuItem.new({
text: "Activate", text: "Unlock",
enabled: !isActive || true, enabled: !isActive || true,
action: async () => await commands.setSigner(pubkey), action: async () => await commands.setSigner(pubkey),
}), }),
@@ -116,10 +117,31 @@ function Account({ pubkey }: { pubkey: string }) {
text: "View Profile", text: "View Profile",
action: () => LumeWindow.openProfile(pubkey), action: () => LumeWindow.openProfile(pubkey),
}), }),
MenuItem.new({
text: "Update Profile",
action: () =>
LumeWindow.openPopup(
`${pubkey}/set-profile`,
"Update Profile",
true,
),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({ MenuItem.new({
text: "Copy Public Key", text: "Copy Public Key",
action: async () => await writeText(pubkey), action: async () => await writeText(pubkey),
}), }),
MenuItem.new({
text: "Copy Private Key",
action: async () => {
const res = await commands.getPrivateKey(pubkey);
if (res.status === "ok") {
await writeText(res.data);
} else {
await message(res.error, { kind: "error" });
}
},
}),
PredefinedMenuItem.new({ item: "Separator" }), PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({ MenuItem.new({
text: "Settings", text: "Settings",
@@ -127,20 +149,13 @@ function Account({ pubkey }: { pubkey: string }) {
}), }),
PredefinedMenuItem.new({ item: "Separator" }), PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({ MenuItem.new({
text: "Delete Account", text: "Logout",
action: async () => { action: async () => {
const res = await commands.deleteAccount(pubkey); const res = await commands.deleteAccount(pubkey);
if (res.status === "ok") { if (res.status === "ok") {
router.invalidate(); router.invalidate();
// Delete column associate with this account
appColumns.setState((prev) =>
prev.filter((col) =>
col.account ? col.account !== pubkey : col,
),
);
// Check remain account // Check remain account
const newAccounts = context.accounts.filter( const newAccounts = context.accounts.filter(
(account) => account !== pubkey, (account) => account !== pubkey,
@@ -176,6 +191,7 @@ function Account({ pubkey }: { pubkey: string }) {
<button <button
type="button" type="button"
onClick={(e) => showContextMenu(e)} onClick={(e) => showContextMenu(e)}
onContextMenu={(e) => showContextMenu(e)}
className="h-10 relative" className="h-10 relative"
> >
<User.Provider pubkey={pubkey}> <User.Provider pubkey={pubkey}>

View File

@@ -1,11 +1,10 @@
import { commands } from "@/commands.gen"; import { commands } from "@/commands.gen";
import { appColumns, displayNpub } from "@/commons"; import { displayNpub } from "@/commons";
import { Column, Spinner } from "@/components"; import { Column, Spinner } from "@/components";
import { LumeWindow } from "@/system"; import { LumeWindow } from "@/system";
import type { ColumnEvent, LumeColumn, Metadata } from "@/types"; import type { ColumnEvent, LumeColumn, Metadata } from "@/types";
import { ArrowLeft, ArrowRight, Plus } from "@phosphor-icons/react"; import { ArrowLeft, ArrowRight, Plus } from "@phosphor-icons/react";
import { createLazyFileRoute, useRouter } from "@tanstack/react-router"; import { createLazyFileRoute, useRouter } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
@@ -26,10 +25,11 @@ export const Route = createLazyFileRoute("/_app/")({
}); });
function Screen() { function Screen() {
const context = Route.useRouteContext();
const initialAppColumns = Route.useLoaderData(); const initialAppColumns = Route.useLoaderData();
const router = useRouter(); const router = useRouter();
const columns = useStore(appColumns, (state) => state);
const [columns, setColumns] = useState<LumeColumn[]>([]);
const [emblaRef, emblaApi] = useEmblaCarousel({ const [emblaRef, emblaApi] = useEmblaCarousel({
watchDrag: false, watchDrag: false,
loop: false, loop: false,
@@ -52,9 +52,9 @@ function Screen() {
const res = await commands.closeColumn(label); const res = await commands.closeColumn(label);
if (res.status === "ok") { if (res.status === "ok") {
appColumns.setState((prev) => prev.filter((t) => t.label !== label)); setColumns((prev) => prev.filter((t) => t.label !== label));
} else { } else {
await message(res.error, { kind: "errror" }); await message(res.error, { kind: "error" });
} }
}, },
[columns], [columns],
@@ -64,7 +64,7 @@ function Screen() {
const exist = columns.find((col) => col.label === column.label); const exist = columns.find((col) => col.label === column.label);
if (!exist) { if (!exist) {
appColumns.setState((prev) => [column, ...prev]); setColumns((prev) => [column, ...prev]);
if (emblaApi) { if (emblaApi) {
emblaApi.scrollTo(0, true); emblaApi.scrollTo(0, true);
@@ -85,7 +85,7 @@ function Screen() {
if (direction === "left") newCols.splice(colIndex - 1, 0, existColumn); if (direction === "left") newCols.splice(colIndex - 1, 0, existColumn);
if (direction === "right") newCols.splice(colIndex + 1, 0, existColumn); if (direction === "right") newCols.splice(colIndex + 1, 0, existColumn);
appColumns.setState(() => newCols); setColumns(() => newCols);
} }
}, },
150, 150,
@@ -100,11 +100,9 @@ function Screen() {
const newCols = columns.slice(); const newCols = columns.slice();
newCols[currentColIndex] = updatedCol; newCols[currentColIndex] = updatedCol;
appColumns.setState(() => newCols); setColumns(() => newCols);
}, 150); }, 150);
const reset = useDebouncedCallback(() => appColumns.setState(() => []), 150);
useEffect(() => { useEffect(() => {
if (emblaApi) { if (emblaApi) {
emblaApi.on("scroll", emitScrollEvent); emblaApi.on("scroll", emitScrollEvent);
@@ -119,7 +117,6 @@ function Screen() {
useEffect(() => { useEffect(() => {
const unlisten = listen<ColumnEvent>("columns", (data) => { const unlisten = listen<ColumnEvent>("columns", (data) => {
if (data.payload.type === "reset") reset();
if (data.payload.type === "add") add(data.payload.column); if (data.payload.type === "add") add(data.payload.column);
if (data.payload.type === "remove") remove(data.payload.label); if (data.payload.type === "remove") remove(data.payload.label);
if (data.payload.type === "move") if (data.payload.type === "move")
@@ -145,12 +142,14 @@ function Screen() {
useEffect(() => { useEffect(() => {
if (initialAppColumns) { if (initialAppColumns) {
appColumns.setState(() => initialAppColumns); setColumns(() => initialAppColumns);
} }
}, [initialAppColumns]); }, [initialAppColumns]);
useEffect(() => { useEffect(() => {
window.localStorage.setItem("columns", JSON.stringify(columns)); (async () => {
await context.store.set("columns", JSON.stringify(columns));
})();
}, [columns]); }, [columns]);
return ( return (

View File

@@ -5,11 +5,11 @@ import { nanoid } from "nanoid";
export const Route = createFileRoute("/_app/")({ export const Route = createFileRoute("/_app/")({
loader: async ({ context }) => { loader: async ({ context }) => {
const accounts = context.accounts; const accounts = context.accounts;
const prevColumns = window.localStorage.getItem("columns"); const prev = await context.store.get("columns");
let initialAppColumns: LumeColumn[] = []; let initialAppColumns: LumeColumn[] = [];
if (!prevColumns || prevColumns.length < 1) { if (!prev) {
initialAppColumns.push({ initialAppColumns.push({
label: "onboarding", label: "onboarding",
name: "Onboarding", name: "Onboarding",
@@ -25,7 +25,7 @@ export const Route = createFileRoute("/_app/")({
}); });
} }
} else { } else {
const parsed: LumeColumn[] = JSON.parse(prevColumns); const parsed: LumeColumn[] = JSON.parse(prev as string);
initialAppColumns = parsed.filter((item) => initialAppColumns = parsed.filter((item) =>
item.account ? context.accounts.includes(item.account) : item, item.account ? context.accounts.includes(item.account) : item,

View File

@@ -1,5 +1,5 @@
import { commands } from "@/commands.gen"; import { commands } from "@/commands.gen";
import { decodeZapInvoice, formatCreatedAt } from "@/commons"; import { createdAt, decodeZapInvoice } from "@/commons";
import { Note, RepostIcon, Spinner, User } from "@/components"; import { Note, RepostIcon, Spinner, User } from "@/components";
import { LumeEvent, LumeWindow, useEvent } from "@/system"; import { LumeEvent, LumeWindow, useEvent } from "@/system";
import { Kind, type NostrEvent } from "@/types"; import { Kind, type NostrEvent } from "@/types";
@@ -300,7 +300,7 @@ function TextNote({ event }: { event: LumeEvent }) {
<div className="flex items-baseline justify-between w-full"> <div className="flex items-baseline justify-between w-full">
<User.Name className="text-sm font-semibold leading-tight" /> <User.Name className="text-sm font-semibold leading-tight" />
<span className="text-sm leading-tight text-black/50 dark:text-white/50"> <span className="text-sm leading-tight text-black/50 dark:text-white/50">
{formatCreatedAt(event.created_at)} {createdAt(event.created_at)}
</span> </span>
</div> </div>
<div className="inline-flex items-baseline gap-1 text-xs"> <div className="inline-flex items-baseline gap-1 text-xs">

View File

@@ -1,5 +1,5 @@
import { commands } from "@/commands.gen"; import { commands } from "@/commands.gen";
import { replyTime, toLumeEvents } from "@/commons"; import { replyAt, toLumeEvents } from "@/commons";
import { Note, Spinner, User } from "@/components"; import { Note, Spinner, User } from "@/components";
import { Hashtag } from "@/components/note/mentions/hashtag"; import { Hashtag } from "@/components/note/mentions/hashtag";
import { MentionUser } from "@/components/note/mentions/user"; import { MentionUser } from "@/components/note/mentions/user";
@@ -155,7 +155,7 @@ const StoryEvent = memo(function StoryEvent({ event }: { event: LumeEvent }) {
</div> </div>
<div className="flex-1 flex items-center justify-between"> <div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500"> <span className="text-sm text-neutral-500">
{replyTime(event.created_at)} {replyAt(event.created_at)}
</span> </span>
<div className="invisible group-hover:visible flex items-center justify-end gap-3"> <div className="invisible group-hover:visible flex items-center justify-end gap-3">
<Note.Reply /> <Note.Reply />

View File

@@ -1,12 +1,19 @@
import { commands } from "@/commands.gen"; import { type Settings, commands } from "@/commands.gen";
import { appSettings } from "@/commons";
import { Spinner } from "@/components"; import { Spinner } from "@/components";
import * as Switch from "@radix-ui/react-switch"; import * as Switch from "@radix-ui/react-switch";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { message } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useState, useTransition } from "react"; import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useState,
useTransition,
} from "react";
import { settingsQueryOptions } from "../__root";
type Theme = "auto" | "light" | "dark"; type Theme = "auto" | "light" | "dark";
@@ -15,7 +22,11 @@ export const Route = createLazyFileRoute("/settings/$id/general")({
}); });
function Screen() { function Screen() {
const [theme, setTheme] = useState<Theme>(null); const settings = useSuspenseQuery(settingsQueryOptions);
const { queryClient } = Route.useRouteContext();
const [theme, setTheme] = useState<Theme>("auto");
const [newSettings, setNewSettings] = useState<Settings>();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const changeTheme = useCallback(async (theme: string) => { const changeTheme = useCallback(async (theme: string) => {
@@ -28,14 +39,14 @@ function Screen() {
const updateSettings = () => { const updateSettings = () => {
startTransition(async () => { startTransition(async () => {
const newSettings = JSON.stringify(appSettings.state); const res = await commands.setAppSettings(JSON.stringify(newSettings));
const res = await commands.setUserSettings(newSettings);
if (res.status === "error") { if (res.status === "ok") {
await queryClient.invalidateQueries({ queryKey: ["settings"] });
return;
} else {
await message(res.error, { kind: "error" }); await message(res.error, { kind: "error" });
} }
return;
}); });
}; };
@@ -43,6 +54,16 @@ function Screen() {
invoke("plugin:theme|get_theme").then((data) => setTheme(data as Theme)); invoke("plugin:theme|get_theme").then((data) => setTheme(data as Theme));
}, []); }, []);
useEffect(() => {
if (settings.status === "success") {
setNewSettings(settings.data);
}
}, [settings]);
if (!newSettings) {
return null;
}
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<div className="flex flex-col gap-6 px-3 pb-3"> <div className="flex flex-col gap-6 px-3 pb-3">
@@ -51,21 +72,22 @@ function Screen() {
General General
</h2> </h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl"> <div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<Setting
name="Relay Hint"
description="Use the relay hint if necessary."
label="use_relay_hint"
/>
<Setting <Setting
name="Content Warning" name="Content Warning"
description="Shows a warning for notes that have a content warning." description="Shows a warning for notes that have a content warning."
label="content_warning" label="content_warning"
checked={newSettings.content_warning}
setNewSettings={setNewSettings}
/> />
{/*
<Setting <Setting
name="Trusted Only" name="Trusted Only"
description="Only shows note's replies from your inner circle." description="Only shows note's replies from your inner circle."
label="trusted_only" label="trusted_only"
newSettings={newSettings}
setNewSettings={setNewSettings}
/> />
*/}
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -94,19 +116,25 @@ function Screen() {
</div> </div>
</div> </div>
<Setting <Setting
name="Transparent Effect" name="Show Avatar"
description="Use native window transparent effect." description="Shows the user avatar."
label="transparent" label="display_avatar"
checked={newSettings.display_avatar}
setNewSettings={setNewSettings}
/> />
<Setting <Setting
name="Show Zap Button" name="Show Zap Button"
description="Shows the Zap button when viewing a note." description="Shows the Zap button when viewing a note."
label="display_zap_button" label="display_zap_button"
checked={newSettings.display_zap_button}
setNewSettings={setNewSettings}
/> />
<Setting <Setting
name="Show Repost Button" name="Show Repost Button"
description="Shows the Repost button when viewing a note." description="Shows the Repost button when viewing a note."
label="display_repost_button" label="display_repost_button"
checked={newSettings.display_repost_button}
setNewSettings={setNewSettings}
/> />
</div> </div>
</div> </div>
@@ -116,25 +144,23 @@ function Screen() {
</h2> </h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl"> <div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<Setting <Setting
name="Proxy" name="Resize Service"
description="Set proxy address." description="Use weserv for resize image on-the-fly."
label="proxy" label="resize_service"
checked={newSettings.resize_service}
setNewSettings={setNewSettings}
/> />
<Setting <Setting
name="Image Resize Service" name="Show Remote Media"
description="Use weserv/images for resize image on-the-fly." description="Automatically load remote media."
label="image_resize_service"
/>
<Setting
name="Load Remote Media"
description="View the remote media directly."
label="display_media" label="display_media"
checked={newSettings.display_media}
setNewSettings={setNewSettings}
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="sticky bottom-0 left-0 w-full h-16 flex items-center justify-end px-3"> <div className="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 <button
type="button" type="button"
onClick={() => updateSettings()} onClick={() => updateSettings()}
@@ -151,33 +177,37 @@ function Setting({
label, label,
name, name,
description, description,
checked,
setNewSettings,
}: { }: {
label: string; label: string;
name: string; name: string;
description: string; description: string;
checked: boolean;
setNewSettings: Dispatch<SetStateAction<Settings | undefined>>;
}) { }) {
const state = useStore(appSettings, (state) => state[label]);
const toggle = useCallback(() => { const toggle = useCallback(() => {
appSettings.setState((state) => { setNewSettings((state) => {
return { if (state) {
...state, return {
[label]: !state[label], ...state,
}; [label]: !state[label],
};
}
}); });
}, []); }, []);
return ( return (
<div className="flex items-start justify-between w-full gap-4 py-3"> <div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1"> <div className="flex-1">
<h3 className="font-medium">{name}</h3> <h3 className="text-sm font-medium">{name}</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300"> <p className="text-xs text-neutral-700 dark:text-neutral-300">
{description} {description}
</p> </p>
</div> </div>
<div className="flex justify-end w-36 shrink-0"> <div className="flex justify-end w-36 shrink-0">
<Switch.Root <Switch.Root
checked={state} checked={checked}
onClick={() => toggle()} onClick={() => toggle()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10" className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
> >

View File

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

View File

@@ -1,245 +0,0 @@
import { type Profile, commands } from "@/commands.gen";
import { cn, upload } from "@/commons";
import { Spinner } from "@/components";
import { Plus } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import {
type Dispatch,
type ReactNode,
type SetStateAction,
useState,
useTransition,
} from "react";
import { useForm } from "react-hook-form";
export const Route = createLazyFileRoute("/settings/$id/profile")({
component: Screen,
});
function Screen() {
const { profile } = Route.useRouteContext();
const { register, handleSubmit } = useForm({ defaultValues: profile });
const [isPending, startTransition] = useTransition();
const [picture, setPicture] = useState<string>("");
const onSubmit = (data: Profile) => {
startTransition(async () => {
const newProfile: Profile = { ...profile, ...data, picture };
const res = await commands.setProfile(newProfile);
if (res.status === "error") {
await message(res.error, { title: "Profile", kind: "error" });
}
return;
});
};
return (
<div className="relative flex flex-col gap-6 px-3 pb-3">
<div className="flex items-center flex-1 h-full gap-3">
<div className="relative rounded-full size-20 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{profile.picture ? (
<img
src={picture || profile.picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 object-cover size-20 rounded-full"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
className="absolute inset-0 z-20 flex items-center justify-center size-full text-white rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<Plus className="size-5" />
</AvatarUploader>
</div>
<div className="flex-1 flex items-center justify-between">
<div>
<div className="text-lg font-semibold">{profile.display_name}</div>
<div className="text-neutral-700 dark:text-neutral-300">
{profile.nip05}
</div>
</div>
<PrivkeyButton />
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3 mb-0"
>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="display_name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Display Name
</label>
<input
name="display_name"
{...register("display_name")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Name
</label>
<input
name="name"
{...register("name")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="website"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Website
</label>
<input
name="website"
type="url"
{...register("website")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="banner"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Cover
</label>
<input
name="banner"
type="url"
{...register("banner")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="nip05"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
NIP-05
</label>
<input
name="nip05"
type="email"
{...register("nip05")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="lnaddress"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Lightning Address
</label>
<input
name="lnaddress"
type="email"
{...register("lud16")}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex items-center justify-end">
<button
type="submit"
disabled={isPending}
className="inline-flex items-center justify-center w-32 px-2 text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? <Spinner className="size-4" /> : "Update Profile"}
</button>
</div>
</form>
</div>
);
}
function PrivkeyButton() {
const { id } = Route.useParams();
const [isPending, startTransition] = useTransition();
const [isCopy, setIsCopy] = useState(false);
const copyPrivateKey = () => {
startTransition(async () => {
const res = await commands.getPrivateKey(id);
if (res.status === "ok") {
await writeText(res.data);
setIsCopy(true);
} else {
await message(res.error, { kind: "error" });
return;
}
});
};
return (
<button
type="button"
onClick={() => copyPrivateKey()}
className="inline-flex items-center justify-center px-3 text-sm font-medium text-blue-500 bg-blue-100 border border-blue-300 rounded-full h-7 hover:bg-blue-200 dark:bg-blue-900 dark:border-blue-800 dark:text-blue-300 dark:hover:bg-blue-800"
>
{isPending ? (
<Spinner className="size-4" />
) : isCopy ? (
"Copied"
) : (
"Copy Private Key"
)}
</button>
);
}
function AvatarUploader({
setPicture,
children,
className,
}: {
setPicture: Dispatch<SetStateAction<string>>;
children: ReactNode;
className?: string;
}) {
const [isPending, startTransition] = useTransition();
const uploadAvatar = () => {
startTransition(async () => {
try {
const image = await upload();
setPicture(image);
} catch (e) {
await message(String(e));
return;
}
});
};
return (
<button
type="button"
onClick={() => uploadAvatar()}
className={cn("size-4", className)}
>
{isPending ? <Spinner className="size-4" /> : children}
</button>
);
}

View File

@@ -97,21 +97,12 @@ export const LumeWindow = {
}); });
}, },
openEditor: async (reply_to?: string, quote?: string) => { openEditor: async (reply_to?: string, quote?: string) => {
let url: string;
if (reply_to) {
url = `/new-post?reply_to=${reply_to}`;
}
if (quote?.length) {
url = `/new-post?quote=${quote}`;
}
if (!reply_to?.length && !quote?.length) {
url = "/new-post";
}
const label = `editor-${reply_to ? reply_to : 0}`; const label = `editor-${reply_to ? reply_to : 0}`;
const url = reply_to
? `/new-post?reply_to=${reply_to}`
: quote?.length
? `/new-post?quote=${quote}`
: "/new-post";
const query = await commands.openWindow({ const query = await commands.openWindow({
label, label,
url, url,
@@ -145,7 +136,7 @@ export const LumeWindow = {
hidden_title: true, hidden_title: true,
closable: true, closable: true,
}); });
} else { } else if (account) {
await LumeWindow.openSettings(account, "wallet"); await LumeWindow.openSettings(account, "wallet");
} }
}, },