1
0
mirror of https://github.com/lumehq/lume.git synced 2025-03-19 14:21:45 +01:00

refactor: app settings

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

@ -24,8 +24,6 @@
"@tanstack/query-persist-client-core": "^5.59.16",
"@tanstack/react-query": "^5.59.16",
"@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/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.1",

6
pnpm-lock.yaml generated

@ -50,12 +50,6 @@ importers:
'@tanstack/react-router':
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)
'@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':
specifier: ^2.0.3
version: 2.0.3

16
src-tauri/Cargo.lock generated

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

@ -5,7 +5,7 @@ use specta::Type;
use std::{str::FromStr, time::Duration};
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)]
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(())
}

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

@ -7,21 +7,9 @@ use tauri::{Emitter, Manager, State};
use crate::{
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)]
pub struct Mention {
pubkey: String,
@ -116,30 +104,9 @@ pub async fn get_contact_list(id: String, state: State<'_, Nostr>) -> Result<Vec
#[tauri::command]
#[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 mut metadata = Metadata::new()
.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)
}
}
let metadata = Metadata::from_json(new_profile).map_err(|e| e.to_string())?;
match client.set_metadata(&metadata).await {
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]
#[specta::specta]
pub async fn verify_nip05(id: String, nip05: &str) -> Result<bool, String> {

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

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

@ -136,9 +136,9 @@ async getProfile(id: string) : Promise<Result<string, string>> {
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 {
return { status: "ok", data: await TAURI_INVOKE("set_profile", { profile }) };
return { status: "ok", data: await TAURI_INVOKE("set_profile", { newProfile }) };
} catch (e) {
if(e instanceof Error) throw e;
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 };
}
},
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>> {
try {
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;
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 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 Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null }
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
export type RichEvent = { raw: string; parsed: Meta | null }
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; 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
/** tauri-specta globals **/

@ -3,7 +3,6 @@ import type {
MaybePromise,
PersistedQuery,
} from "@tanstack/query-persist-client-core";
import { Store } from "@tanstack/react-store";
import { ask, message, open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { relaunch } from "@tauri-apps/plugin-process";
@ -16,76 +15,79 @@ import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale";
import { decode } from "light-bolt11-decoder";
import { twMerge } from "tailwind-merge";
import type { RichEvent, Settings } from "./commands.gen";
import type { RichEvent } from "./commands.gen";
import { LumeEvent } from "./system";
import type { LumeColumn, 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",
},
});
import type { NostrEvent } from "./types";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const isImagePath = (path: string) => {
for (const suffix of ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"]) {
if (path.endsWith(suffix)) return true;
const exts = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
for (const suffix of exts) {
if (path.endsWith(suffix)) {
return true;
}
}
return false;
};
export const isImageUrl = (url: string) => {
try {
if (!url) return false;
const ext = new URL(url).pathname.split(".").pop();
return ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"].includes(ext);
} catch {
return false;
}
};
export function createdAt(time: number) {
// Config for dayjs
dayjs.extend(relativeTime);
dayjs.extend(updateLocale);
export function formatCreatedAt(time: number, message = false) {
let formated: string;
// 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 now = dayjs();
const inputTime = dayjs.unix(time);
const diff = now.diff(inputTime, "hour");
if (message) {
if (diff < 12) {
formated = inputTime.format("HH:mm A");
} else {
formated = inputTime.format("MMM DD");
}
if (diff < 24) {
return inputTime.from(now, true);
} else {
if (diff < 24) {
formated = inputTime.from(now, true);
} else {
formated = inputTime.format("MMM DD");
}
return inputTime.format("MMM DD");
}
return formated;
}
export function replyTime(time: number) {
const inputTime = dayjs.unix(time);
const formated = inputTime.format("MM-DD-YY HH:mm");
export function replyAt(time: number) {
// Config for dayjs
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) {
@ -114,7 +116,7 @@ export function displayLongHandle(str: string) {
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) {
let bitcoinFormatted = new BitcoinUnit(satoshis, "satoshis")
.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];
if (!invoice) return;
const decodedInvoice = decode(invoice);
const amountSection = decodedInvoice.sections.find(
const section = decodedInvoice.sections.find(
(s: { name: string }) => s.name === "amount",
);
// @ts-ignore, its fine.
const amount = Number.parseInt(amountSection.value) / 1000;
const displayValue = getBitcoinDisplayValues(amount);
if (!section) {
return null;
}
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) {
@ -277,18 +286,3 @@ export function newQueryStorage(
(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[]>([]);

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

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

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

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

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

@ -1,23 +1,24 @@
import { appSettings, cn } from "@/commons";
import { cn } from "@/commons";
import { Spinner } from "@/components";
import { settingsQueryOptions } from "@/routes/__root";
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 useEmblaCarousel from "embla-carousel-react";
import { useCallback, useEffect, useMemo, useState } from "react";
export function Images({ urls }: { urls: string[] }) {
const [slidesInView, setSlidesInView] = useState([]);
const [slidesInView, setSlidesInView] = useState<number[]>([]);
const [emblaRef, emblaApi] = useEmblaCarousel({
dragFree: true,
align: "start",
watchSlides: false,
});
const service = useStore(appSettings, (state) => state.image_resize_service);
const settings = useSuspenseQuery(settingsQueryOptions);
const imageUrls = useMemo(() => {
if (service?.length) {
if (settings.data.resize_service) {
let newUrls: string[];
if (urls.length === 1) {
@ -28,11 +29,12 @@ export function Images({ urls }: { urls: string[] }) {
if (url.includes("bsky.network")) {
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 {
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 {
return urls;
}
}, [service]);
}, [settings.data.resize_service]);
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev();
@ -50,23 +52,27 @@ export function Images({ urls }: { urls: string[] }) {
if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]);
const updateSlidesInView = useCallback((emblaApi) => {
const updateSlidesInView = useCallback(() => {
setSlidesInView((slidesInView) => {
if (slidesInView.length === emblaApi.slideNodes().length) {
emblaApi.off("slidesInView", updateSlidesInView);
if (slidesInView.length === emblaApi?.slideNodes().length) {
emblaApi?.off("slidesInView", updateSlidesInView);
}
const inView = emblaApi
.slidesInView()
?.slidesInView()
.filter((index) => !slidesInView.includes(index));
return slidesInView.concat(inView);
if (inView) {
return slidesInView.concat(inView);
} else {
return slidesInView;
}
});
}, []);
}, [emblaApi]);
useEffect(() => {
if (emblaApi && urls.length > 1) {
updateSlidesInView(emblaApi);
updateSlidesInView();
emblaApi.on("slidesInView", updateSlidesInView);
emblaApi.on("reInit", updateSlidesInView);

@ -1,10 +1,10 @@
import { appSettings } from "@/commons";
import { useStore } from "@tanstack/react-store";
import { settingsQueryOptions } from "@/routes/__root";
import { useSuspenseQuery } from "@tanstack/react-query";
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 (
<a
href={url}

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

@ -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 { useStore } from "@tanstack/react-store";
import { useSuspenseQuery } from "@tanstack/react-query";
import { minidenticon } from "minidenticons";
import { useMemo } from "react";
import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) {
const [service, visible] = useStore(appSettings, (state) => [
state.image_resize_service,
state.display_avatar,
]);
const settings = useSuspenseQuery(settingsQueryOptions);
const user = useUserContext();
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/")) {
return user.profile?.picture;
}
if (user.profile?.picture.includes("bsky.network")) {
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 {
return user.profile?.picture;
return user?.profile?.picture;
}
}, [user.profile?.picture]);
}, [user]);
const fallback = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey, 60, 50),
minidenticon(user ? user.pubkey : "lume", 60, 50),
)}`,
[user.pubkey],
[user],
);
return (
@ -42,18 +40,19 @@ export function UserAvatar({ className }: { className?: string }) {
className,
)}
>
{visible ? (
{settings.data.display_avatar ? (
<Avatar.Image
src={picture}
alt={user.pubkey}
alt={user?.pubkey}
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]"
/>
) : null}
<Avatar.Fallback>
<img
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]"
/>
</Avatar.Fallback>

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

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

@ -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>
);
}

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

@ -1,20 +1,38 @@
import { commands } from "@/commands.gen";
import { Spinner } from "@/components";
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 { getCurrentWindow } from "@tauri-apps/api/window";
import type { OsType } from "@tauri-apps/plugin-os";
import type { Store } from "@tauri-apps/plugin-store";
import { useEffect } from "react";
interface RouterContext {
store: Store;
queryClient: QueryClient;
platform: OsType;
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>()({
component: Screen,
pendingComponent: Pending,
loader: ({ context }) =>
context.queryClient.ensureQueryData(settingsQueryOptions),
});
function Screen() {

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

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

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

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

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

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

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

@ -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>
);
}

@ -97,21 +97,12 @@ export const LumeWindow = {
});
},
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 url = reply_to
? `/new-post?reply_to=${reply_to}`
: quote?.length
? `/new-post?quote=${quote}`
: "/new-post";
const query = await commands.openWindow({
label,
url,
@ -145,7 +136,7 @@ export const LumeWindow = {
hidden_title: true,
closable: true,
});
} else {
} else if (account) {
await LumeWindow.openSettings(account, "wallet");
}
},